feat: publish av-tools 0.1.0

This commit is contained in:
2026-07-24 14:58:03 +02:00
commit fcc537b024
254 changed files with 63270 additions and 0 deletions

199
src/media/timecode.ts Normal file
View 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');
}