timecode-converter
Version:
Modern TypeScript library for broadcast timecode conversions with full SMPTE drop-frame support
265 lines (258 loc) • 10 kB
JavaScript
;
// src/dropFrameHelpers.ts
var isDropFrameTimecode = (tc) => {
return tc.includes(";");
};
var isDropFrameRate = (fps) => {
return Math.abs(fps - 29.97) < 0.01 || Math.abs(fps - 59.94) < 0.01;
};
var calculateDroppedFrames = (hours, minutes, frameRate) => {
if (!isDropFrameRate(frameRate)) return 0;
const dropFramesPerMinute = Math.abs(frameRate - 29.97) < 0.01 ? 2 : 4;
const totalMinutes = hours * 60 + minutes;
const droppedMinutes = totalMinutes - Math.floor(totalMinutes / 10);
return droppedMinutes * dropFramesPerMinute;
};
var dropFrameTimecodeToFrames = (hours, minutes, seconds, frames, frameRate) => {
const nominalFps = Math.round(frameRate);
let totalFrames = frames;
totalFrames += seconds * nominalFps;
totalFrames += minutes * nominalFps * 60;
totalFrames += hours * nominalFps * 60 * 60;
const droppedFrames = calculateDroppedFrames(hours, minutes, frameRate);
return totalFrames - droppedFrames;
};
var framesToDropFrameTimecode = (frameCount, frameRate) => {
const nominalFps = Math.round(frameRate);
const dropFramesPerMinute = Math.abs(frameRate - 29.97) < 0.01 ? 2 : 4;
const actualFramesPerMinute = nominalFps * 60 - dropFramesPerMinute;
const framesPer10Minutes = actualFramesPerMinute * 9 + nominalFps * 60;
const tenMinuteSegments = Math.floor(frameCount / framesPer10Minutes);
let remainingFrames = frameCount % framesPer10Minutes;
let additionalMinutes = 0;
if (remainingFrames >= nominalFps * 60) {
remainingFrames -= nominalFps * 60;
additionalMinutes = 1 + Math.floor(remainingFrames / actualFramesPerMinute);
remainingFrames = remainingFrames % actualFramesPerMinute;
}
const totalMinutes = tenMinuteSegments * 10 + additionalMinutes;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
let displayFrames = remainingFrames;
if (minutes % 10 !== 0 && additionalMinutes > 0) {
displayFrames += dropFramesPerMinute;
}
const seconds = Math.floor(displayFrames / nominalFps);
const frames = displayFrames % nominalFps;
return { hours, minutes, seconds, frames };
};
// src/secondsToTimecode.ts
var normalisePlayerTime = (seconds, fps) => {
const totalFrames = Math.floor(Number((fps * seconds).toPrecision(12)));
return Number((totalFrames / fps).toFixed(2));
};
var padZero = (n) => {
if (n < 10) return `0${Math.floor(n)}`;
return String(Math.floor(n));
};
var secondsToTimecode = (seconds, frameRate, dropFrame) => {
let useDropFrame;
if (dropFrame !== void 0) {
useDropFrame = dropFrame;
} else if (isDropFrameRate(frameRate)) {
useDropFrame = seconds >= 60;
} else {
useDropFrame = false;
}
if (dropFrame === false && isDropFrameRate(frameRate) && seconds >= 3600) {
console.warn(
`Warning: Converting ${seconds} seconds at ${frameRate} fps without drop-frame. For durations over 1 hour, consider using drop-frame format for accurate broadcast timecode. After ${Math.floor(seconds / 3600)} hour(s), drift is approximately ${Math.round(seconds / 3600 * 3.6)} seconds.`
);
}
if (useDropFrame && isDropFrameRate(frameRate)) {
const totalFrames = Math.round(seconds * frameRate);
const { hours: hours2, minutes: minutes2, seconds: secs2, frames: frames2 } = framesToDropFrameTimecode(totalFrames, frameRate);
return `${padZero(hours2)}:${padZero(minutes2)}:${padZero(secs2)};${padZero(frames2)}`;
}
if (seconds === 0) {
return "00:00:00:00";
}
const fps = frameRate;
const normalisedSeconds = normalisePlayerTime(seconds, fps);
const wholeSeconds = Math.floor(normalisedSeconds);
const framesPrecise = (normalisedSeconds - wholeSeconds) * fps;
const frames = Math.round(framesPrecise * 100) / 100;
const hours = Math.floor(wholeSeconds / 3600);
const minutes = Math.floor(wholeSeconds % 3600 / 60);
const secs = wholeSeconds % 60;
return `${padZero(hours)}:${padZero(minutes)}:${padZero(secs)}:${padZero(frames)}`;
};
var secondsToTimecode_default = secondsToTimecode;
// src/timecodeToSeconds.ts
var parseTimecode = (tc) => {
const isDropFrame = isDropFrameTimecode(tc);
const normalized = tc.replace(";", ":");
const parts = normalized.split(":");
return {
hours: parseInt(parts[0], 10),
minutes: parseInt(parts[1], 10),
seconds: parseInt(parts[2], 10),
frames: parseInt(parts[3], 10),
isDropFrame
};
};
var timecodeToFrames = (tc, fps) => {
const { hours, minutes, seconds, frames, isDropFrame } = parseTimecode(tc);
if (isDropFrame && isDropFrameRate(fps)) {
return dropFrameTimecodeToFrames(hours, minutes, seconds, frames, fps);
}
let totalFrames = frames;
totalFrames += seconds * fps;
totalFrames += minutes * fps * 60;
totalFrames += hours * fps * 60 * 60;
return totalFrames;
};
var timecodeToSecondsHelper = (tc, frameRate) => {
const fps = frameRate;
if (isDropFrameTimecode(tc) && !isDropFrameRate(fps)) {
console.warn(
`Warning: Drop-frame timecode format (${tc}) used with non-drop-frame rate ${fps} fps. Drop-frame is only valid for 29.97 and 59.94 fps.`
);
}
const frames = timecodeToFrames(tc, fps);
return Number((frames / fps).toFixed(2));
};
var timecodeToSeconds_default = timecodeToSecondsHelper;
// src/padTimeToTimecode.ts
var countColon = (timecode) => timecode.split(":").length;
var includesFullStop = (timecode) => timecode.includes(".");
var isOneDigit = (str) => str.length === 1;
var padTimeToTimecode = (time) => {
if (typeof time === "string") {
const isDropFrame = time.includes(";");
const separatorCount = isDropFrame ? time.split(/[:;]/).length : countColon(time);
switch (separatorCount) {
case 4:
return time;
case 2: {
const parts = time.split(":");
if (isOneDigit(parts[0])) {
return `00:0${time}:00`;
}
return `00:${time}:00`;
}
case 3:
return `${time}:00`;
default: {
if (includesFullStop(time)) {
const parts = time.split(".");
if (isOneDigit(parts[0])) {
return `00:0${parts[0]}:${parts[1]}:00`;
}
return `00:${time.replace(".", ":")}:00`;
}
if (isOneDigit(time)) {
return `00:00:0${time}:00`;
}
return `00:00:${time}:00`;
}
}
}
return time;
};
var padTimeToTimecode_default = padTimeToTimecode;
// src/validateTimecode.ts
function validateTimecode(timecode, frameRate) {
const errors = [];
const warnings = [];
const dropFrameFormat = isDropFrameTimecode(timecode);
const format = dropFrameFormat ? "drop-frame" : "non-drop";
const normalized = timecode.replace(";", ":");
const parts = normalized.split(":");
if (parts.length !== 4) {
errors.push(`Invalid timecode format. Expected hh:mm:ss:ff or hh:mm:ss;ff, got ${timecode}`);
return { valid: false, errors, warnings };
}
const hours = parts[0] ? parseInt(parts[0], 10) : NaN;
const minutes = parts[1] ? parseInt(parts[1], 10) : NaN;
const seconds = parts[2] ? parseInt(parts[2], 10) : NaN;
const frames = parts[3] ? parseInt(parts[3], 10) : NaN;
if (isNaN(hours) || hours < 0) {
errors.push("Hours must be a valid non-negative number");
} else if (hours > 23) {
errors.push("Hours cannot exceed 23");
}
if (isNaN(minutes) || minutes < 0) {
errors.push("Minutes must be a valid non-negative number");
} else if (minutes > 59) {
errors.push("Minutes cannot exceed 59");
}
if (isNaN(seconds) || seconds < 0) {
errors.push("Seconds must be a valid non-negative number");
} else if (seconds > 59) {
errors.push("Seconds cannot exceed 59");
}
if (isNaN(frames) || frames < 0) {
errors.push("Frames must be a valid non-negative number");
}
if (frameRate !== void 0) {
const maxFrames = Math.round(frameRate) - 1;
if (frames > maxFrames) {
errors.push(`Frames cannot exceed ${maxFrames} for ${frameRate} fps`);
}
if (dropFrameFormat) {
if (!isDropFrameRate(frameRate)) {
warnings.push(
`Drop-frame format used with non-drop-frame rate ${frameRate} fps. Drop-frame is only valid for 29.97 and 59.94 fps.`
);
} else {
if (seconds === 0 && minutes % 10 !== 0 && frames < 2) {
errors.push(`Invalid drop-frame timecode. Frames 00 and 01 don't exist at minute ${minutes}`);
}
}
}
}
if (dropFrameFormat && frameRate === void 0) {
warnings.push("Drop-frame format detected but no frame rate provided for validation");
}
return {
valid: errors.length === 0,
errors,
warnings,
format,
components: errors.length === 0 ? { hours, minutes, seconds, frames } : void 0
};
}
// src/index.ts
var timecodeToSeconds = (time, frameRate) => {
if (typeof time === "string") {
if (frameRate === void 0) {
throw new Error("Frame rate must be specified when converting timecode strings to seconds");
}
const resultPadded = padTimeToTimecode_default(time);
if (typeof resultPadded === "string") {
const resultConverted = timecodeToSeconds_default(resultPadded, frameRate);
return resultConverted;
}
return resultPadded;
}
return parseFloat(String(time));
};
var shortTimecode = (time, frameRate, dropFrame) => {
if (time === 0) {
return "00:00:00";
} else {
const useDropFrame = dropFrame !== void 0 ? dropFrame : typeof time === "string" && time.includes(";");
const seconds = typeof time === "string" ? timecodeToSeconds(time, frameRate) : time;
const timecode = secondsToTimecode_default(seconds, frameRate, useDropFrame);
return timecode.slice(0, -3);
}
};
exports.isDropFrameRate = isDropFrameRate;
exports.isDropFrameTimecode = isDropFrameTimecode;
exports.secondsToTimecode = secondsToTimecode_default;
exports.shortTimecode = shortTimecode;
exports.timecodeToSeconds = timecodeToSeconds;
exports.validateTimecode = validateTimecode;
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map