// services/mam-api/src/routes/sequences.js import express from 'express'; import pool from '../db/pool.js'; import { getSignedUrlForObject } from '../s3/client.js'; import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); router.use(requireAuth); // ── 59.94 DF timecode helpers (for EDL export) ──────────────────────────────── const NOM = 60; // nominal integer fps const DROP = 4; // frames dropped per minute (except every 10th) const FRAMES_PER_MIN = NOM * 60 - DROP; // 3596 const FRAMES_PER_10MIN = FRAMES_PER_MIN * 10 + DROP; // 35964 const FRAMES_PER_HOUR = FRAMES_PER_10MIN * 6; // 215784 function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); } function framesToTC(totalFrames) { const fc = Math.max(0, Math.round(totalFrames)); const h = Math.floor(fc / FRAMES_PER_HOUR); let rem = fc % FRAMES_PER_HOUR; const tm = Math.floor(rem / FRAMES_PER_10MIN); rem = rem % FRAMES_PER_10MIN; let m = 0; if (rem >= DROP) { m = Math.floor((rem - DROP) / FRAMES_PER_MIN) + 1; rem = (rem - DROP) % FRAMES_PER_MIN; } const M = tm * 10 + m; const s = Math.floor(rem / NOM); const ff = rem % NOM; return `${pad2(h)}:${pad2(M)}:${pad2(s)};${pad2(ff)}`; } function generateEDL(seqName, clips) { 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); const srcOut = framesToTC(c.source_out_frames); const recIn = framesToTC(c.timeline_in_frames); const recOut = framesToTC(c.timeline_out_frames); 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); } 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(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({ ...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(r.rows[0]); } catch (e) { next(e); } }); // ── DELETE /:id ─────────────────────────────────────────────────────────────── router.delete('/:id', async (req, res, next) => { try { await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]); 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) => { const client = await pool.connect(); try { await client.query('BEGIN'); await client.query( `DELETE FROM sequence_clips WHERE sequence_id = $1`, [req.params.id] ); const clips = Array.isArray(req.body) ? req.body : []; 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 = 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); 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); } }); export default router;