dragonflight/services/mam-api/src/routes/sequences.js
Zac ec026195eb feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.

- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
  assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
  view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
  "Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
  service-token-needs-admin/grants requirement

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 02:37:36 +00:00

342 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// services/mam-api/src/routes/sequences.js
// TODO(authz): per-project scoping not yet enforced. Sequences belong to a
// project; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
import express from 'express';
import pool from '../db/pool.js';
import { getSignedUrlForObject } from '../s3/client.js';
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();
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;