fix: EDL export uses sequence frame_rate for timecode (29.97/59.94 DF, others non-drop)

This commit is contained in:
Zac Gaetano 2026-05-19 23:09:17 -04:00
parent d21c61a8b2
commit d382c6b559

View file

@ -7,39 +7,77 @@ import { requireAuth } from '../middleware/auth.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
// ── 59.94 DF timecode helpers (for EDL export) ──────────────────────────────── // ── Timecode helpers ──────────────────────────────────────────────────────────
const NOM = 60; // nominal integer fps //
const DROP = 4; // frames dropped per minute (except every 10th) // generateEDL emits CMX3600 timecode using the sequence's frame_rate.
const FRAMES_FIRST_MIN = NOM * 60; // 3600 — first (non-drop) minute per 10-min group //
const FRAMES_PER_MIN = NOM * 60 - DROP; // 3596 // 29.97 fps → NTSC drop-frame (30 NOM, drop 2/min except every 10th) → ";"
const FRAMES_PER_10MIN = FRAMES_PER_MIN * 10 + DROP; // 35964 // 59.94 fps → double-NTSC DF (60 NOM, drop 4/min except every 10th) → ";"
const FRAMES_PER_HOUR = FRAMES_PER_10MIN * 6; // 215784 // all others → non-drop integer (24/25/30/50/60 …) → ":"
//
function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); } 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 fc = Math.max(0, Math.round(totalFrames));
const h = Math.floor(fc / FRAMES_PER_HOUR);
let rem = fc % FRAMES_PER_HOUR; // 29.97 DF ─ drop 2 frames per minute except every 10th
const tm = Math.floor(rem / FRAMES_PER_10MIN); if (Math.abs(fps - 29.97) < 0.02) {
rem = rem % FRAMES_PER_10MIN; const NOM = 30, DROP = 2;
let m, ss, ff; const FPM = NOM * 60 - DROP; // 1798
// The first minute of each 10-minute group is non-drop (rem < 3600). const FP10M = FPM * 10 + DROP; // 17982
// Minutes 1-9 are drop-frame and start at frame label :00;04. const FPH = FP10M * 6; // 107892
if (rem < FRAMES_FIRST_MIN) { const h = Math.floor(fc / FPH);
m = 0; ss = Math.floor(rem / NOM); ff = rem % NOM; let rem = fc % FPH;
} else { const tm = Math.floor(rem / FP10M);
rem -= FRAMES_FIRST_MIN; rem = rem % FP10M;
m = Math.floor(rem / FRAMES_PER_MIN) + 1; let m, ss, ff;
const remInMin = rem % FRAMES_PER_MIN; if (rem < NOM * 60) {
const adj = remInMin + DROP; // shift so label starts at :00;04 m = 0; ss = Math.floor(rem / NOM); ff = rem % NOM;
ss = Math.floor(adj / NOM); ff = adj % 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}`, '']; const lines = [`TITLE: ${seqName}`, ''];
clips.forEach((c, i) => { clips.forEach((c, i) => {
const num = String(i + 1).padStart(3, '0'); const num = String(i + 1).padStart(3, '0');
@ -49,10 +87,10 @@ function generateEDL(seqName, clips) {
.toUpperCase() .toUpperCase()
.substring(0, 32) .substring(0, 32)
.padEnd(8); .padEnd(8);
const srcIn = framesToTC(c.source_in_frames); const srcIn = framesToTC(c.source_in_frames, fps);
const srcOut = framesToTC(c.source_out_frames); const srcOut = framesToTC(c.source_out_frames, fps);
const recIn = framesToTC(c.timeline_in_frames); const recIn = framesToTC(c.timeline_in_frames, fps);
const recOut = framesToTC(c.timeline_out_frames); const recOut = framesToTC(c.timeline_out_frames, fps);
lines.push(`${num} ${reel} V C ${srcIn} ${srcOut} ${recIn} ${recOut}`); lines.push(`${num} ${reel} V C ${srcIn} ${srcOut} ${recIn} ${recOut}`);
}); });
return lines.join('\n'); return lines.join('\n');
@ -228,7 +266,7 @@ router.post('/:id/export/edl', async (req, res, next) => {
[req.params.id] [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`; const filename = `${seq.name.replace(/[^a-z0-9]/gi, '_')}.edl`;
res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);