feat: publish av-tools 0.1.0
This commit is contained in:
199
src/media/timecode.ts
Normal file
199
src/media/timecode.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user