101 lines
3.7 KiB
JavaScript
101 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(() => {});
|
||
|
|
}
|
||
|
|
};
|