2026-05-18 19:58:34 -04:00
|
|
|
|
// services/web-ui/public/js/timecode.js
|
|
|
|
|
|
// 59.94 fps drop-frame timecode utilities.
|
|
|
|
|
|
// Exposes: window.TC
|
|
|
|
|
|
|
|
|
|
|
|
(function (global) {
|
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
// 59.94 = 60000/1001
|
2026-05-19 00:01:18 -04:00
|
|
|
|
const FPS_EXACT = 60000 / 1001;
|
|
|
|
|
|
const NOM = 60; // nominal integer fps
|
|
|
|
|
|
const DROP = 4; // frames dropped per minute (except every 10th)
|
|
|
|
|
|
const FRAMES_FIRST_MIN = NOM * 60; // 3600 – non-drop minute
|
|
|
|
|
|
const FRAMES_PER_MIN = NOM * 60 - DROP; // 3596 – drop minute
|
|
|
|
|
|
const FRAMES_PER_10MIN = FRAMES_PER_MIN * 10 + DROP; // 35964
|
|
|
|
|
|
const FRAMES_PER_HOUR = FRAMES_PER_10MIN * 6; // 215784
|
2026-05-18 19:58:34 -04:00
|
|
|
|
|
|
|
|
|
|
function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); }
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Convert a frame count to a 59.94 DF timecode string: HH:MM:SS;FF
|
2026-05-19 00:01:18 -04:00
|
|
|
|
*
|
|
|
|
|
|
* Each 10-minute group contains one non-drop minute (3600 frames) followed
|
|
|
|
|
|
* by nine drop minutes (3596 frames each). Frame numbers 0–3 are skipped at
|
|
|
|
|
|
* the start of every drop minute; we account for this by shifting the
|
|
|
|
|
|
* within-minute frame index by DROP when computing ss/ff.
|
2026-05-18 19:58:34 -04:00
|
|
|
|
*/
|
|
|
|
|
|
function framesToTC(totalFrames) {
|
|
|
|
|
|
const fc = Math.max(0, Math.round(totalFrames));
|
|
|
|
|
|
const h = Math.floor(fc / FRAMES_PER_HOUR);
|
|
|
|
|
|
let rem = fc % FRAMES_PER_HOUR;
|
2026-05-19 00:01:18 -04:00
|
|
|
|
const tm = Math.floor(rem / FRAMES_PER_10MIN); // tens-of-minutes group (0–5)
|
2026-05-18 19:58:34 -04:00
|
|
|
|
rem = rem % FRAMES_PER_10MIN;
|
2026-05-19 00:01:18 -04:00
|
|
|
|
|
|
|
|
|
|
let m, ss, ff;
|
|
|
|
|
|
if (rem < FRAMES_FIRST_MIN) {
|
|
|
|
|
|
// First minute of the 10-min group — no frame drop
|
|
|
|
|
|
m = 0;
|
|
|
|
|
|
ss = Math.floor(rem / NOM);
|
|
|
|
|
|
ff = rem % NOM;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Minutes 1–9: frame numbers 0–3 at second :00 are skipped
|
|
|
|
|
|
rem -= FRAMES_FIRST_MIN;
|
|
|
|
|
|
m = Math.floor(rem / FRAMES_PER_MIN) + 1;
|
|
|
|
|
|
const remInMin = rem % FRAMES_PER_MIN;
|
|
|
|
|
|
// Shift by DROP so the label starts at :00;04 instead of :00;00
|
|
|
|
|
|
const adj = remInMin + DROP;
|
|
|
|
|
|
ss = Math.floor(adj / NOM);
|
|
|
|
|
|
ff = adj % NOM;
|
2026-05-18 19:58:34 -04:00
|
|
|
|
}
|
2026-05-19 00:01:18 -04:00
|
|
|
|
const M = tm * 10 + m;
|
|
|
|
|
|
return `${pad2(h)}:${pad2(M)}:${pad2(ss)};${pad2(ff)}`;
|
2026-05-18 19:58:34 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Convert a 59.94 DF timecode string (HH:MM:SS;FF or HH:MM:SS:FF) to frame count.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function tcToFrames(tc) {
|
|
|
|
|
|
if (!tc) return 0;
|
|
|
|
|
|
const clean = String(tc).replace(';', ':');
|
|
|
|
|
|
const parts = clean.split(':').map(Number);
|
|
|
|
|
|
if (parts.length !== 4) return 0;
|
|
|
|
|
|
const [h, m, s, f] = parts;
|
|
|
|
|
|
const totalMinutes = h * 60 + m;
|
|
|
|
|
|
return (NOM * 3600 * h)
|
|
|
|
|
|
+ (NOM * 60 * m)
|
|
|
|
|
|
+ (NOM * s)
|
|
|
|
|
|
+ f
|
|
|
|
|
|
- DROP * (totalMinutes - Math.floor(totalMinutes / 10));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Convert seconds (float) → frame count at 59.94.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function secondsToFrames(seconds) {
|
|
|
|
|
|
return Math.round(seconds * FPS_EXACT);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Convert frame count → seconds (float) at 59.94.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function framesToSeconds(frames) {
|
|
|
|
|
|
return frames / FPS_EXACT;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
global.TC = {
|
|
|
|
|
|
framesToTC,
|
|
|
|
|
|
tcToFrames,
|
|
|
|
|
|
secondsToFrames,
|
|
|
|
|
|
framesToSeconds,
|
|
|
|
|
|
FPS: FPS_EXACT,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
})(window);
|