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>
342 lines
13 KiB
JavaScript
342 lines
13 KiB
JavaScript
// 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;
|