dragonflight/services/mam-api/src/routes/sequences.js

341 lines
13 KiB
JavaScript
Raw Normal View History

// services/mam-api/src/routes/sequences.js
import express from 'express';
import pool from '../db/pool.js';
import { getSignedUrlForObject } from '../s3/client.js';
chore: 1.2 ship-prep sweep — close 38 issues Frontend / UX / a11y - Sidebar collapse/expand toggle with localStorage persistence (#142) - Settings sections wrap inputs in <form> with Enter-to-submit + native validation; password autocomplete=new-password (#141, #138) - Asset thumbnails get descriptive alt text (#140) - Production deploy now precompiles JSX via esbuild and loads the production React UMD instead of dev builds + in-browser Babel (#139, #122) - Search wrapper gets role=search; global search input gets aria-label, role=combobox, aria-controls/aria-expanded/aria-activedescendant wiring (#137, #135) - Dashboard and Library no longer share the same nav icon (#136) - Sidebar collapses off-canvas with a topbar menu button below 768 px; mobile default is collapsed (#134) - --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133) - Schedule and Library routes were rendering empty inside the .main flex container — switched to flex:1 + min-height:0 (#131, #132, editor + asset detail get the same fix) - Jobs nav badge now polls /jobs?status=active every 10 s and reflects the live count (#130, #113) - aria-label sweep on every icon-only button (#126) - Premiere panel release list moved to window.PREMIERE_RELEASES in data.jsx; Editor + Settings read from the same source (#125) - Typo setPgMclips → setPgmClips (#124) - Stray console.error / console.warn calls gated behind window.DF_LOG.{warn,error} (#123) - Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115) - Schedule rows no longer crash on null recorder_id (#117) - EditorKeyboard guards against document.activeElement === null (#116) - Unmount-safe timers for PasswordResetModal, Containers, Editor (#111) - Player seek clamps below totalMs, server-side range clamping + uncached 416 on EOF, client-side EOF-stall watchdog (#143) - Duration badge overlap fix on narrow asset cards (#52) Backend / security / reliability - GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id; Docker inspects bounded to actually-recording rows (#121) - Upload disk-storage (multer.diskStorage) streams parts to S3 instead of buffering 500 MB in RAM (#120) - /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119) - SDK upload archive listing + post-extract sanitize block zip-slip / tar-slip and symlink escapes (#118) - Migrations track applied state in schema_migrations, run in a transaction, and exit non-zero on failure (#107) - node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem detection wins (#109, #127) - GPU_COUNT override now merges with nvidia-smi enrichment (#108) - /cluster/heartbeat requires a node-bound token or admin user; tokens carry bound_hostname (#106) - /recorders/:id/start error responses no longer echo the Docker create payload — env vars stay out of client responses (#105) - /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks private + loopback hosts for non-admins, denies common service ports (#104) - Scheduler tick guarded by a Postgres advisory lock; pending/running rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to survive multi-node deploys (#103) - UUID validateUuid('id') param middleware on every /:id route (#102) - Error handler scrubs Postgres error messages and 5xx detail (#101) - Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP server, ends the pool, 25 s force-exit watchdog (#100) - AMPP sync moved from fire-and-forget to a persisted retry queue (ampp_sync_status / attempts / next_attempt_at + scheduler retry loop with exponential backoff) (#77) Migrations - 019: api_tokens.bound_hostname (#106) - 020: assets.ampp_sync_status + retry bookkeeping (#77) Other - Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57 Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3 migration tool to v1.3
2026-05-26 22:06:14 -04:00
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();
chore: 1.2 ship-prep sweep — close 38 issues Frontend / UX / a11y - Sidebar collapse/expand toggle with localStorage persistence (#142) - Settings sections wrap inputs in <form> with Enter-to-submit + native validation; password autocomplete=new-password (#141, #138) - Asset thumbnails get descriptive alt text (#140) - Production deploy now precompiles JSX via esbuild and loads the production React UMD instead of dev builds + in-browser Babel (#139, #122) - Search wrapper gets role=search; global search input gets aria-label, role=combobox, aria-controls/aria-expanded/aria-activedescendant wiring (#137, #135) - Dashboard and Library no longer share the same nav icon (#136) - Sidebar collapses off-canvas with a topbar menu button below 768 px; mobile default is collapsed (#134) - --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133) - Schedule and Library routes were rendering empty inside the .main flex container — switched to flex:1 + min-height:0 (#131, #132, editor + asset detail get the same fix) - Jobs nav badge now polls /jobs?status=active every 10 s and reflects the live count (#130, #113) - aria-label sweep on every icon-only button (#126) - Premiere panel release list moved to window.PREMIERE_RELEASES in data.jsx; Editor + Settings read from the same source (#125) - Typo setPgMclips → setPgmClips (#124) - Stray console.error / console.warn calls gated behind window.DF_LOG.{warn,error} (#123) - Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115) - Schedule rows no longer crash on null recorder_id (#117) - EditorKeyboard guards against document.activeElement === null (#116) - Unmount-safe timers for PasswordResetModal, Containers, Editor (#111) - Player seek clamps below totalMs, server-side range clamping + uncached 416 on EOF, client-side EOF-stall watchdog (#143) - Duration badge overlap fix on narrow asset cards (#52) Backend / security / reliability - GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id; Docker inspects bounded to actually-recording rows (#121) - Upload disk-storage (multer.diskStorage) streams parts to S3 instead of buffering 500 MB in RAM (#120) - /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119) - SDK upload archive listing + post-extract sanitize block zip-slip / tar-slip and symlink escapes (#118) - Migrations track applied state in schema_migrations, run in a transaction, and exit non-zero on failure (#107) - node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem detection wins (#109, #127) - GPU_COUNT override now merges with nvidia-smi enrichment (#108) - /cluster/heartbeat requires a node-bound token or admin user; tokens carry bound_hostname (#106) - /recorders/:id/start error responses no longer echo the Docker create payload — env vars stay out of client responses (#105) - /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks private + loopback hosts for non-admins, denies common service ports (#104) - Scheduler tick guarded by a Postgres advisory lock; pending/running rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to survive multi-node deploys (#103) - UUID validateUuid('id') param middleware on every /:id route (#102) - Error handler scrubs Postgres error messages and 5xx detail (#101) - Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP server, ends the pool, 25 s force-exit watchdog (#100) - AMPP sync moved from fire-and-forget to a persisted retry queue (ampp_sync_status / attempts / next_attempt_at + scheduler retry loop with exponential backoff) (#77) Migrations - 019: api_tokens.bound_hostname (#106) - 020: assets.ampp_sync_status + retry bookkeeping (#77) Other - Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57 Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3 migration tool to v1.3
2026-05-26 22:06:14 -04:00
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;