feat(api): add sequences route with CRUD, clip sync, and EDL export
This commit is contained in:
parent
eb248c690f
commit
05d49b7199
1 changed files with 217 additions and 0 deletions
217
services/mam-api/src/routes/sequences.js
Normal file
217
services/mam-api/src/routes/sequences.js
Normal 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;
|
||||
Loading…
Reference in a new issue