feat(api): add sequences route with CRUD, clip sync, and EDL export

This commit is contained in:
Zac Gaetano 2026-05-18 19:50:29 -04:00
parent eb248c690f
commit 05d49b7199

View file

@ -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;