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:
Zac Gaetano 2026-05-26 16:39:44 +00:00
parent 564cf6b18f
commit a03c85f08a
13 changed files with 726 additions and 172 deletions

View file

@ -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;

View file

@ -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); }
});

View file

@ -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' },

View file

@ -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

View file

@ -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" />

View 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)

View file

@ -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);

View file

@ -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">

View file

@ -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);
}

View file

@ -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>

View file

@ -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(),
]);

View 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(() => {});
}
};

View file

@ -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);