From d382c6b559107991730478ad0731c3db5dbc0e39 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Tue, 19 May 2026 23:09:17 -0400 Subject: [PATCH] fix: EDL export uses sequence frame_rate for timecode (29.97/59.94 DF, others non-drop) --- services/mam-api/src/routes/sequences.js | 100 ++++++++++++++++------- 1 file changed, 69 insertions(+), 31 deletions(-) diff --git a/services/mam-api/src/routes/sequences.js b/services/mam-api/src/routes/sequences.js index 778836a..419d6f2 100644 --- a/services/mam-api/src/routes/sequences.js +++ b/services/mam-api/src/routes/sequences.js @@ -7,39 +7,77 @@ import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); router.use(requireAuth); -// ── 59.94 DF timecode helpers (for EDL export) ──────────────────────────────── -const NOM = 60; // nominal integer fps -const DROP = 4; // frames dropped per minute (except every 10th) -const FRAMES_FIRST_MIN = NOM * 60; // 3600 — first (non-drop) minute per 10-min group -const FRAMES_PER_MIN = NOM * 60 - DROP; // 3596 -const FRAMES_PER_10MIN = FRAMES_PER_MIN * 10 + DROP; // 35964 -const FRAMES_PER_HOUR = FRAMES_PER_10MIN * 6; // 215784 +// ── 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) { +function framesToTC(totalFrames, fps) { + fps = fps || 59.94; const fc = Math.max(0, Math.round(totalFrames)); - const h = Math.floor(fc / FRAMES_PER_HOUR); - let rem = fc % FRAMES_PER_HOUR; - const tm = Math.floor(rem / FRAMES_PER_10MIN); - rem = rem % FRAMES_PER_10MIN; - let m, ss, ff; - // The first minute of each 10-minute group is non-drop (rem < 3600). - // Minutes 1-9 are drop-frame and start at frame label :00;04. - if (rem < FRAMES_FIRST_MIN) { - m = 0; ss = Math.floor(rem / NOM); ff = rem % NOM; - } else { - rem -= FRAMES_FIRST_MIN; - m = Math.floor(rem / FRAMES_PER_MIN) + 1; - const remInMin = rem % FRAMES_PER_MIN; - const adj = remInMin + DROP; // shift so label starts at :00;04 - ss = Math.floor(adj / NOM); ff = adj % NOM; + + // 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)}`; } - const M = tm * 10 + m; - return `${pad2(h)}:${pad2(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) { +function generateEDL(seqName, clips, fps) { + fps = fps || 59.94; const lines = [`TITLE: ${seqName}`, '']; clips.forEach((c, i) => { const num = String(i + 1).padStart(3, '0'); @@ -49,10 +87,10 @@ function generateEDL(seqName, clips) { .toUpperCase() .substring(0, 32) .padEnd(8); - const srcIn = framesToTC(c.source_in_frames); - const srcOut = framesToTC(c.source_out_frames); - const recIn = framesToTC(c.timeline_in_frames); - const recOut = framesToTC(c.timeline_out_frames); + 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'); @@ -228,7 +266,7 @@ router.post('/:id/export/edl', async (req, res, next) => { [req.params.id] ); - const edl = generateEDL(seq.name, clipsR.rows); + 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}"`);