diff --git a/services/mam-api/src/routes/sequences.js b/services/mam-api/src/routes/sequences.js new file mode 100644 index 0000000..6f41189 --- /dev/null +++ b/services/mam-api/src/routes/sequences.js @@ -0,0 +1,217 @@ +// 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;