// services/mam-api/src/routes/sequences.js // TODO(authz): per-project scoping not yet enforced. Sequences belong to a // project; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern. import express from 'express'; import pool from '../db/pool.js'; import { getSignedUrlForObject } from '../s3/client.js'; import { validateUuid } from '../middleware/errors.js'; import { Queue } from 'bullmq'; const parseRedisUrl = (url) => { try { const parsed = new URL(url); return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 }; } catch { return { host: 'localhost', port: 6379 }; } }; const conformQueue = new Queue('conform', { connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'), }); const router = express.Router(); router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // ── Row mapper ──────────────────────────────────────────────────────────────── // node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a // JS float before sending any sequence object to clients. function mapSeq(row) { if (!row) return row; return { ...row, frame_rate: parseFloat(row.frame_rate) || 59.94 }; } // ── Timecode helpers ────────────────────────────────────────────────────────── // // generateEDL emits CMX3600 timecode using the sequence's frame_rate. // // 29.97 fps → NTSC drop-frame (30 NOM, drop 2/min except every 10th) → ";" // 59.94 fps → double-NTSC DF (60 NOM, drop 4/min except every 10th) → ";" // all others → non-drop integer (24/25/30/50/60 …) → ":" // function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); } function framesToTC(totalFrames, fps) { fps = parseFloat(fps) || 59.94; const fc = Math.max(0, Math.round(totalFrames)); // 29.97 DF ─ drop 2 frames per minute except every 10th if (Math.abs(fps - 29.97) < 0.02) { const NOM = 30, DROP = 2; const FPM = NOM * 60 - DROP; // 1798 const FP10M = FPM * 10 + DROP; // 17982 const FPH = FP10M * 6; // 107892 const h = Math.floor(fc / FPH); let rem = fc % FPH; const tm = Math.floor(rem / FP10M); rem = rem % FP10M; let m, ss, ff; if (rem < NOM * 60) { m = 0; ss = Math.floor(rem / NOM); ff = rem % NOM; } else { rem -= NOM * 60; m = Math.floor(rem / FPM) + 1; const adj = (rem % FPM) + DROP; ss = Math.floor(adj / NOM); ff = adj % NOM; } return `${pad2(h)}:${pad2(tm * 10 + m)}:${pad2(ss)};${pad2(ff)}`; } // 59.94 DF ─ drop 4 frames per minute except every 10th if (Math.abs(fps - 59.94) < 0.02) { const NOM = 60, DROP = 4; const FPM = NOM * 60 - DROP; // 3596 const FP10M = FPM * 10 + DROP; // 35964 const FPH = FP10M * 6; // 215784 const h = Math.floor(fc / FPH); let rem = fc % FPH; const tm = Math.floor(rem / FP10M); rem = rem % FP10M; let m, ss, ff; if (rem < NOM * 60) { m = 0; ss = Math.floor(rem / NOM); ff = rem % NOM; } else { rem -= NOM * 60; m = Math.floor(rem / FPM) + 1; const adj = (rem % FPM) + DROP; ss = Math.floor(adj / NOM); ff = adj % NOM; } return `${pad2(h)}:${pad2(tm * 10 + m)}:${pad2(ss)};${pad2(ff)}`; } // Non-drop frame (24, 23.976→24, 25, 30, 50, 60 …) ─ colon separator const nomFps = Math.round(fps); const ff = fc % nomFps; const totalSec = Math.floor(fc / nomFps); const ss = totalSec % 60; const mm = Math.floor(totalSec / 60) % 60; const hh = Math.floor(totalSec / 3600); return `${pad2(hh)}:${pad2(mm)}:${pad2(ss)}:${pad2(ff)}`; } function generateEDL(seqName, clips, fps) { fps = parseFloat(fps) || 59.94; const lines = [`TITLE: ${seqName}`, '']; clips.forEach((c, i) => { const num = String(i + 1).padStart(3, '0'); const reel = (c.filename || 'UNKNOWN') .replace(/\.[^.]+$/, '') // strip extension .replace(/[^A-Za-z0-9_]/g, '_') .toUpperCase() .substring(0, 32) .padEnd(8); const srcIn = framesToTC(c.source_in_frames, fps); const srcOut = framesToTC(c.source_out_frames, fps); const recIn = framesToTC(c.timeline_in_frames, fps); const recOut = framesToTC(c.timeline_out_frames, fps); lines.push(`${num} ${reel} V C ${srcIn} ${srcOut} ${recIn} ${recOut}`); }); return lines.join('\n'); } // ── GET / – list sequences for a project ───────────────────────────────────── router.get('/', async (req, res, next) => { try { const { project_id } = req.query; if (!project_id) return res.status(400).json({ error: 'project_id is required' }); const r = await pool.query( `SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`, [project_id] ); res.json(r.rows.map(mapSeq)); } catch (e) { next(e); } }); // ── POST / – create sequence ────────────────────────────────────────────────── router.post('/', async (req, res, next) => { try { const { project_id, name = 'Sequence 1', frame_rate = 59.94, width = 1920, height = 1080, } = req.body; if (!project_id) return res.status(400).json({ error: 'project_id is required' }); const r = await pool.query( `INSERT INTO sequences (project_id, name, frame_rate, width, height) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [project_id, name, frame_rate, width, height] ); res.status(201).json(mapSeq(r.rows[0])); } catch (e) { next(e); } }); // ── GET /:id – sequence + all clips joined with asset data ─────────────────── router.get('/:id', async (req, res, next) => { try { const seqR = await pool.query( `SELECT * FROM sequences WHERE id = $1`, [req.params.id] ); if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' }); const clipsR = await pool.query( `SELECT sc.*, a.display_name, a.filename, a.fps, a.duration_ms, a.proxy_s3_key, a.thumbnail_s3_key FROM sequence_clips sc JOIN assets a ON a.id = sc.asset_id WHERE sc.sequence_id = $1 ORDER BY sc.track, sc.timeline_in_frames`, [req.params.id] ); // Attach signed stream URLs (best-effort; missing proxy → streamUrl: null) const clips = await Promise.all( clipsR.rows.map(async (clip) => { let streamUrl = null; if (clip.proxy_s3_key) { try { streamUrl = await getSignedUrlForObject(clip.proxy_s3_key); } catch (_) {} } return { ...clip, streamUrl }; }) ); res.json({ ...mapSeq(seqR.rows[0]), clips }); } catch (e) { next(e); } }); // ── PUT /:id – update sequence metadata ────────────────────────────────────── router.put('/:id', async (req, res, next) => { try { const { name, frame_rate, width, height } = req.body; const updates = []; const params = []; let n = 1; if (name !== undefined) { updates.push(`name = $${n++}`); params.push(name); } if (frame_rate !== undefined) { updates.push(`frame_rate = $${n++}`); params.push(frame_rate); } if (width !== undefined) { updates.push(`width = $${n++}`); params.push(width); } if (height !== undefined) { updates.push(`height = $${n++}`); params.push(height); } if (!updates.length) return res.status(400).json({ error: 'No fields to update' }); updates.push('updated_at = NOW()'); params.push(req.params.id); const r = await pool.query( `UPDATE sequences SET ${updates.join(', ')} WHERE id = $${n} RETURNING *`, params ); if (!r.rows.length) return res.status(404).json({ error: 'Sequence not found' }); res.json(mapSeq(r.rows[0])); } catch (e) { next(e); } }); // ── DELETE /:id ─────────────────────────────────────────────────────────────── router.delete('/:id', async (req, res, next) => { try { const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]); if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' }); res.json({ ok: true }); } catch (e) { next(e); } }); // ── PUT /:id/clips – full replace of clip array (single transaction) ────────── router.put('/:id/clips', async (req, res, next) => { // Verify sequence exists first (before acquiring transaction client) const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]); if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' }); const clips = Array.isArray(req.body) ? req.body : []; for (const c of clips) { if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' }); if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) || !Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) { return res.status(400).json({ error: 'Clip frame fields must be finite numbers' }); } if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) { return res.status(400).json({ error: 'Clip track must be a non-negative integer' }); } } const client = await pool.connect(); try { await client.query('BEGIN'); await client.query( `DELETE FROM sequence_clips WHERE sequence_id = $1`, [req.params.id] ); for (const c of clips) { await client.query( `INSERT INTO sequence_clips (sequence_id, asset_id, track, timeline_in_frames, timeline_out_frames, source_in_frames, source_out_frames) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ req.params.id, c.asset_id, c.track, c.timeline_in_frames, c.timeline_out_frames, c.source_in_frames, c.source_out_frames, ] ); } await client.query( `UPDATE sequences SET updated_at = NOW() WHERE id = $1`, [req.params.id] ); await client.query('COMMIT'); res.json({ ok: true, count: clips.length }); } catch (e) { await client.query('ROLLBACK'); next(e); } finally { client.release(); } }); // ── POST /:id/export/edl – download CMX3600 EDL ────────────────────────────── router.post('/:id/export/edl', async (req, res, next) => { try { const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]); if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' }); const seq = mapSeq(seqR.rows[0]); // Export V1 clips only (primary video track) sorted by position const clipsR = await pool.query( `SELECT sc.*, a.filename FROM sequence_clips sc JOIN assets a ON a.id = sc.asset_id WHERE sc.sequence_id = $1 AND sc.track = 0 ORDER BY sc.timeline_in_frames`, [req.params.id] ); const edl = generateEDL(seq.name, clipsR.rows, seq.frame_rate); const filename = `${seq.name.replace(/[^a-z0-9]/gi, '_')}.edl`; res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(edl); } catch (e) { next(e); } }); // ── POST /:id/conform – conform sequence via FCP XML ───────────────────────── // Accepts FCP XML content and encode settings from the Premiere plugin, // queues a conform job in BullMQ, and returns the job ID for polling. router.post('/:id/conform', async (req, res, next) => { try { const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]); if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' }); const seq = mapSeq(seqR.rows[0]); const { fcp_xml, codec = 'h264', quality = 'high', resolution = 'match', audio = 'include' } = req.body; if (!fcp_xml) { return res.status(400).json({ error: 'fcp_xml is required' }); } const bullJob = await conformQueue.add('conform-task', { fcpXml: fcp_xml, sequenceId: req.params.id, // The worker INSERTs the rendered output into the `assets` table at the // end of the pipeline; project_id is NOT NULL on that table, so without // this the conform finished successfully but failed at the very last // step. Sequences live under projects, so the natural target for the // rendered output is the sequence's own project. projectId: seq.project_id, sequenceName: seq.name, frameRate: seq.frame_rate, width: seq.width, height: seq.height, codec, quality, resolution, audio, }); res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' }); } catch (err) { next(err); } }); export default router;