dragonflight/services/worker/src/workers/filmstrip.js
ZGaetano a03c85f08a 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.
2026-05-26 16:39:44 +00:00

100 lines
3.7 KiB
JavaScript

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