dragonflight/services/mam-api/src/routes/sequences.js
Claude 56d7479a35 fix(mam-api): pass project_id into conform job so render can register the asset
The conform worker's final step INSERTs the rendered output into the
assets table:

  INSERT INTO assets (project_id, filename, display_name, …)
  VALUES ($1, …)
  -- project_id NOT NULL

It reads projectId from job.data, but the /sequences/:id/conform
endpoint never set it. Render finished cleanly, ffmpeg ran, output
uploaded to S3, then the final asset row INSERT failed:
  null value in column "project_id" of relation "assets"

Pass seq.project_id from the loaded sequence row. The rendered output
lands as an asset under the same project as its source sequence —
the natural target.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:24:04 -04:00

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