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'); }