2026-05-18 19:50:29 -04:00
|
|
|
|
// services/mam-api/src/routes/sequences.js
|
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-29 22:37:36 -04:00
|
|
|
|
// TODO(authz): per-project scoping not yet enforced. Sequences belong to a
|
|
|
|
|
|
// project; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
|
2026-05-18 19:50:29 -04:00
|
|
|
|
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';
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
|
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'),
|
|
|
|
|
|
});
|
2026-05-18 19:50:29 -04:00
|
|
|
|
|
|
|
|
|
|
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));
|
2026-05-18 19:50:29 -04:00
|
|
|
|
|
2026-05-19 23:24:16 -04:00
|
|
|
|
// ── 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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 23:09:17 -04:00
|
|
|
|
// ── 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 …) → ":"
|
|
|
|
|
|
//
|
2026-05-18 19:50:29 -04:00
|
|
|
|
|
|
|
|
|
|
function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); }
|
|
|
|
|
|
|
2026-05-19 23:09:17 -04:00
|
|
|
|
function framesToTC(totalFrames, fps) {
|
2026-05-19 23:24:16 -04:00
|
|
|
|
fps = parseFloat(fps) || 59.94;
|
2026-05-19 00:22:17 -04:00
|
|
|
|
const fc = Math.max(0, Math.round(totalFrames));
|
2026-05-19 23:09:17 -04:00
|
|
|
|
|
|
|
|
|
|
// 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)}`;
|
2026-05-18 19:50:29 -04:00
|
|
|
|
}
|
2026-05-19 23:09:17 -04:00
|
|
|
|
|
|
|
|
|
|
// 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)}`;
|
2026-05-18 19:50:29 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 23:09:17 -04:00
|
|
|
|
function generateEDL(seqName, clips, fps) {
|
2026-05-19 23:24:16 -04:00
|
|
|
|
fps = parseFloat(fps) || 59.94;
|
2026-05-18 19:50:29 -04:00
|
|
|
|
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);
|
2026-05-19 23:09:17 -04:00
|
|
|
|
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);
|
2026-05-18 19:50:29 -04:00
|
|
|
|
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]
|
|
|
|
|
|
);
|
2026-05-19 23:24:16 -04:00
|
|
|
|
res.json(r.rows.map(mapSeq));
|
2026-05-18 19:50:29 -04:00
|
|
|
|
} 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]
|
|
|
|
|
|
);
|
2026-05-19 23:24:16 -04:00
|
|
|
|
res.status(201).json(mapSeq(r.rows[0]));
|
2026-05-18 19:50:29 -04:00
|
|
|
|
} 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 };
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-19 23:24:16 -04:00
|
|
|
|
res.json({ ...mapSeq(seqR.rows[0]), clips });
|
2026-05-18 19:50:29 -04:00
|
|
|
|
} 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' });
|
2026-05-19 23:24:16 -04:00
|
|
|
|
res.json(mapSeq(r.rows[0]));
|
2026-05-18 19:50:29 -04:00
|
|
|
|
} catch (e) { next(e); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── DELETE /:id ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
router.delete('/:id', async (req, res, next) => {
|
|
|
|
|
|
try {
|
2026-05-18 19:53:14 -04:00
|
|
|
|
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' });
|
2026-05-18 19:50:29 -04:00
|
|
|
|
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) => {
|
2026-05-18 19:53:14 -04:00
|
|
|
|
// 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' });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 19:50:29 -04:00
|
|
|
|
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' });
|
2026-05-19 23:24:16 -04:00
|
|
|
|
const seq = mapSeq(seqR.rows[0]);
|
2026-05-18 19:50:29 -04:00
|
|
|
|
|
|
|
|
|
|
// 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]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-19 23:09:17 -04:00
|
|
|
|
const edl = generateEDL(seq.name, clipsR.rows, seq.frame_rate);
|
2026-05-18 19:50:29 -04:00
|
|
|
|
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); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
|
// ── 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,
|
2026-05-28 14:24:04 -04:00
|
|
|
|
// 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,
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-18 19:50:29 -04:00
|
|
|
|
export default router;
|