200 lines
6.0 KiB
TypeScript
200 lines
6.0 KiB
TypeScript
import { roundSeconds } from './duration';
|
|
|
|
export type TimecodeParseFailure =
|
|
| 'empty'
|
|
| 'invalid-syntax'
|
|
| 'negative-not-allowed'
|
|
| 'minutes-out-of-range'
|
|
| 'seconds-out-of-range'
|
|
| 'frames-out-of-range'
|
|
| 'frame-rate-required'
|
|
| 'invalid-frame-rate'
|
|
| 'not-finite';
|
|
|
|
export type TimecodeParseResult =
|
|
{ ok: true; seconds: number } | { ok: false; reason: TimecodeParseFailure };
|
|
|
|
export interface ParseTimecodeOptions {
|
|
allowNegative?: boolean;
|
|
/**
|
|
* Enables HH:MM:SS:FF input. This is nominal, non-drop-frame timecode.
|
|
*/
|
|
frameRate?: number;
|
|
}
|
|
|
|
export interface FormatTimecodeOptions {
|
|
decimalPlaces?: number;
|
|
alwaysShowHours?: boolean;
|
|
}
|
|
|
|
const DECIMAL_COMPONENT = /^\d+(?:\.\d+)?$/;
|
|
const INTEGER_COMPONENT = /^\d+$/;
|
|
|
|
export function parseTimecodeDetailed(
|
|
input: string | number,
|
|
options: ParseTimecodeOptions = {}
|
|
): TimecodeParseResult {
|
|
const text = String(input).trim();
|
|
if (!text) {
|
|
return { ok: false, reason: 'empty' };
|
|
}
|
|
|
|
const negative = text.startsWith('-');
|
|
if (negative && options.allowNegative !== true) {
|
|
return { ok: false, reason: 'negative-not-allowed' };
|
|
}
|
|
|
|
const unsigned = negative || text.startsWith('+') ? text.slice(1) : text;
|
|
const parts = unsigned.split(':');
|
|
if (parts.length > 4 || parts.some((part) => part === '')) {
|
|
return { ok: false, reason: 'invalid-syntax' };
|
|
}
|
|
|
|
let seconds: number;
|
|
if (parts.length === 4) {
|
|
if (options.frameRate === undefined) {
|
|
return { ok: false, reason: 'frame-rate-required' };
|
|
}
|
|
if (!isValidFrameRate(options.frameRate)) {
|
|
return { ok: false, reason: 'invalid-frame-rate' };
|
|
}
|
|
if (!parts.every((part) => INTEGER_COMPONENT.test(part))) {
|
|
return { ok: false, reason: 'invalid-syntax' };
|
|
}
|
|
|
|
const hours = Number(parts[0]);
|
|
const minutes = Number(parts[1]);
|
|
const wholeSeconds = Number(parts[2]);
|
|
const frames = Number(parts[3]);
|
|
if (minutes >= 60) {
|
|
return { ok: false, reason: 'minutes-out-of-range' };
|
|
}
|
|
if (wholeSeconds >= 60) {
|
|
return { ok: false, reason: 'seconds-out-of-range' };
|
|
}
|
|
if (frames >= Math.ceil(options.frameRate)) {
|
|
return { ok: false, reason: 'frames-out-of-range' };
|
|
}
|
|
seconds =
|
|
hours * 3_600 + minutes * 60 + wholeSeconds + frames / options.frameRate;
|
|
} else {
|
|
if (
|
|
!parts.every((part, index) =>
|
|
index === parts.length - 1
|
|
? DECIMAL_COMPONENT.test(part)
|
|
: INTEGER_COMPONENT.test(part)
|
|
)
|
|
) {
|
|
return { ok: false, reason: 'invalid-syntax' };
|
|
}
|
|
const values = parts.map(Number);
|
|
const last = values.at(-1);
|
|
if (last === undefined) {
|
|
return { ok: false, reason: 'invalid-syntax' };
|
|
}
|
|
if (parts.length >= 2 && last >= 60) {
|
|
return { ok: false, reason: 'seconds-out-of-range' };
|
|
}
|
|
if (parts.length === 3 && (values[1] ?? 0) >= 60) {
|
|
return { ok: false, reason: 'minutes-out-of-range' };
|
|
}
|
|
|
|
seconds =
|
|
parts.length === 1
|
|
? last
|
|
: parts.length === 2
|
|
? (values[0] ?? 0) * 60 + last
|
|
: (values[0] ?? 0) * 3_600 + (values[1] ?? 0) * 60 + last;
|
|
}
|
|
|
|
seconds *= negative ? -1 : 1;
|
|
if (!Number.isFinite(seconds)) {
|
|
return { ok: false, reason: 'not-finite' };
|
|
}
|
|
return { ok: true, seconds: roundSeconds(seconds, 9) };
|
|
}
|
|
|
|
export function parseTimecode(
|
|
input: string | number,
|
|
options?: ParseTimecodeOptions
|
|
): number | undefined {
|
|
const result = parseTimecodeDetailed(input, options);
|
|
return result.ok ? result.seconds : undefined;
|
|
}
|
|
|
|
export function formatTimecode(
|
|
seconds: number,
|
|
options: FormatTimecodeOptions = {}
|
|
): string {
|
|
if (!Number.isFinite(seconds)) {
|
|
throw new RangeError('Timecode seconds must be finite.');
|
|
}
|
|
const decimalPlaces = options.decimalPlaces ?? 3;
|
|
if (
|
|
!Number.isInteger(decimalPlaces) ||
|
|
decimalPlaces < 0 ||
|
|
decimalPlaces > 9
|
|
) {
|
|
throw new RangeError('Decimal places must be an integer from 0 to 9.');
|
|
}
|
|
|
|
const negative = seconds < 0;
|
|
const unitsPerSecond = 10 ** decimalPlaces;
|
|
const totalUnits = Math.round(Math.abs(seconds) * unitsPerSecond);
|
|
const unitsPerMinute = 60 * unitsPerSecond;
|
|
const unitsPerHour = 60 * unitsPerMinute;
|
|
const hours = Math.floor(totalUnits / unitsPerHour);
|
|
const minuteRemainder = totalUnits % unitsPerHour;
|
|
const minutes = Math.floor(minuteRemainder / unitsPerMinute);
|
|
const secondUnits = minuteRemainder % unitsPerMinute;
|
|
const wholeSeconds = Math.floor(secondUnits / unitsPerSecond);
|
|
const fractionalUnits = secondUnits % unitsPerSecond;
|
|
const sign = negative && totalUnits !== 0 ? '-' : '';
|
|
const secondText =
|
|
decimalPlaces === 0
|
|
? padTwo(wholeSeconds)
|
|
: `${padTwo(wholeSeconds)}.${String(fractionalUnits).padStart(
|
|
decimalPlaces,
|
|
'0'
|
|
)}`;
|
|
|
|
if (hours > 0 || options.alwaysShowHours !== false) {
|
|
return `${sign}${String(hours).padStart(2, '0')}:${padTwo(minutes)}:${secondText}`;
|
|
}
|
|
return `${sign}${padTwo(minutes)}:${secondText}`;
|
|
}
|
|
|
|
export function formatFrameTimecode(
|
|
seconds: number,
|
|
frameRate: number
|
|
): string {
|
|
if (!Number.isFinite(seconds)) {
|
|
throw new RangeError('Timecode seconds must be finite.');
|
|
}
|
|
if (!isValidFrameRate(frameRate)) {
|
|
throw new RangeError('Frame rate must be finite and positive.');
|
|
}
|
|
|
|
const nominalFrameRate = Math.round(frameRate);
|
|
const totalFrames = Math.round(Math.abs(seconds) * frameRate);
|
|
const frames = totalFrames % nominalFrameRate;
|
|
const totalWholeSeconds = Math.floor(totalFrames / nominalFrameRate);
|
|
const wholeSeconds = totalWholeSeconds % 60;
|
|
const totalMinutes = Math.floor(totalWholeSeconds / 60);
|
|
const minutes = totalMinutes % 60;
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const sign = seconds < 0 && totalFrames !== 0 ? '-' : '';
|
|
|
|
return `${sign}${String(hours).padStart(2, '0')}:${padTwo(minutes)}:${padTwo(
|
|
wholeSeconds
|
|
)}:${String(frames).padStart(2, '0')}`;
|
|
}
|
|
|
|
function isValidFrameRate(frameRate: number): boolean {
|
|
return Number.isFinite(frameRate) && frameRate >= 1 && frameRate <= 1_000;
|
|
}
|
|
|
|
function padTwo(value: number): string {
|
|
return String(value).padStart(2, '0');
|
|
}
|