feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze
Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
This commit is contained in:
parent
564cf6b18f
commit
a03c85f08a
13 changed files with 726 additions and 172 deletions
|
|
@ -0,0 +1,7 @@
|
|||
-- Migration 018: Add filmstrip_s3_key to assets
|
||||
-- Stores the S3 path to a JSON array of base64 JPEG frames generated
|
||||
-- server-side by the filmstrip worker. Allows the UI to fetch a pre-built
|
||||
-- filmstrip instead of seeking through the proxy in the browser.
|
||||
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN IF NOT EXISTS filmstrip_s3_key TEXT DEFAULT NULL;
|
||||
|
|
@ -30,6 +30,10 @@ const trimQueue = new Queue('trim', {
|
|||
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
||||
});
|
||||
|
||||
const filmstripQueue = new Queue('filmstrip', {
|
||||
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
||||
});
|
||||
|
||||
// GET / - List assets with filtering
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
|
|
@ -404,34 +408,49 @@ router.post('/backfill-proxies', async (_req, res, next) => {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/reprocess?type=proxy|thumbnail
|
||||
// Force-requeue a proxy or thumbnail job regardless of current asset status.
|
||||
// Different from /retry which only works when status=error or proxy is missing.
|
||||
// POST /:id/reprocess?type=proxy|thumbnail|filmstrip
|
||||
// Force-requeue a processing job regardless of current asset status.
|
||||
router.post('/:id/reprocess', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const type = req.query.type || 'proxy';
|
||||
if (!['proxy', 'thumbnail'].includes(type)) {
|
||||
return res.status(400).json({ error: 'type must be "proxy" or "thumbnail"' });
|
||||
if (!['proxy', 'thumbnail', 'filmstrip'].includes(type)) {
|
||||
return res.status(400).json({ error: 'type must be "proxy", "thumbnail", or "filmstrip"' });
|
||||
}
|
||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
const asset = r.rows[0];
|
||||
if (!asset.original_s3_key) {
|
||||
return res.status(400).json({ error: 'Asset has no source file to reprocess' });
|
||||
}
|
||||
if (type === 'proxy') {
|
||||
if (!asset.original_s3_key) return res.status(400).json({ error: 'Asset has no source file' });
|
||||
const proxyKey = `proxies/${id}.mp4`;
|
||||
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey });
|
||||
await pool.query(`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1`, [id]);
|
||||
return res.json({ queued: 'proxy', assetId: id });
|
||||
}
|
||||
if (type === 'thumbnail') {
|
||||
const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`;
|
||||
if (!asset.proxy_s3_key) return res.status(400).json({ error: 'Asset has no proxy' });
|
||||
const thumbnailKey = `thumbnails/${id}.jpg`;
|
||||
await thumbnailQueue.add('generate', { assetId: id, proxyKey, outputKey: thumbnailKey });
|
||||
await thumbnailQueue.add('generate', { assetId: id, proxyKey: asset.proxy_s3_key, outputKey: thumbnailKey });
|
||||
return res.json({ queued: 'thumbnail', assetId: id });
|
||||
}
|
||||
if (type === 'filmstrip') {
|
||||
if (!asset.proxy_s3_key) return res.status(400).json({ error: 'Asset has no proxy — generate proxy first' });
|
||||
await filmstripQueue.add('generate', { assetId: id, proxyKey: asset.proxy_s3_key });
|
||||
return res.json({ queued: 'filmstrip', assetId: id });
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:id/filmstrip — returns signed URL to the pre-built filmstrip JSON
|
||||
router.get('/:id/filmstrip', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const r = await pool.query('SELECT filmstrip_s3_key FROM assets WHERE id = $1', [id]);
|
||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
const { filmstrip_s3_key } = r.rows[0];
|
||||
if (!filmstrip_s3_key) return res.json({ url: null, ready: false });
|
||||
const url = await getSignedUrlForObject(filmstrip_s3_key);
|
||||
res.json({ url, ready: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379');
|
|||
|
||||
const proxyQueue = new Queue('proxy', { connection: redisConn });
|
||||
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
|
||||
const filmstripQueue = new Queue('filmstrip', { connection: redisConn });
|
||||
const conformQueue = new Queue('conform', { connection: redisConn });
|
||||
const importQueue = new Queue('import', { connection: redisConn });
|
||||
const trimQueue = new Queue('trim', { connection: redisConn });
|
||||
|
|
@ -27,6 +28,7 @@ const trimQueue = new Queue('trim', { connection: redisConn });
|
|||
const QUEUES = [
|
||||
{ queue: proxyQueue, type: 'proxy' },
|
||||
{ queue: thumbnailQueue, type: 'thumbnail' },
|
||||
{ queue: filmstripQueue, type: 'filmstrip' },
|
||||
{ queue: conformQueue, type: 'conform' },
|
||||
{ queue: importQueue, type: 'import' },
|
||||
{ queue: trimQueue, type: 'trim' },
|
||||
|
|
|
|||
|
|
@ -111,7 +111,6 @@ async function tick() {
|
|||
const staleResult = await pool.query(
|
||||
`UPDATE assets
|
||||
SET status = 'error',
|
||||
error_message = 'Recording timed out — capture ended unexpectedly',
|
||||
updated_at = NOW()
|
||||
WHERE status = 'live'
|
||||
AND created_at < NOW() - ($1 || ' minutes')::INTERVAL
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<ExtensionManifest xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
ExtensionBundleId="net.wilddragon.dragonflight.panel"
|
||||
ExtensionBundleName="Wild Dragon MAM"
|
||||
ExtensionBundleVersion="1.0.1"
|
||||
ExtensionBundleVersion="1.1.0"
|
||||
Version="7.0">
|
||||
<ExtensionList>
|
||||
<Extension Id="net.wilddragon.dragonflight.panel" Version="1.0" />
|
||||
|
|
|
|||
230
services/premiere-plugin/FEATURE_SPEC.md
Normal file
230
services/premiere-plugin/FEATURE_SPEC.md
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
# Premiere Pro Plugin - Complete Workflow Specification
|
||||
|
||||
## Overview
|
||||
|
||||
The Wild Dragon MAM Premiere Pro plugin provides three primary workflows for proxy-based editing with hi-res delivery, plus a dedicated interface for growing files (live capture).
|
||||
|
||||
---
|
||||
|
||||
## Workflow 1: Proxy Edit with Server-Side Conform (Export to MAM)
|
||||
|
||||
**Status:** ✅ Already implemented (FCP XML Export & Conform)
|
||||
|
||||
### User Story
|
||||
Editor works with proxy files in Premiere, completes the edit, then exports the timeline to the MAM server. The server renders the hi-res master using FFmpeg and stores it as a new MAM asset.
|
||||
|
||||
### Workflow Steps
|
||||
1. Editor imports proxy assets from MAM into Premiere via plugin
|
||||
2. Editor completes timeline edit using proxies
|
||||
3. Editor clicks **Advanced > Export & Conform Timeline**
|
||||
4. Plugin generates FCP XML from active sequence
|
||||
5. Plugin uploads XML to MAM API endpoint `POST /api/sequences/conform`
|
||||
6. Server-side FFmpeg worker:
|
||||
- Parses FCP XML
|
||||
- Fetches hi-res originals from S3
|
||||
- Renders timeline with selected codec/quality preset
|
||||
- Uploads rendered file to S3
|
||||
- Creates new asset in MAM with name "Conformed: [Sequence Name]"
|
||||
7. Plugin polls job status and shows progress
|
||||
8. On completion, conformed asset appears in MAM library
|
||||
|
||||
### Technical Details
|
||||
- **Codec presets:** ProRes, H.264, H.265/HEVC
|
||||
- **Quality levels:** Low, High, Broadcast
|
||||
- **Resolution options:** Match sequence, 4K, 1080p, 720p
|
||||
- **Audio:** Optional stereo/AAC
|
||||
- **Job queue:** BullMQ FIFO, concurrency 1
|
||||
- **Storage:** S3 at `projects/<projectId>/conformed/<filename>`
|
||||
|
||||
### Current Implementation
|
||||
- File: `services/premiere-plugin/jsx/premiere.jsx` (FCP XML export)
|
||||
- File: `services/premiere-plugin/js/main.js` (conform UI)
|
||||
- API: `services/mam-api/src/routes/sequences.js`
|
||||
- Worker: `services/worker/src/workers/conform.js`
|
||||
|
||||
---
|
||||
|
||||
## Workflow 2: Local Export with Hi-Res Segment Fetch
|
||||
|
||||
**Status:** ✅ Already implemented (Hi-Res Auto-Relink)
|
||||
|
||||
### User Story
|
||||
Editor works with proxy files in Premiere on a remote machine. When ready for final export, editor requests hi-res segments for only the used portions of each clip. Server renders frame-accurate trimmed segments, sends them back to the local machine, and plugin relinks them in Premiere. Editor then exports locally at full quality.
|
||||
|
||||
### Workflow Steps
|
||||
1. Editor imports proxy assets from MAM into Premiere via plugin
|
||||
2. Editor completes timeline edit using proxies
|
||||
3. Editor clicks **Advanced > Fetch & Relink All**
|
||||
4. Plugin analyzes active sequence and lists all proxy clips with in/out times
|
||||
5. Editor reviews clip list and deselects any clips to keep as proxy
|
||||
6. Editor clicks **Fetch & Relink**
|
||||
7. For each selected clip:
|
||||
- Plugin sends trim request to `POST /api/assets/batch-trim`
|
||||
- Server-side FFmpeg worker fetches hi-res original from S3
|
||||
- Worker renders frame-accurate segment (no handles) using stream copy where possible
|
||||
- Worker uploads segment to S3 at `temp-segments/<clipInstanceId>.mov`
|
||||
- Worker returns presigned download URL
|
||||
8. Plugin downloads each segment to local temp directory
|
||||
9. Plugin calls `ProjectItem.changeMediaPath()` to relink each clip in place
|
||||
10. Timeline cuts are preserved; clips now reference hi-res segments
|
||||
11. Editor exports timeline locally from Premiere (File > Export)
|
||||
|
||||
### Technical Details
|
||||
- **Segment storage:** S3 prefix `temp-segments/`
|
||||
- **TTL:** 24 hours (automatic cleanup via hourly task)
|
||||
- **Job queue:** BullMQ FIFO, concurrency 4
|
||||
- **Relink method:** ExtendScript `changeMediaPath()`
|
||||
- **Cleanup:** `services/mam-api/src/tasks/cleanupTempSegments.js`
|
||||
|
||||
### Current Implementation
|
||||
- File: `services/premiere-plugin/jsx/premiere.jsx` (timeline analysis, relink)
|
||||
- File: `services/premiere-plugin/js/main.js` (relink UI)
|
||||
- API: `services/mam-api/src/routes/assets.js` (batch-trim endpoint)
|
||||
- Worker: `services/worker/src/workers/trimWorker.js`
|
||||
- DB table: `temp_segments`
|
||||
|
||||
---
|
||||
|
||||
## Workflow 3: Growing Files Tab (NEW)
|
||||
|
||||
**Status:** ❌ Not implemented
|
||||
|
||||
### User Story
|
||||
Editor wants to see and work with clips that are currently being captured to the SMB landing zone (growing files) separately from the main S3-backed asset library. A dedicated "Growing" tab shows live captures, allows mounting them for live editing, and shows promotion status.
|
||||
|
||||
### Requirements
|
||||
|
||||
#### UI Changes
|
||||
1. **New tab in plugin:** Add "Growing" tab alongside existing asset grid
|
||||
2. **Tab navigation:** Toggle between "Library" (S3 assets) and "Growing" (SMB assets)
|
||||
3. **Growing tab layout:**
|
||||
- Grid view similar to main asset grid
|
||||
- Show assets with `status=live` or `status=ingesting`
|
||||
- Display live preview thumbnail (from HLS stream if available)
|
||||
- Show recording indicator (red dot + duration counter)
|
||||
- Show promotion status badge:
|
||||
- "Recording" (red) - actively capturing
|
||||
- "Idle" (yellow) - capture stopped, waiting for promotion
|
||||
- "Promoting" (blue) - uploading to S3
|
||||
- "Ready" (green) - promoted to S3, ready to relink
|
||||
|
||||
#### Functionality
|
||||
1. **Mount Live:** Button to import growing file from SMB share into Premiere
|
||||
- Calls `GET /api/assets/:id/live-path`
|
||||
- Resolves SMB UNC path (e.g., `\\10.0.0.25\mam-growing\<projectId>\<filename>`)
|
||||
- Calls `app.project.importFiles([uncPath])`
|
||||
- Premiere imports still-growing file for live editing
|
||||
2. **Relink to Hi-Res:** Button appears when asset transitions to `status=ready`
|
||||
- Downloads finalized hi-res from S3
|
||||
- Calls `ProjectItem.changeMediaPath()` to relink
|
||||
- Timeline cuts preserved
|
||||
3. **Auto-refresh:** Poll every 5 seconds to update status badges and promotion progress
|
||||
4. **Filter by project:** Same project filter as main library tab
|
||||
|
||||
#### API Requirements
|
||||
- `GET /api/assets?status=live` - fetch growing files
|
||||
- `GET /api/assets/:id/live-path` - resolve SMB UNC path
|
||||
- Existing promotion worker already handles status transitions
|
||||
|
||||
#### Technical Details
|
||||
- **SMB mount:** Editor must mount `smb://10.0.0.25/mam-growing` at OS level
|
||||
- **Polling interval:** 5 seconds
|
||||
- **Status transitions:** `live` → `ingesting` → `ready`
|
||||
- **Promotion trigger:** `growing_promote_after_seconds` of mtime inactivity (default 300s)
|
||||
|
||||
---
|
||||
|
||||
## Settings Integration
|
||||
|
||||
All workflows respect the following MAM settings (Settings → Growing files):
|
||||
|
||||
- `growing_enabled` - Master switch for growing files feature
|
||||
- `growing_path` - Container mount path (default `/growing`)
|
||||
- `growing_smb_url` - SMB URL for plugin (e.g., `smb://10.0.0.25/mam-growing`)
|
||||
- `growing_promote_after_seconds` - Idle threshold before promotion (default 300)
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
### Deployment Targets
|
||||
- **ZAMPP1** (online) - MAM API + worker container
|
||||
- **ZAMPP2** (online) - MAM API + worker container
|
||||
- **BMG-PC-Edit** (online) - Premiere Pro workstation with plugin installed
|
||||
|
||||
### Storage
|
||||
- **S3:** Hi-res masters at `projects/<projectId>/masters/`
|
||||
- **S3:** Proxies at `projects/<projectId>/proxies/`
|
||||
- **S3:** Conformed exports at `projects/<projectId>/conformed/`
|
||||
- **S3:** Temp segments at `temp-segments/` (24h TTL)
|
||||
- **SMB:** Growing files at `/mnt/NVME/MAM-growing/<projectId>/` (TrueNAS)
|
||||
|
||||
### Network Requirements
|
||||
- Plugin → MAM API: HTTP/HTTPS
|
||||
- Plugin → SMB share: SMB 2.0+ (passwordless on LAN)
|
||||
- MAM API → S3: HTTPS (presigned URLs)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Workflow 1: Proxy Edit with Server-Side Conform
|
||||
- [x] FCP XML export from Premiere
|
||||
- [x] Server-side conform worker
|
||||
- [x] Codec presets (ProRes, H.264, H.265)
|
||||
- [x] Job queue and progress tracking
|
||||
- [x] Asset creation in MAM
|
||||
|
||||
### Workflow 2: Local Export with Hi-Res Segments
|
||||
- [x] Timeline analysis and clip detection
|
||||
- [x] Batch trim API endpoint
|
||||
- [x] Frame-accurate segment rendering
|
||||
- [x] Temp segment storage with TTL
|
||||
- [x] Auto-relink in Premiere
|
||||
- [x] Cleanup task for expired segments
|
||||
|
||||
### Workflow 3: Growing Files Tab
|
||||
- [x] Add "Growing" tab to plugin UI
|
||||
- [x] Fetch and display `status=live` assets
|
||||
- [x] Show recording indicator and duration
|
||||
- [x] Show promotion status badges
|
||||
- [x] Implement "Mount Live" button (existing, now exposed via Growing tab)
|
||||
- [x] Implement "Relink to Hi-Res" button (existing, now exposed via Growing tab)
|
||||
- [x] Auto-refresh polling (5s interval)
|
||||
- [x] Project filter for growing tab
|
||||
- [x] Handle SMB path resolution (existing live-path endpoint)
|
||||
- [x] Error handling for unmounted SMB share (existing)
|
||||
|
||||
---
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Workflow 1 Testing
|
||||
1. Import proxies, edit timeline, export via plugin
|
||||
2. Verify conform job completes
|
||||
3. Verify conformed asset appears in MAM
|
||||
4. Test all codec presets
|
||||
5. Test error handling (missing hi-res, worker offline)
|
||||
|
||||
### Workflow 2 Testing
|
||||
1. Import proxies, edit timeline, fetch hi-res segments
|
||||
2. Verify segments download and relink
|
||||
3. Verify timeline cuts preserved
|
||||
4. Export locally and verify quality
|
||||
5. Verify temp segments cleaned up after 24h
|
||||
|
||||
### Workflow 3 Testing
|
||||
1. Start recorder, verify asset appears in Growing tab
|
||||
2. Click "Mount Live", verify import into Premiere
|
||||
3. Edit while recording continues
|
||||
4. Stop recorder, verify promotion status updates
|
||||
5. Click "Relink to Hi-Res", verify relink completes
|
||||
6. Verify timeline cuts preserved after relink
|
||||
7. Test with unmounted SMB share (error handling)
|
||||
|
||||
---
|
||||
|
||||
## Related Issues
|
||||
|
||||
- Issue #92: Growing Files: Per-upload toggle, retention controls, and seamless relinking
|
||||
- Commit c312991: feat: implement advanced features (conform, auto-relink, GUI redesign)
|
||||
|
|
@ -2,32 +2,32 @@
|
|||
/* OKLCH tokens aligned with web-ui/common.css */
|
||||
|
||||
:root {
|
||||
--bg-deep: oklch(8% 0.011 266);
|
||||
--bg-base: oklch(11% 0.010 266);
|
||||
--bg-panel: oklch(15% 0.013 266);
|
||||
--bg-surface: oklch(19% 0.014 266);
|
||||
--bg-raised: oklch(24% 0.015 266);
|
||||
--bg-hover: oklch(28% 0.015 266);
|
||||
--bg-deep: oklch(8% 0.011 32);
|
||||
--bg-base: oklch(11% 0.010 32);
|
||||
--bg-panel: oklch(15% 0.013 32);
|
||||
--bg-surface: oklch(19% 0.014 32);
|
||||
--bg-raised: oklch(24% 0.015 32);
|
||||
--bg-hover: oklch(28% 0.015 32);
|
||||
|
||||
--accent: oklch(45% 0.20 266);
|
||||
--accent: oklch(62% 0.22 32);
|
||||
--accent-dim: oklch(56% 0.130 52);
|
||||
--accent-subtle: oklch(55% 0.20 266 / 0.12);
|
||||
--accent-border: oklch(55% 0.20 266 / 0.36);
|
||||
--accent-subtle: oklch(62% 0.22 32 / 0.12);
|
||||
--accent-border: oklch(62% 0.22 32 / 0.36);
|
||||
|
||||
--text-primary: oklch(94% 0.008 266);
|
||||
--text-secondary: oklch(72% 0.014 266);
|
||||
--text-tertiary: oklch(52% 0.012 266);
|
||||
--text-disabled: oklch(38% 0.010 266);
|
||||
--text-primary: oklch(94% 0.008 32);
|
||||
--text-secondary: oklch(72% 0.014 32);
|
||||
--text-tertiary: oklch(56% 0.012 32);
|
||||
--text-disabled: oklch(38% 0.010 32);
|
||||
|
||||
--border-faint: oklch(22% 0.013 266);
|
||||
--border: oklch(28% 0.015 266);
|
||||
--border-strong: oklch(38% 0.018 266);
|
||||
--border-faint: oklch(22% 0.013 32);
|
||||
--border: oklch(28% 0.015 32);
|
||||
--border-strong: oklch(38% 0.018 32);
|
||||
|
||||
--status-green: oklch(70% 0.18 148);
|
||||
--status-red: oklch(64% 0.22 25);
|
||||
--status-blue: oklch(67% 0.16 245);
|
||||
--status-yellow: oklch(80% 0.16 90);
|
||||
--status-gray: oklch(58% 0.012 266);
|
||||
--status-gray: oklch(58% 0.012 32);
|
||||
|
||||
--status-green-bg: oklch(70% 0.18 148 / 0.12);
|
||||
--status-red-bg: oklch(64% 0.22 25 / 0.12);
|
||||
|
|
@ -201,17 +201,17 @@ html, body {
|
|||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: oklch(11% 0.010 250);
|
||||
color: oklch(11% 0.010 32);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: oklch(52% 0.21 266);
|
||||
border-color: oklch(52% 0.21 266);
|
||||
background: oklch(68% 0.22 32);
|
||||
border-color: oklch(68% 0.22 32);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
background: oklch(40% 0.19 266);
|
||||
background: oklch(56% 0.20 32);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
|
|
@ -408,6 +408,65 @@ select option { background: var(--bg-surface); color: var(--text-primary); }
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
TAB NAVIGATION
|
||||
================================================================ */
|
||||
|
||||
.tab-nav {
|
||||
display: flex;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
cursor: pointer;
|
||||
transition: all var(--t-fast);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.tab-btn .badge {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.tab-btn.active .badge {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
MAIN CONTENT AREA
|
||||
================================================================ */
|
||||
|
|
@ -526,12 +585,26 @@ select option { background: var(--bg-surface); color: var(--text-primary); }
|
|||
color: var(--status-green);
|
||||
}
|
||||
|
||||
.status-badge.live,
|
||||
.status-badge.recording {
|
||||
background: var(--status-red-bg);
|
||||
color: var(--status-red);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-badge.ingesting,
|
||||
.status-badge.promoting,
|
||||
.status-badge.processing {
|
||||
background: var(--status-blue-bg);
|
||||
color: var(--status-blue);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-badge.idle {
|
||||
background: var(--status-yellow-bg);
|
||||
color: var(--status-yellow);
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: var(--status-red-bg);
|
||||
color: var(--status-red);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,25 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="tab-nav">
|
||||
<button id="tab-library" class="tab-btn active">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||
</svg>
|
||||
Library
|
||||
</button>
|
||||
<button id="tab-growing" class="tab-btn">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M23 7l-7 5 7 5V7z"/>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
|
||||
</svg>
|
||||
Growing
|
||||
<span id="growing-count" class="badge">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Area -->
|
||||
<div class="search-filter-area">
|
||||
<div class="search-bar">
|
||||
|
|
@ -61,8 +80,8 @@
|
|||
|
||||
<!-- Main Content Area -->
|
||||
<div class="content-area">
|
||||
<!-- Asset Grid -->
|
||||
<div class="asset-grid-container">
|
||||
<!-- Library Grid -->
|
||||
<div class="asset-grid-container" id="library-container">
|
||||
<div id="asset-grid" class="asset-grid">
|
||||
<div id="empty-state" class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
|
|
@ -77,6 +96,28 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Growing Grid -->
|
||||
<div class="asset-grid-container hidden" id="growing-container">
|
||||
<div id="growing-grid" class="asset-grid">
|
||||
<div id="growing-empty-state" class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
|
||||
<line x1="7" y1="2" x2="7" y2="22"/>
|
||||
<line x1="17" y1="2" x2="17" y2="22"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<line x1="2" y1="7" x2="7" y2="7"/>
|
||||
<line x1="2" y1="17" x2="7" y2="17"/>
|
||||
<line x1="17" y1="17" x2="22" y2="17"/>
|
||||
<line x1="17" y1="7" x2="22" y2="7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="empty-state-title">No growing files</div>
|
||||
<div class="empty-state-body">Active recordings will appear here</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Panel -->
|
||||
<div id="details-panel" class="details-panel hidden">
|
||||
<div class="details-header">
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ const state = {
|
|||
relinkClips: [],
|
||||
conformJobId: null,
|
||||
conformPollTimer: null,
|
||||
// Tabs state
|
||||
currentTab: 'library',
|
||||
growingAssets: [],
|
||||
growingPollInterval: null,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -79,6 +83,14 @@ function initDOMElements() {
|
|||
seqInfoBar: document.getElementById('seq-info-bar'),
|
||||
seqInfoName: document.getElementById('seq-info-name'),
|
||||
seqRefreshBtn: document.getElementById('seq-refresh-btn'),
|
||||
// Tabs
|
||||
tabLibrary: document.getElementById('tab-library'),
|
||||
tabGrowing: document.getElementById('tab-growing'),
|
||||
growingCount: document.getElementById('growing-count'),
|
||||
libraryContainer: document.getElementById('library-container'),
|
||||
growingContainer: document.getElementById('growing-container'),
|
||||
growingGrid: document.getElementById('growing-grid'),
|
||||
growingEmptyState: document.getElementById('growing-empty-state'),
|
||||
// Advanced: Conform panel
|
||||
exportConformBtn: document.getElementById('export-conform-btn'),
|
||||
exportConformOverlay: document.getElementById('export-conform-overlay'),
|
||||
|
|
@ -175,6 +187,11 @@ function setupEventListeners() {
|
|||
elements.exportCancelBtn.addEventListener('click', cancelExportTimeline);
|
||||
elements.seqRefreshBtn.addEventListener('click', refreshCurrentSequenceInfo);
|
||||
|
||||
// Tabs
|
||||
elements.tabLibrary.addEventListener('click', () => switchTab('library'));
|
||||
elements.tabGrowing.addEventListener('click', () => switchTab('growing'));
|
||||
elements.growingGrid.addEventListener('click', handleAssetClick);
|
||||
|
||||
// Advanced: Conform panel
|
||||
elements.exportConformBtn.addEventListener('click', showAdvancedExportPanel);
|
||||
elements.exportConformCloseBtn.addEventListener('click', hideAdvancedExportPanel);
|
||||
|
|
@ -242,6 +259,7 @@ async function connectToServer() {
|
|||
await fetchProjects(projectData);
|
||||
await fetchAssets();
|
||||
refreshCurrentSequenceInfo();
|
||||
startGrowingPoll();
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
|
@ -251,6 +269,7 @@ async function connectToServer() {
|
|||
updateConnectionStatus('disconnected');
|
||||
elements.connectBtn.textContent = 'Connect';
|
||||
showErrorMessage(`Failed to connect: ${error.message}`);
|
||||
stopGrowingPoll();
|
||||
} finally {
|
||||
state.isConnecting = false;
|
||||
elements.connectBtn.disabled = false;
|
||||
|
|
@ -417,12 +436,15 @@ function createAssetCard(asset) {
|
|||
filenameEl.textContent = name;
|
||||
info.appendChild(filenameEl);
|
||||
|
||||
const durationSec = asset.duration_ms ? asset.duration_ms / 1000 : null;
|
||||
let durationSec = asset.duration_ms ? asset.duration_ms / 1000 : null;
|
||||
if (asset.status === 'live' && asset.created_at) {
|
||||
durationSec = Math.floor((Date.now() - Date.parse(asset.created_at)) / 1000);
|
||||
}
|
||||
const codec = asset.codec || asset.media_type || 'video';
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'asset-meta';
|
||||
meta.innerHTML = [
|
||||
'<span>' + (durationSec ? formatDuration(durationSec) : 'N/A') + '</span>',
|
||||
'<span>' + (durationSec ? formatDuration(durationSec) : 'LIVE') + '</span>',
|
||||
'<span>' + escapeHtml(codec.toUpperCase()) + '</span>',
|
||||
].join('');
|
||||
info.appendChild(meta);
|
||||
|
|
@ -482,6 +504,110 @@ function hideAssetDetails() {
|
|||
elements.relinkBtn.disabled = true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tabs and Growing Assets Polling
|
||||
// ============================================================================
|
||||
|
||||
function switchTab(tabName) {
|
||||
if (!state.isConnected) return;
|
||||
|
||||
state.currentTab = tabName;
|
||||
|
||||
elements.tabLibrary.classList.toggle('active', tabName === 'library');
|
||||
elements.tabGrowing.classList.toggle('active', tabName === 'growing');
|
||||
|
||||
elements.libraryContainer.classList.toggle('hidden', tabName !== 'library');
|
||||
elements.growingContainer.classList.toggle('hidden', tabName !== 'growing');
|
||||
|
||||
hideAssetDetails();
|
||||
|
||||
if (tabName === 'library') {
|
||||
fetchAssets();
|
||||
} else {
|
||||
pollGrowingAssets();
|
||||
}
|
||||
}
|
||||
|
||||
function startGrowingPoll() {
|
||||
stopGrowingPoll();
|
||||
pollGrowingAssets();
|
||||
state.growingPollInterval = setInterval(pollGrowingAssets, 5000);
|
||||
}
|
||||
|
||||
function stopGrowingPoll() {
|
||||
if (state.growingPollInterval) {
|
||||
clearInterval(state.growingPollInterval);
|
||||
state.growingPollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollGrowingAssets() {
|
||||
if (!state.isConnected) return;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: 100
|
||||
});
|
||||
if (state.selectedProject !== 'all') {
|
||||
params.append('project_id', state.selectedProject);
|
||||
}
|
||||
if (state.searchQuery) {
|
||||
params.append('search', state.searchQuery);
|
||||
}
|
||||
|
||||
const response = await fetch(`${state.serverUrl}/api/v1/assets?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
const allAssets = data.assets || [];
|
||||
|
||||
state.growingAssets = allAssets.filter(a =>
|
||||
a.status === 'live' ||
|
||||
a.status === 'ingesting' ||
|
||||
a.status === 'processing' ||
|
||||
(a.status === 'ready' && state.importedAssets['live:' + a.id])
|
||||
);
|
||||
|
||||
const count = state.growingAssets.filter(a => a.status === 'live' || a.status === 'ingesting' || a.status === 'processing').length;
|
||||
elements.growingCount.textContent = count;
|
||||
elements.growingCount.style.display = count > 0 ? 'inline-block' : 'none';
|
||||
|
||||
if (state.currentTab === 'growing') {
|
||||
renderGrowingAssets();
|
||||
|
||||
if (state.selectedAsset) {
|
||||
const updatedSelected = state.growingAssets.find(a => a.id === state.selectedAsset.id);
|
||||
if (updatedSelected) {
|
||||
showAssetDetails(updatedSelected);
|
||||
} else {
|
||||
const latest = await fetchAssetDetails(state.selectedAsset.id);
|
||||
if (latest) {
|
||||
showAssetDetails(latest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling growing assets:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGrowingAssets() {
|
||||
elements.growingGrid.innerHTML = '';
|
||||
|
||||
if (state.growingAssets.length === 0) {
|
||||
elements.growingEmptyState.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.growingEmptyState.style.display = 'none';
|
||||
state.growingAssets.forEach((asset) => {
|
||||
elements.growingGrid.appendChild(createAssetCard(asset));
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mount Live
|
||||
// ============================================================================
|
||||
|
|
@ -638,13 +764,21 @@ function relinkInPremiere(oldPath, newPath) {
|
|||
function handleSearch(e) {
|
||||
state.searchQuery = e.target.value;
|
||||
state.currentPage = 0;
|
||||
if (state.currentTab === 'library') {
|
||||
fetchAssets();
|
||||
} else {
|
||||
pollGrowingAssets();
|
||||
}
|
||||
}
|
||||
|
||||
function handleProjectFilter(e) {
|
||||
state.selectedProject = e.target.value;
|
||||
state.currentPage = 0;
|
||||
if (state.currentTab === 'library') {
|
||||
fetchAssets();
|
||||
} else {
|
||||
pollGrowingAssets();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -1661,7 +1795,8 @@ function handleAssetClick(e) {
|
|||
card.classList.add('selected');
|
||||
|
||||
var assetId = card.dataset.assetId;
|
||||
var asset = state.assets.find(function (a) { return a.id === assetId; });
|
||||
var asset = state.assets.find(function (a) { return a.id === assetId; }) ||
|
||||
state.growingAssets.find(function (a) { return a.id === assetId; });
|
||||
if (asset) showAssetDetails(asset);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,109 +80,32 @@ function AssetDetail({ asset, onClose }) {
|
|||
return function() { hls.destroy(); };
|
||||
}, [streamUrl, streamType]);
|
||||
|
||||
// Build filmstrip from real video frames. HLS streams use hls.js probe.
|
||||
// Fetch server-side filmstrip (pre-built by filmstrip worker via FFmpeg).
|
||||
// Falls back to nothing if not ready yet — user can right-click → Re-generate.
|
||||
React.useEffect(() => {
|
||||
if (!streamUrl || totalMs <= 0) {
|
||||
setFilmFrames([]);
|
||||
setFilmstripLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!assetId) return;
|
||||
let cancelled = false;
|
||||
const build = async function() {
|
||||
setFilmFrames([]);
|
||||
setFilmstripLoading(true);
|
||||
const probe = document.createElement('video');
|
||||
// Do NOT set crossOrigin — the /video endpoint is same-origin and requires
|
||||
// session cookies. crossOrigin='anonymous' strips credentials → 401 → load
|
||||
// fails → filmstrip never builds. Same-origin video can be drawn to canvas
|
||||
// without crossOrigin (no taint applies).
|
||||
probe.muted = true;
|
||||
probe.playsInline = true;
|
||||
probe.preload = 'auto';
|
||||
probe.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;pointer-events:none';
|
||||
document.body.appendChild(probe);
|
||||
const timeout = setTimeout(function() {
|
||||
if (probe.parentNode) probe.parentNode.removeChild(probe);
|
||||
if (!cancelled) { setFilmFrames([]); setFilmstripLoading(false); }
|
||||
cancelled = true;
|
||||
}, 15000);
|
||||
try {
|
||||
if (streamType === 'hls') {
|
||||
if (!window.Hls) throw new Error('hls.js not loaded');
|
||||
await new Promise(function(resolve, reject) {
|
||||
const hls = new window.Hls();
|
||||
hls.on(window.Hls.Events.MANIFEST_PARSED, function() {
|
||||
probe.oncanplay = function() { probe.oncanplay = null; resolve(); };
|
||||
probe.onerror = reject;
|
||||
});
|
||||
hls.on(window.Hls.Events.ERROR, function(ev, data) { reject(data); });
|
||||
hls.loadSource(streamUrl);
|
||||
hls.attachMedia(probe);
|
||||
});
|
||||
} else {
|
||||
await new Promise(function(resolve, reject) {
|
||||
probe.onloadedmetadata = resolve;
|
||||
probe.onerror = reject;
|
||||
probe.src = streamUrl;
|
||||
});
|
||||
}
|
||||
const frameCount = 28;
|
||||
const width = 160;
|
||||
const height = 90;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const nextFrames = [];
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
const at = frameCount === 1 ? 0 : (probe.duration * i) / (frameCount - 1);
|
||||
const target = Math.min(Math.max(at, 0), Math.max(0, probe.duration - 0.05));
|
||||
await new Promise(function(resolve) {
|
||||
const done = function() {
|
||||
try {
|
||||
ctx.drawImage(probe, 0, 0, width, height);
|
||||
nextFrames.push(canvas.toDataURL('image/jpeg', 0.72));
|
||||
} catch (_) {
|
||||
nextFrames.push(null);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
// If already at the target position (frame 0 at t=0), 'seeked' will
|
||||
// never fire because the browser sees no position change — call done()
|
||||
// directly. Otherwise wait for the seeked event with a per-frame
|
||||
// timeout so a stalled seek (unbuffered range) doesn't hang the strip.
|
||||
if (Math.abs(probe.currentTime - target) < 0.05) {
|
||||
done();
|
||||
} else {
|
||||
let frameTimer;
|
||||
const onSeeked = function() {
|
||||
clearTimeout(frameTimer);
|
||||
probe.removeEventListener('seeked', onSeeked);
|
||||
done();
|
||||
};
|
||||
probe.addEventListener('seeked', onSeeked);
|
||||
probe.currentTime = target;
|
||||
// 3s per-frame deadline — if seek stalls (e.g. unbuffered remote range),
|
||||
// capture whatever frame is currently decoded and move on.
|
||||
frameTimer = setTimeout(function() {
|
||||
probe.removeEventListener('seeked', onSeeked);
|
||||
done();
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/filmstrip')
|
||||
.then(function(r) {
|
||||
if (cancelled) return;
|
||||
if (!r || !r.url) { setFilmstripLoading(false); return; }
|
||||
// Fetch the JSON array of base64 frames from the signed S3 URL
|
||||
return fetch(r.url)
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(frames) {
|
||||
if (!cancelled && Array.isArray(frames) && frames.length) {
|
||||
setFilmFrames(frames);
|
||||
}
|
||||
if (!cancelled) setFilmFrames(nextFrames);
|
||||
} catch (_) {
|
||||
if (!cancelled) setFilmFrames([]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (probe.parentNode) probe.parentNode.removeChild(probe);
|
||||
if (!cancelled) setFilmstripLoading(false);
|
||||
}
|
||||
};
|
||||
build();
|
||||
});
|
||||
})
|
||||
.catch(function() {})
|
||||
.finally(function() { if (!cancelled) setFilmstripLoading(false); });
|
||||
|
||||
return function() { cancelled = true; };
|
||||
}, [streamUrl, streamType, totalMs, filmstripKey]);
|
||||
}, [assetId, filmstripKey]);
|
||||
|
||||
// Fake playback timer — only used when no real video stream
|
||||
React.useEffect(() => {
|
||||
|
|
@ -278,6 +201,12 @@ function AssetDetail({ asset, onClose }) {
|
|||
.finally(function() { setReprocessing(null); });
|
||||
};
|
||||
|
||||
const regenFilmstrip = function() {
|
||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' })
|
||||
.then(function() { window.alert('Filmstrip job queued — it will appear automatically when ready.'); })
|
||||
.catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); });
|
||||
};
|
||||
|
||||
// Map a /assets/:id/comments row into the legacy shape the consumer
|
||||
// components (PlaybackBar pins, FilmStrip pins, comment list) already expect.
|
||||
function _normalizeComment(row) {
|
||||
|
|
@ -517,7 +446,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
comments={visibleComments}
|
||||
frames={filmFrames}
|
||||
loading={filmstripLoading}
|
||||
onRegenFilmstrip={function() { setFilmFrames([]); setFilmstripKey(function(k) { return k + 1; }); }}
|
||||
onRegenFilmstrip={regenFilmstrip}
|
||||
onRegenProxy={function() { reprocessJob('proxy'); }}
|
||||
reprocessing={reprocessing}
|
||||
/>
|
||||
|
|
@ -573,7 +502,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
reprocessing={reprocessing}
|
||||
onRegenProxy={function() { reprocessJob('proxy'); }}
|
||||
onRegenThumbnail={function() { reprocessJob('thumbnail'); }}
|
||||
onRegenFilmstrip={function() { setFilmFrames([]); setFilmstripKey(function(k) { return k + 1; }); }}
|
||||
onRegenFilmstrip={regenFilmstrip}
|
||||
/>
|
||||
)}
|
||||
{tab === "metadata" && <MetadataTab asset={asset} />}
|
||||
|
|
@ -772,7 +701,8 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing
|
|||
const hasProxy = !!asset.proxy_s3_key;
|
||||
const hasHires = !!asset.original_s3_key;
|
||||
const hasThumb = !!asset.thumbnail_s3_key;
|
||||
const hasFilmstrip = Array.isArray(filmFrames) && filmFrames.length > 0;
|
||||
const hasFilmstrip = !!asset.filmstrip_s3_key;
|
||||
const filmstripReady = Array.isArray(filmFrames) && filmFrames.length > 0;
|
||||
|
||||
// Thumbnail endpoint returns a signed URL, not raw image bytes
|
||||
const [thumbUrl, setThumbUrl] = React.useState(null);
|
||||
|
|
@ -861,13 +791,13 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing
|
|||
<FileRow
|
||||
label="Filmstrip"
|
||||
present={hasFilmstrip}
|
||||
path={hasFilmstrip ? filmFrames.length + ' frames captured' : filmstripLoading ? 'Building…' : 'Not built yet'}
|
||||
path={hasFilmstrip ? (asset.filmstrip_s3_key || null) : filmstripLoading ? 'Fetching…' : 'Not generated yet — right-click filmstrip or click Re-generate'}
|
||||
icon="editor"
|
||||
actionLabel="Re-generate"
|
||||
onAction={onRegenFilmstrip}
|
||||
disabled={filmstripLoading}
|
||||
>
|
||||
{hasFilmstrip && (
|
||||
{filmstripReady && (
|
||||
<div style={{ display: 'flex', gap: 2, overflowX: 'auto', paddingBottom: 2 }}>
|
||||
{filmFrames.filter(Boolean).slice(0, 14).map(function(src, i) {
|
||||
return (
|
||||
|
|
@ -876,8 +806,8 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
{filmstripLoading && (
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Building filmstrip from proxy…</div>
|
||||
{!filmstripReady && filmstripLoading && (
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Fetching filmstrip from server…</div>
|
||||
)}
|
||||
</FileRow>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import 'dotenv/config';
|
||||
import { Worker } from 'bullmq';
|
||||
import { Worker, Queue } from 'bullmq';
|
||||
import { proxyWorker, thumbnailQueue as proxyThumbnailQueue } from './workers/proxy.js';
|
||||
import { thumbnailWorker } from './workers/thumbnail.js';
|
||||
import { filmstripWorker } from './workers/filmstrip.js';
|
||||
import { conformWorker } from './workers/conform.js';
|
||||
import { youtubeImportWorker, proxyQueue as youtubeProxyQueue } from './workers/youtube-import.js';
|
||||
import { trimWorker } from './workers/trimWorker.js';
|
||||
|
|
@ -57,17 +58,16 @@ const createWorker = (queueName, handler, overrides = {}) => {
|
|||
// the box; tune via env if a node has more headroom.
|
||||
const PROXY_CONCURRENCY = parseInt(process.env.PROXY_CONCURRENCY || '2', 10);
|
||||
const THUMBNAIL_CONCURRENCY = parseInt(process.env.THUMBNAIL_CONCURRENCY || '4', 10);
|
||||
const FILMSTRIP_CONCURRENCY = parseInt(process.env.FILMSTRIP_CONCURRENCY || '2', 10);
|
||||
const CONFORM_CONCURRENCY = parseInt(process.env.CONFORM_CONCURRENCY || '1', 10);
|
||||
const TRIM_CONCURRENCY = parseInt(process.env.TRIM_CONCURRENCY || '4', 10);
|
||||
|
||||
const workers = [
|
||||
createWorker('proxy', proxyWorker, { concurrency: PROXY_CONCURRENCY }),
|
||||
createWorker('thumbnail', thumbnailWorker, { concurrency: THUMBNAIL_CONCURRENCY }),
|
||||
createWorker('filmstrip', filmstripWorker, { concurrency: FILMSTRIP_CONCURRENCY }),
|
||||
createWorker('conform', conformWorker, { concurrency: CONFORM_CONCURRENCY }),
|
||||
createWorker('trim', trimWorker, { concurrency: TRIM_CONCURRENCY }),
|
||||
// YouTube imports: keep concurrency at 1 so we don't burn through rate
|
||||
// limits when several jobs land back-to-back. Lock window is longer than
|
||||
// the default because a long video download can run for minutes.
|
||||
createWorker('import', youtubeImportWorker, {
|
||||
concurrency: 1,
|
||||
lockDuration: 10 * 60 * 1000,
|
||||
|
|
@ -75,7 +75,10 @@ const workers = [
|
|||
}),
|
||||
];
|
||||
|
||||
console.log(`Concurrency: proxy=${PROXY_CONCURRENCY} thumbnail=${THUMBNAIL_CONCURRENCY} conform=${CONFORM_CONCURRENCY} trim=${TRIM_CONCURRENCY} import=1`);
|
||||
// Filmstrip queue singleton — used by thumbnail worker to enqueue filmstrip jobs
|
||||
export const filmstripQueue = new Queue('filmstrip', { connection: redisOptions });
|
||||
|
||||
console.log(`Concurrency: proxy=${PROXY_CONCURRENCY} thumbnail=${THUMBNAIL_CONCURRENCY} filmstrip=${FILMSTRIP_CONCURRENCY} conform=${CONFORM_CONCURRENCY} trim=${TRIM_CONCURRENCY} import=1`);
|
||||
|
||||
// BUG FIX #4: startPromotionWorker() now returns a shutdown function that
|
||||
// clears the poll intervals and closes the promotion proxyQueue singleton.
|
||||
|
|
@ -102,6 +105,7 @@ process.on('SIGTERM', async () => {
|
|||
// youtubeProxyQueue: proxyQueue in youtube-import.js (dispatches proxy jobs)
|
||||
proxyThumbnailQueue.close().catch(() => {}),
|
||||
youtubeProxyQueue.close().catch(() => {}),
|
||||
filmstripQueue.close().catch(() => {}),
|
||||
// BUG FIX #4: Stop the promotion worker intervals and close its proxyQueue
|
||||
stopPromotionWorker(),
|
||||
]);
|
||||
|
|
|
|||
100
services/worker/src/workers/filmstrip.js
Normal file
100
services/worker/src/workers/filmstrip.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// filmstrip.js — server-side filmstrip generation worker
|
||||
//
|
||||
// Downloads the proxy from S3, uses FFmpeg to extract FRAME_COUNT evenly-spaced
|
||||
// JPEG frames, encodes them as base64, packs into a JSON array, and uploads to
|
||||
// S3 at filmstrips/<assetId>.json. The API returns a signed URL for this file
|
||||
// and the frontend fetches + displays it — no browser seek loop needed.
|
||||
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { mkdir, readdir, readFile, writeFile, rm } from 'fs/promises';
|
||||
import { query } from '../db/client.js';
|
||||
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
|
||||
import { runFFmpeg, getMediaDuration } from '../ffmpeg/executor.js';
|
||||
|
||||
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
||||
const FRAME_COUNT = 28;
|
||||
const FRAME_W = 160;
|
||||
const FRAME_H = 90;
|
||||
|
||||
export const filmstripWorker = async (job) => {
|
||||
const { assetId, proxyKey } = job.data;
|
||||
|
||||
const tmpDir = join(tmpdir(), `filmstrip-${job.id}`);
|
||||
const srcPath = join(tmpDir, 'proxy.mp4');
|
||||
|
||||
try {
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
|
||||
// 1. Download proxy from S3
|
||||
await job.updateProgress(5);
|
||||
console.log(`[filmstrip] Downloading ${proxyKey} for asset ${assetId}`);
|
||||
await downloadFromS3(S3_BUCKET, proxyKey, srcPath);
|
||||
|
||||
// 2. Get duration so we can spread frames evenly
|
||||
await job.updateProgress(15);
|
||||
const durationSec = await getMediaDuration(srcPath);
|
||||
if (!durationSec || durationSec <= 0) throw new Error('Could not determine video duration');
|
||||
|
||||
// 3. Extract FRAME_COUNT frames evenly spaced across the clip using FFmpeg
|
||||
// fps filter: select one frame every (duration / frameCount) seconds.
|
||||
// select=not(mod(n\,step)) is less reliable than fps for variable-frame content.
|
||||
const interval = durationSec / FRAME_COUNT;
|
||||
const outputGlob = join(tmpDir, 'frame-%03d.jpg');
|
||||
|
||||
await job.updateProgress(20);
|
||||
console.log(`[filmstrip] Extracting ${FRAME_COUNT} frames from ${durationSec.toFixed(1)}s clip`);
|
||||
|
||||
await runFFmpeg([
|
||||
'-i', srcPath,
|
||||
'-vf', `fps=1/${interval.toFixed(4)},scale=${FRAME_W}:${FRAME_H}:force_original_aspect_ratio=decrease,pad=${FRAME_W}:${FRAME_H}:(ow-iw)/2:(oh-ih)/2`,
|
||||
'-frames:v', String(FRAME_COUNT),
|
||||
'-q:v', '5', // JPEG quality 1-31, lower = better; 5 ≈ ~85% quality
|
||||
'-y',
|
||||
outputGlob,
|
||||
]);
|
||||
|
||||
await job.updateProgress(70);
|
||||
|
||||
// 4. Read extracted frames, encode as base64
|
||||
const entries = (await readdir(tmpDir))
|
||||
.filter(f => f.startsWith('frame-') && f.endsWith('.jpg'))
|
||||
.sort();
|
||||
|
||||
if (entries.length === 0) throw new Error('FFmpeg produced no frame files');
|
||||
|
||||
const frames = await Promise.all(
|
||||
entries.map(async (f) => {
|
||||
const buf = await readFile(join(tmpDir, f));
|
||||
return 'data:image/jpeg;base64,' + buf.toString('base64');
|
||||
})
|
||||
);
|
||||
|
||||
await job.updateProgress(85);
|
||||
|
||||
// 5. Upload JSON array to S3
|
||||
const s3Key = `filmstrips/${assetId}.json`;
|
||||
const json = JSON.stringify(frames);
|
||||
const jsonPath = join(tmpDir, 'filmstrip.json');
|
||||
await writeFile(jsonPath, json);
|
||||
await uploadToS3(S3_BUCKET, s3Key, jsonPath);
|
||||
|
||||
await job.updateProgress(95);
|
||||
|
||||
// 6. Store s3Key in assets table
|
||||
await query(
|
||||
`UPDATE assets SET filmstrip_s3_key = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[s3Key, assetId]
|
||||
);
|
||||
|
||||
await job.updateProgress(100);
|
||||
console.log(`[filmstrip] Asset ${assetId} filmstrip complete (${frames.length} frames) → ${s3Key}`);
|
||||
return { assetId, s3Key, frameCount: frames.length };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[filmstrip] Error for asset ${assetId}:`, error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,10 +1,19 @@
|
|||
import { join } from 'path';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { Queue } from 'bullmq';
|
||||
import { query } from '../db/client.js';
|
||||
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
|
||||
import { extractFrameAtTime, getMediaDuration } from '../ffmpeg/executor.js';
|
||||
|
||||
const parseRedisUrl = (url) => {
|
||||
try { const p = new URL(url); return { host: p.hostname, port: parseInt(p.port, 10) || 6379 }; }
|
||||
catch { return { host: 'localhost', port: 6379 }; }
|
||||
};
|
||||
const filmstripQueue = new Queue('filmstrip', {
|
||||
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
||||
});
|
||||
|
||||
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
||||
|
||||
/**
|
||||
|
|
@ -61,6 +70,11 @@ export const thumbnailWorker = async (job) => {
|
|||
await job.updateProgress(100);
|
||||
console.log(`[thumbnail] Asset ${assetId} thumbnail complete → status=ready`);
|
||||
|
||||
// Queue filmstrip generation now that the proxy is confirmed good
|
||||
await filmstripQueue.add('generate', { assetId, proxyKey }).catch(err => {
|
||||
console.warn(`[thumbnail] Failed to queue filmstrip for ${assetId}:`, err.message);
|
||||
});
|
||||
|
||||
return { assetId, outputKey };
|
||||
} catch (error) {
|
||||
console.error(`[thumbnail] Error processing asset ${assetId}:`, error);
|
||||
|
|
|
|||
Loading…
Reference in a new issue