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