diff --git a/services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql b/services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql new file mode 100644 index 0000000..e3574a4 --- /dev/null +++ b/services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql @@ -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; diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index f53f786..848ce0e 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -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); } }); diff --git a/services/mam-api/src/routes/jobs.js b/services/mam-api/src/routes/jobs.js index 59402cc..8f28a0e 100644 --- a/services/mam-api/src/routes/jobs.js +++ b/services/mam-api/src/routes/jobs.js @@ -18,18 +18,20 @@ const parseRedisUrl = (url) => { 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 conformQueue = new Queue('conform', { connection: redisConn }); -const importQueue = new Queue('import', { connection: redisConn }); -const trimQueue = new Queue('trim', { connection: redisConn }); +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 }); const QUEUES = [ - { queue: proxyQueue, type: 'proxy' }, - { queue: thumbnailQueue, type: 'thumbnail' }, - { queue: conformQueue, type: 'conform' }, - { queue: importQueue, type: 'import' }, - { queue: trimQueue, type: 'trim' }, + { queue: proxyQueue, type: 'proxy' }, + { queue: thumbnailQueue, type: 'thumbnail' }, + { queue: filmstripQueue, type: 'filmstrip' }, + { queue: conformQueue, type: 'conform' }, + { queue: importQueue, type: 'import' }, + { queue: trimQueue, type: 'trim' }, ]; // BullMQ state → API status mapping diff --git a/services/mam-api/src/scheduler.js b/services/mam-api/src/scheduler.js index 399d2fe..721a28b 100644 --- a/services/mam-api/src/scheduler.js +++ b/services/mam-api/src/scheduler.js @@ -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 diff --git a/services/premiere-plugin/CSXS/manifest.xml b/services/premiere-plugin/CSXS/manifest.xml index 525079a..93d6b15 100644 --- a/services/premiere-plugin/CSXS/manifest.xml +++ b/services/premiere-plugin/CSXS/manifest.xml @@ -2,7 +2,7 @@ diff --git a/services/premiere-plugin/FEATURE_SPEC.md b/services/premiere-plugin/FEATURE_SPEC.md new file mode 100644 index 0000000..0fa272d --- /dev/null +++ b/services/premiere-plugin/FEATURE_SPEC.md @@ -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//conformed/` + +### 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/.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\\`) + - 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//masters/` +- **S3:** Proxies at `projects//proxies/` +- **S3:** Conformed exports at `projects//conformed/` +- **S3:** Temp segments at `temp-segments/` (24h TTL) +- **SMB:** Growing files at `/mnt/NVME/MAM-growing//` (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) diff --git a/services/premiere-plugin/css/styles.css b/services/premiere-plugin/css/styles.css index 0fe574b..ebcc507 100644 --- a/services/premiere-plugin/css/styles.css +++ b/services/premiere-plugin/css/styles.css @@ -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); diff --git a/services/premiere-plugin/index.html b/services/premiere-plugin/index.html index 7f5583f..703c1e9 100644 --- a/services/premiere-plugin/index.html +++ b/services/premiere-plugin/index.html @@ -35,6 +35,25 @@ + +
+ + +
+