fix: EDL export uses sequence frame_rate for timecode (29.97/59.94 DF, others non-drop)
This commit is contained in:
parent
d21c61a8b2
commit
d382c6b559
1 changed files with 69 additions and 31 deletions
|
|
@ -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}"`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue