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
+4584
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
error?: Error;
}
export class AppErrorBoundary extends Component<Props, State> {
state: State = {};
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('Unhandled av-tools application error', error, info);
}
render(): ReactNode {
if (this.state.error) {
return (
<main className="fatal-error" role="alert">
<p className="eyebrow">Application error</p>
<h1>The workspace could not continue.</h1>
<p>{this.state.error.message}</p>
<button type="button" onClick={() => globalThis.location.reload()}>
Reload application
</button>
</main>
);
}
return this.props.children;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { useEffect } from 'react';
import App from '../App';
import { engineManagerLifecycle } from '../ffmpeg/engine-lifecycle';
export function ApplicationRuntime() {
useEffect(() => engineManagerLifecycle.acquire(), []);
return <App />;
}
+67
View File
@@ -0,0 +1,67 @@
import type { MediaProbe } from '../media';
export type AssetPhase = 'queued' | 'probing' | 'ready' | 'error';
export interface ImportedMediaAsset {
id: string;
file: File;
objectUrl: string;
phase: AssetPhase;
probe?: MediaProbe;
rawProbe?: string;
error?: string;
}
export interface ExportResult {
id: string;
name: string;
mimeType: string;
bytes: number;
blob: Blob;
objectUrl: string;
createdAt: string;
}
export type WorkspaceMode = 'quick-convert' | 'edit';
export function createAsset(file: File): ImportedMediaAsset {
return {
id: crypto.randomUUID(),
file,
objectUrl: URL.createObjectURL(file),
phase: 'queued',
};
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown size';
if (bytes < 1_000) return `${bytes} B`;
const units = ['kB', 'MB', 'GB', 'TB'];
let value = bytes / 1_000;
let unit = units[0];
for (let index = 1; index < units.length && value >= 1_000; index += 1) {
value /= 1_000;
unit = units[index];
}
return `${value.toFixed(value >= 10 ? 1 : 2)} ${unit}`;
}
export function formatDuration(seconds: number | undefined): string {
if (seconds === undefined || !Number.isFinite(seconds)) return 'Unknown';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remaining = seconds % 60;
return [
...(hours > 0 ? [String(hours)] : []),
String(minutes).padStart(hours > 0 ? 2 : 1, '0'),
remaining.toFixed(2).padStart(5, '0'),
].join(':');
}
export function releaseAssets(assets: readonly ImportedMediaAsset[]): void {
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
}
export function releaseResults(results: readonly ExportResult[]): void {
for (const result of results) URL.revokeObjectURL(result.objectUrl);
}
+61
View File
@@ -0,0 +1,61 @@
import {
createCacheKey,
createSourceFingerprint,
openDerivedCache,
type DerivedCache,
type DerivedCacheKind,
} from '../storage';
let cachePromise: Promise<DerivedCache> | undefined;
export function getDerivedCache(): Promise<DerivedCache> {
cachePromise ??= openDerivedCache();
return cachePromise;
}
function derivativeCacheKey(
file: File,
operation: string,
settings: Readonly<Record<string, unknown>>
): string {
return createCacheKey({
sourceFingerprint: createSourceFingerprint(file),
operation,
settings,
version: 1,
});
}
export async function readCachedDerivative(
file: File,
operation: string,
settings: Readonly<Record<string, unknown>>
): Promise<Blob | undefined> {
const cache = await getDerivedCache();
return (await cache.get(derivativeCacheKey(file, operation, settings)))?.data;
}
export async function cacheDerivative(
file: File,
operation: string,
kind: DerivedCacheKind,
projectId: string,
blob: Blob,
settings: Readonly<Record<string, unknown>>
): Promise<void> {
const cache = await getDerivedCache();
const key = derivativeCacheKey(file, operation, settings);
const expiresAt = new Date(
Date.now() + 7 * 24 * 60 * 60 * 1_000
).toISOString();
await cache.put(
{
key,
kind,
projectId,
mediaType: blob.type || 'application/octet-stream',
expiresAt,
},
blob
);
}
+24
View File
@@ -0,0 +1,24 @@
export interface BrowserFeatures {
secureContext: boolean;
workers: boolean;
indexedDb: boolean;
opfs: boolean;
fileSystemAccess: boolean;
crossOriginIsolated: boolean;
sharedArrayBuffer: boolean;
}
export function detectBrowserFeatures(): BrowserFeatures {
const scope = globalThis as typeof globalThis & {
showSaveFilePicker?: unknown;
};
return {
secureContext: globalThis.isSecureContext === true,
workers: typeof Worker !== 'undefined',
indexedDb: typeof indexedDB !== 'undefined',
opfs: typeof navigator.storage?.getDirectory === 'function',
fileSystemAccess: typeof scope.showSaveFilePicker === 'function',
crossOriginIsolated: globalThis.crossOriginIsolated === true,
sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+366
View File
@@ -0,0 +1,366 @@
import type { SourceReference } from './command-utils';
import {
chapterSecondsToTicks,
DEFAULT_CHAPTER_TIME_BASE,
parseChapterTimeBase,
} from '../media/chapter-time-base';
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
workVirtualPath,
} from './command-utils';
import {
freezeCommandPlan,
type CommandDiagnostic,
type FFmpegCommandPlan,
} from './command-plan';
import { validateSplitMarkers } from './split';
export interface EditableChapter {
readonly id: string;
readonly title: string;
readonly startSeconds: number;
readonly endSeconds: number;
readonly timeBase?: string;
}
export interface ChapterValidationOptions {
readonly durationSeconds?: number;
readonly allowOverlap?: boolean;
}
export interface ChapterDocument {
readonly schemaVersion: 1;
readonly chapters: readonly EditableChapter[];
}
export function createChaptersFromMarkers(
markers: readonly number[],
durationSeconds: number,
titles: readonly string[] = []
): readonly EditableChapter[] {
const sorted = validateSplitMarkers(markers, durationSeconds);
const boundaries = [0, ...sorted, durationSeconds];
return Object.freeze(
boundaries.slice(0, -1).map((startSeconds, index) =>
Object.freeze({
id: `chapter-${String(index + 1).padStart(3, '0')}`,
title: titles[index]?.trim() || `Chapter ${index + 1}`,
startSeconds,
endSeconds: boundaries[index + 1] as number,
timeBase: '1/1000',
})
)
);
}
export function createChaptersAtIntervals(
durationSeconds: number,
intervalSeconds: number
): readonly EditableChapter[] {
if (
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0 ||
!Number.isFinite(intervalSeconds) ||
intervalSeconds <= 0
) {
throw new RangeError('Chapter duration and interval must be positive');
}
const markers: number[] = [];
for (
let marker = intervalSeconds;
marker < durationSeconds;
marker += intervalSeconds
) {
markers.push(marker);
if (markers.length > 10_000) {
throw new RangeError('Fixed interval would create too many chapters');
}
}
return createChaptersFromMarkers(markers, durationSeconds);
}
export function validateChapters(
chapters: readonly EditableChapter[],
options: ChapterValidationOptions = {}
): readonly CommandDiagnostic[] {
const diagnostics: CommandDiagnostic[] = [];
const ids = new Set<string>();
const titles = new Set<string>();
let previousEnd = 0;
chapters.forEach((chapter, index) => {
if (!chapter.id || ids.has(chapter.id)) {
diagnostics.push(
diagnostic(
'chapter-id',
'error',
'Chapter IDs must be nonempty and unique.',
`${index}.id`
)
);
}
ids.add(chapter.id);
if (!chapter.title.trim()) {
diagnostics.push(
diagnostic(
'chapter-title',
'error',
'Chapter title is required.',
`${index}.title`
)
);
} else if (titles.has(chapter.title.trim())) {
diagnostics.push(
diagnostic(
'duplicate-chapter-title',
'warning',
`Duplicate chapter title: ${chapter.title.trim()}`,
`${index}.title`
)
);
}
titles.add(chapter.title.trim());
if (
!Number.isFinite(chapter.startSeconds) ||
!Number.isFinite(chapter.endSeconds) ||
chapter.startSeconds < 0 ||
chapter.endSeconds <= chapter.startSeconds
) {
diagnostics.push(
diagnostic(
'chapter-range',
'error',
'Chapter start must be nonnegative and before its end.',
`${index}.startSeconds`
)
);
}
const timeBase = parseChapterTimeBase(
chapter.timeBase ?? DEFAULT_CHAPTER_TIME_BASE
);
if (timeBase === undefined) {
diagnostics.push(
diagnostic(
'chapter-time-base',
'error',
'Chapter time base must be a positive integer fraction such as 1/1000.',
`${index}.timeBase`
)
);
} else {
const startTick = chapterSecondsToTicks(chapter.startSeconds, timeBase);
const endTick = chapterSecondsToTicks(chapter.endSeconds, timeBase);
if (
startTick === undefined ||
endTick === undefined ||
endTick <= startTick
) {
diagnostics.push(
diagnostic(
'chapter-time-base-resolution',
'error',
'Chapter time base is too coarse or large for this chapter range.',
`${index}.timeBase`
)
);
}
}
if (
options.durationSeconds !== undefined &&
chapter.endSeconds > options.durationSeconds
) {
diagnostics.push(
diagnostic(
'chapter-bounds',
'error',
'Chapter extends beyond output duration.',
`${index}.endSeconds`
)
);
}
if (chapter.startSeconds < previousEnd) {
diagnostics.push(
diagnostic(
options.allowOverlap
? 'chapter-overlap'
: 'chapter-overlap-forbidden',
options.allowOverlap ? 'warning' : 'error',
'Chapter overlaps or is out of chronological order.',
`${index}.startSeconds`
)
);
}
previousEnd = Math.max(previousEnd, chapter.endSeconds);
});
return Object.freeze(diagnostics);
}
export function escapeFFMetadata(value: string): string {
if (value.includes('\0')) {
throw new TypeError('FFMETADATA values cannot contain NUL');
}
return value
.replace(/\\/gu, '\\\\')
.replace(/\r\n?|\n/gu, '\\\n')
.replace(/([=;#])/gu, '\\$1');
}
export function serializeFFMetadata(
chapters: readonly EditableChapter[],
options: ChapterValidationOptions = {}
): string {
const diagnostics = validateChapters(chapters, options);
const errors = diagnostics.filter((entry) => entry.severity === 'error');
if (errors.length > 0) {
throw new RangeError(errors.map((entry) => entry.message).join('; '));
}
const lines = [';FFMETADATA1'];
for (const chapter of chapters) {
const timeBase = parseChapterTimeBase(
chapter.timeBase ?? DEFAULT_CHAPTER_TIME_BASE
);
if (timeBase === undefined) {
throw new TypeError('Chapter time base failed validation');
}
const startTick = chapterSecondsToTicks(chapter.startSeconds, timeBase);
const endTick = chapterSecondsToTicks(chapter.endSeconds, timeBase);
if (startTick === undefined || endTick === undefined) {
throw new RangeError('Chapter time cannot be represented safely');
}
lines.push(
'[CHAPTER]',
`TIMEBASE=${timeBase.normalized}`,
`START=${startTick}`,
`END=${endTick}`,
`title=${escapeFFMetadata(chapter.title)}`
);
}
return `${lines.join('\n')}\n`;
}
export function serializeChapterJson(
chapters: readonly EditableChapter[]
): string {
const document: ChapterDocument = { schemaVersion: 1, chapters };
const errors = validateChapters(chapters).filter(
(entry) => entry.severity === 'error'
);
if (errors.length > 0) {
throw new TypeError(errors.map((entry) => entry.message).join('; '));
}
return `${JSON.stringify(document, null, 2)}\n`;
}
export function parseChapterJson(json: string): ChapterDocument {
let value: unknown;
try {
value = JSON.parse(json) as unknown;
} catch {
throw new TypeError('Chapter JSON is malformed');
}
if (
!isRecord(value) ||
value.schemaVersion !== 1 ||
!Array.isArray(value.chapters)
) {
throw new TypeError('Chapter document must use schemaVersion 1');
}
const chapters = value.chapters.map((entry, index) =>
parseChapter(entry, index)
);
const errors = validateChapters(chapters).filter(
(entry) => entry.severity === 'error'
);
if (errors.length > 0) {
throw new TypeError(errors.map((entry) => entry.message).join('; '));
}
return Object.freeze({ schemaVersion: 1, chapters: Object.freeze(chapters) });
}
export interface ChapterMuxOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly chapters: readonly EditableChapter[];
readonly targetExtension: string;
readonly targetMuxer: string;
readonly durationSeconds?: number;
}
export function buildChapterMuxPlan(
options: ChapterMuxOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
const metadataContent = serializeFFMetadata(options.chapters, {
durationSeconds: options.durationSeconds,
});
const input = createPlannedInput(options.jobId, options.source);
const metadataPath = workVirtualPath(options.jobId, 'chapters.ffmeta');
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'chapters',
extension: options.targetExtension,
});
return freezeCommandPlan({
id: `${options.jobId}:chapters`,
operation: 'chapters',
inputs: [input],
temporaryFiles: [
{ path: metadataPath, content: metadataContent, purpose: 'metadata' },
],
args: [
'-i',
input.path,
'-i',
metadataPath,
'-map',
'0',
'-map_metadata',
'0',
'-map_chapters',
'1',
'-c',
'copy',
output.path,
],
outputs: [output],
...(options.durationSeconds !== undefined
? { expectedDurationSeconds: options.durationSeconds }
: {}),
requiredCapabilities: { muxers: [options.targetMuxer] },
diagnostics: [
diagnostic(
'chapter-round-trip',
'info',
'Re-probe the output to verify chapter count, times, and titles.'
),
],
});
}
function parseChapter(value: unknown, index: number): EditableChapter {
if (!isRecord(value)) {
throw new TypeError(`chapters.${index} must be an object`);
}
if (
typeof value.id !== 'string' ||
typeof value.title !== 'string' ||
typeof value.startSeconds !== 'number' ||
typeof value.endSeconds !== 'number' ||
(value.timeBase !== undefined && typeof value.timeBase !== 'string')
) {
throw new TypeError(`chapters.${index} has invalid fields`);
}
return {
id: value.id,
title: value.title,
startSeconds: value.startSeconds,
endSeconds: value.endSeconds,
...(typeof value.timeBase === 'string' ? { timeBase: value.timeBase } : {}),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
+196
View File
@@ -0,0 +1,196 @@
export type CapabilityKind = 'muxers' | 'encoders' | 'decoders' | 'filters';
export interface PresetRequirements {
readonly muxers?: readonly string[];
readonly encoders?: readonly string[];
readonly decoders?: readonly string[];
readonly filters?: readonly string[];
}
export interface PlannedInput {
readonly id: string;
readonly path: string;
readonly sourceIndex: number;
readonly kind: 'media' | 'subtitle' | 'font' | 'metadata' | 'generated';
}
export interface PlannedTemporaryFile {
readonly path: string;
readonly content: string | Uint8Array;
readonly purpose:
'concat-list' | 'metadata' | 'subtitle' | 'analysis' | 'other';
}
export interface PlannedOutput {
readonly id: string;
readonly path: string;
readonly fileName: string;
readonly mediaType: string;
/** Exact source interval represented by this output, when applicable. */
readonly timeRange?: {
readonly startSeconds: number;
readonly endSeconds: number;
};
readonly role:
'media' | 'audio' | 'subtitle' | 'thumbnail' | 'contact-sheet' | 'analysis';
}
export type DiagnosticSeverity = 'info' | 'warning' | 'error';
export interface CommandDiagnostic {
readonly code: string;
readonly severity: DiagnosticSeverity;
readonly message: string;
readonly field?: string;
}
export interface FFmpegCommandPlan {
readonly id: string;
readonly operation: string;
readonly inputs: readonly PlannedInput[];
readonly temporaryFiles: readonly PlannedTemporaryFile[];
/**
* Structured argv passed directly to FFmpeg. It is never interpreted by a
* shell and intentionally excludes the executable name.
*/
readonly args: readonly string[];
readonly outputs: readonly PlannedOutput[];
readonly expectedDurationSeconds?: number;
readonly timeoutMs?: number;
readonly requiredCapabilities: PresetRequirements;
readonly diagnostics: readonly CommandDiagnostic[];
}
export interface CommandPlanDefaults {
readonly timeoutMs?: number;
readonly expectedDurationSeconds?: number;
}
export function freezeCommandPlan(plan: FFmpegCommandPlan): FFmpegCommandPlan {
validateCommandPlanShape(plan);
const requirements: PresetRequirements = Object.freeze({
...(plan.requiredCapabilities.muxers
? { muxers: Object.freeze([...plan.requiredCapabilities.muxers]) }
: {}),
...(plan.requiredCapabilities.encoders
? { encoders: Object.freeze([...plan.requiredCapabilities.encoders]) }
: {}),
...(plan.requiredCapabilities.decoders
? { decoders: Object.freeze([...plan.requiredCapabilities.decoders]) }
: {}),
...(plan.requiredCapabilities.filters
? { filters: Object.freeze([...plan.requiredCapabilities.filters]) }
: {}),
});
return Object.freeze({
...plan,
inputs: Object.freeze(
plan.inputs.map((entry) => Object.freeze({ ...entry }))
),
temporaryFiles: Object.freeze(
plan.temporaryFiles.map((entry) => Object.freeze({ ...entry }))
),
args: Object.freeze([...plan.args]),
outputs: Object.freeze(
plan.outputs.map((entry) =>
Object.freeze({
...entry,
...(entry.timeRange
? { timeRange: Object.freeze({ ...entry.timeRange }) }
: {}),
})
)
),
requiredCapabilities: requirements,
diagnostics: Object.freeze(
plan.diagnostics.map((entry) => Object.freeze({ ...entry }))
),
});
}
function validateCommandPlanShape(plan: FFmpegCommandPlan): void {
if (!plan.id.trim() || !plan.operation.trim()) {
throw new TypeError('Command plan id and operation are required');
}
if (
plan.args.some(
(argument) => argument.length === 0 || argument.includes('\0')
)
) {
throw new TypeError('Command arguments cannot be empty or contain NUL');
}
if (
plan.expectedDurationSeconds !== undefined &&
(!Number.isFinite(plan.expectedDurationSeconds) ||
plan.expectedDurationSeconds <= 0)
) {
throw new RangeError('Expected command duration must be positive');
}
if (
plan.timeoutMs !== undefined &&
(!Number.isSafeInteger(plan.timeoutMs) || plan.timeoutMs <= 0)
) {
throw new RangeError('Command timeout must be a positive integer');
}
assertUnique(
plan.outputs.map((output) => output.id),
'output id'
);
assertUnique(
plan.outputs.map((output) => output.path),
'output path'
);
assertUnique(
plan.temporaryFiles.map((temporary) => temporary.path),
'temporary-file path'
);
const temporaryPaths = new Set(
plan.temporaryFiles.map((temporary) => temporary.path)
);
if (plan.outputs.some((output) => temporaryPaths.has(output.path))) {
throw new TypeError('Output and temporary-file paths must be distinct');
}
for (const output of plan.outputs) {
if (
output.timeRange &&
(!Number.isFinite(output.timeRange.startSeconds) ||
output.timeRange.startSeconds < 0 ||
!Number.isFinite(output.timeRange.endSeconds) ||
output.timeRange.endSeconds <= output.timeRange.startSeconds)
) {
throw new RangeError(
`Output "${output.id}" has an invalid source time range.`
);
}
if (!plan.args.some((argument) => argument.includes(output.path))) {
throw new TypeError(
`Declared output path is absent from command arguments: ${output.path}`
);
}
}
for (const temporary of plan.temporaryFiles) {
if (!plan.args.some((argument) => argument.includes(temporary.path))) {
throw new TypeError(
`Declared temporary path is absent from command arguments: ${temporary.path}`
);
}
}
}
function assertUnique(values: readonly string[], label: string): void {
const unique = new Set(values);
if (unique.size !== values.length) {
throw new TypeError(`Command plan contains a duplicate ${label}`);
}
}
export class CommandPlanError extends Error {
readonly diagnostics: readonly CommandDiagnostic[];
constructor(message: string, diagnostics: readonly CommandDiagnostic[]) {
super(message);
this.name = 'CommandPlanError';
this.diagnostics = diagnostics;
}
}
+409
View File
@@ -0,0 +1,409 @@
import type {
CommandDiagnostic,
PlannedInput,
PlannedOutput,
PresetRequirements,
} from './command-plan';
// eslint-disable-next-line no-control-regex -- control characters are deliberately removed from virtual filenames.
const CONTROL_OR_SEPARATOR = /[\u0000-\u001f\u007f/\\]+/gu;
const UNSAFE_FILE_CHARACTER = /[^a-zA-Z0-9._-]+/gu;
const RESERVED_COMPONENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;
export interface SourceReference {
readonly id: string;
readonly sourceIndex: number;
readonly fileName: string;
readonly extension?: string;
}
export interface OutputReference {
readonly id?: string;
readonly baseName: string;
readonly operation: string;
readonly extension: string;
readonly role?: PlannedOutput['role'];
readonly mediaType?: string;
readonly suffix?: string;
readonly timeRange?: PlannedOutput['timeRange'];
/** Optional user-facing leaf name. Its extension is always replaced. */
readonly fileName?: string;
}
export interface StreamSelection {
readonly video?: readonly number[];
readonly audio?: readonly number[];
readonly subtitles?: readonly number[];
readonly attachments?: readonly number[];
readonly data?: readonly number[];
}
export function normalizeExtension(extension: string): string {
const normalized = extension.trim().replace(/^\.+/u, '').toLowerCase();
if (!/^[a-z0-9]{1,10}$/u.test(normalized)) {
throw new TypeError(`Invalid file extension: ${extension}`);
}
return normalized;
}
export function safePathComponent(value: string, fallback = 'media'): string {
let result = value
.normalize('NFKC')
.replace(CONTROL_OR_SEPARATOR, '-')
.replace(UNSAFE_FILE_CHARACTER, '-')
.replace(/-+/gu, '-')
.replace(/^[ ._-]+|[ ._-]+$/gu, '')
.slice(0, 96);
if (
!result ||
result === '.' ||
result === '..' ||
RESERVED_COMPONENT.test(result)
) {
result = fallback;
}
return result;
}
export function sourceVirtualPath(
jobId: string,
source: SourceReference
): string {
const job = safePathComponent(jobId, 'job');
const extension = source.extension
? `.${normalizeExtension(source.extension)}`
: extensionFromFileName(source.fileName);
return `/input/job-${job}/source-${source.sourceIndex}${extension}`;
}
export function workVirtualPath(jobId: string, fileName: string): string {
return `/work/job-${safePathComponent(jobId, 'job')}/${safePathComponent(fileName)}`;
}
export function createPlannedInput(
jobId: string,
source: SourceReference,
kind: PlannedInput['kind'] = 'media'
): PlannedInput {
return {
id: source.id,
path: sourceVirtualPath(jobId, source),
sourceIndex: source.sourceIndex,
kind,
};
}
export function createPlannedOutput(
jobId: string,
reference: OutputReference
): PlannedOutput {
const extension = normalizeExtension(reference.extension);
const fileName = reference.fileName
? `${safePathComponent(stripExtension(reference.fileName), 'output').slice(
0,
180
)}.${extension}`
: `${[
safePathComponent(stripExtension(reference.baseName), 'output'),
safePathComponent(reference.operation, 'export'),
reference.suffix
? safePathComponent(reference.suffix, 'result')
: undefined,
]
.filter((part): part is string => Boolean(part))
.join('-')
.slice(0, 180)}.${extension}`;
return {
id: reference.id ?? `${reference.operation}-output`,
path: workVirtualPath(jobId, fileName),
fileName,
mediaType: reference.mediaType ?? mediaTypeForExtension(extension),
role: reference.role ?? 'media',
...(reference.timeRange ? { timeRange: reference.timeRange } : {}),
};
}
export function mediaTypeForExtension(extension: string): string {
const mediaTypes: Readonly<Record<string, string>> = {
mp4: 'video/mp4',
m4a: 'audio/mp4',
webm: 'video/webm',
mkv: 'video/x-matroska',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
opus: 'audio/ogg',
wav: 'audio/wav',
flac: 'audio/flac',
gif: 'image/gif',
webp: 'image/webp',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
srt: 'application/x-subrip',
vtt: 'text/vtt',
ass: 'text/x-ssa',
json: 'application/json',
};
return (
mediaTypes[normalizeExtension(extension)] ?? 'application/octet-stream'
);
}
export function muxerForExtension(extension: string): string | undefined {
const muxers: Readonly<Record<string, string>> = {
mp4: 'mp4',
m4a: 'ipod',
m4v: 'ipod',
mov: 'mov',
webm: 'webm',
mkv: 'matroska',
mp3: 'mp3',
ogg: 'ogg',
opus: 'opus',
wav: 'wav',
flac: 'flac',
gif: 'gif',
webp: 'webp',
png: 'image2',
jpg: 'image2',
jpeg: 'image2',
srt: 'srt',
vtt: 'webvtt',
ass: 'ass',
};
return muxers[normalizeExtension(extension)];
}
export function assertMuxerMatchesExtension(
muxer: string,
extension: string
): void {
const inferred = muxerForExtension(extension);
if (!inferred) {
return;
}
const normalizedMuxer =
muxer === 'mkv' ? 'matroska' : muxer === 'm4a' ? 'ipod' : muxer;
if (normalizedMuxer !== inferred) {
throw new TypeError(
`Muxer ${muxer} does not match the .${normalizeExtension(extension)} output extension (expected ${inferred})`
);
}
}
export function stripExtension(fileName: string): string {
const cleaned = fileName.replace(CONTROL_OR_SEPARATOR, '-');
const lastDot = cleaned.lastIndexOf('.');
return lastDot > 0 ? cleaned.slice(0, lastDot) : cleaned;
}
function extensionFromFileName(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0 || lastDot === fileName.length - 1) {
return '';
}
try {
return `.${normalizeExtension(fileName.slice(lastDot + 1))}`;
} catch {
return '';
}
}
export function assertFiniteNonNegative(value: number, name: string): void {
if (!Number.isFinite(value) || value < 0) {
throw new RangeError(`${name} must be a finite nonnegative number`);
}
}
export function normalizeSeconds(value: number): string {
assertFiniteNonNegative(value, 'time');
if (value === 0) {
return '0';
}
return value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '');
}
export function validateRange(
startSeconds: number,
endSeconds: number,
durationSeconds?: number,
minimumDurationSeconds = 0.04
): void {
assertFiniteNonNegative(startSeconds, 'startSeconds');
assertFiniteNonNegative(endSeconds, 'endSeconds');
if (endSeconds <= startSeconds) {
throw new RangeError('endSeconds must be greater than startSeconds');
}
if (endSeconds - startSeconds < minimumDurationSeconds) {
throw new RangeError(
`clip duration must be at least ${minimumDurationSeconds} seconds`
);
}
if (
durationSeconds !== undefined &&
(!Number.isFinite(durationSeconds) || endSeconds > durationSeconds + 1e-6)
) {
throw new RangeError('clip range exceeds source duration');
}
}
export function streamMapArguments(
selection: StreamSelection
): readonly string[] {
const result: string[] = [];
const selectedIndexes = new Set<number>();
const groups: readonly (keyof StreamSelection)[] = [
'video',
'audio',
'subtitles',
'attachments',
'data',
];
for (const key of groups) {
const indexes = selection[key];
if (!indexes) {
continue;
}
if (new Set(indexes).size !== indexes.length) {
throw new RangeError(`Duplicate ${key} stream selection`);
}
for (const index of indexes) {
if (!Number.isSafeInteger(index) || index < 0) {
throw new RangeError(`Invalid ${key} stream index: ${index}`);
}
if (selectedIndexes.has(index)) {
throw new RangeError(
`Stream index ${index} was selected in more than one stream group`
);
}
selectedIndexes.add(index);
// StreamSelection stores the absolute ffprobe stream index. A typed
// specifier such as 0:a:1 would instead mean "the second audio stream"
// and silently points at the wrong stream for common video=0/audio=1
// inputs.
result.push('-map', `0:${index}`);
}
}
return Object.freeze(result);
}
export function mergeRequirements(
...requirements: readonly PresetRequirements[]
): PresetRequirements {
const kinds = ['muxers', 'encoders', 'decoders', 'filters'] as const;
const result: Record<(typeof kinds)[number], string[]> = {
muxers: [],
encoders: [],
decoders: [],
filters: [],
};
for (const requirement of requirements) {
for (const kind of kinds) {
for (const value of requirement[kind] ?? []) {
if (!result[kind].includes(value)) {
result[kind].push(value);
}
}
}
}
return Object.fromEntries(
kinds
.filter((kind) => result[kind].length > 0)
.map((kind) => [kind, Object.freeze(result[kind])])
) as PresetRequirements;
}
export function diagnostic(
code: string,
severity: CommandDiagnostic['severity'],
message: string,
field?: string
): CommandDiagnostic {
return field
? { code, severity, message, field }
: { code, severity, message };
}
/**
* Shell-like rendering for diagnostics only. The returned string must never be
* executed; command plans always expose argv separately.
*/
export function displayArguments(args: readonly string[]): string {
return args.map(displayArgument).join(' ');
}
function displayArgument(argument: string): string {
if (/^[a-zA-Z0-9_./:=+%@,-]+$/u.test(argument)) {
return argument;
}
return `'${argument.replace(/'/gu, `'\\''`)}'`;
}
export function escapeConcatFilePath(path: string): string {
if (path.includes('\n') || path.includes('\r') || path.includes('\0')) {
throw new TypeError('Concat paths cannot contain line breaks or NUL');
}
return path.replace(/'/gu, "'\\''");
}
export function createConcatList(paths: readonly string[]): string {
if (paths.length === 0) {
throw new RangeError('At least one concat path is required');
}
return `${paths.map((path) => `file '${escapeConcatFilePath(path)}'`).join('\n')}\n`;
}
export interface ConcatListEntry {
readonly path: string;
readonly inPointSeconds?: number;
readonly outPointSeconds?: number;
}
export function createConcatListEntries(
entries: readonly ConcatListEntry[]
): string {
if (entries.length === 0) {
throw new RangeError('At least one concat entry is required');
}
const lines: string[] = [];
for (const entry of entries) {
lines.push(`file '${escapeConcatFilePath(entry.path)}'`);
if (entry.inPointSeconds !== undefined) {
lines.push(`inpoint ${normalizeSeconds(entry.inPointSeconds)}`);
}
if (entry.outPointSeconds !== undefined) {
lines.push(`outpoint ${normalizeSeconds(entry.outPointSeconds)}`);
}
if (
entry.inPointSeconds !== undefined &&
entry.outPointSeconds !== undefined &&
entry.outPointSeconds <= entry.inPointSeconds
) {
throw new RangeError('Concat outpoint must be after its inpoint');
}
}
return `${lines.join('\n')}\n`;
}
export function escapeFilterValue(value: string): string {
if (value.includes('\0') || value.includes('\n') || value.includes('\r')) {
throw new TypeError('Filter values cannot contain line breaks or NUL');
}
return value
.replace(/\\/gu, '\\\\')
.replace(/'/gu, "\\'")
.replace(/:/gu, '\\:')
.replace(/,/gu, '\\,')
.replace(/;/gu, '\\;')
.replace(/\[/gu, '\\[')
.replace(/\]/gu, '\\]');
}
export function escapeMetadataValue(value: string): string {
if (value.includes('\0')) {
throw new TypeError('Metadata values cannot contain NUL');
}
return value.replace(/\r\n?/gu, '\n');
}
+499
View File
@@ -0,0 +1,499 @@
import { presetEncodingArguments } from '../presets/preset-registry';
import type { ExportPreset } from './convert';
import { metadataPolicyArguments, requirementsForUserPreset } from './convert';
import {
assertMuxerMatchesExtension,
createConcatListEntries,
createPlannedInput,
createPlannedOutput,
diagnostic,
mergeRequirements,
normalizeSeconds,
validateRange,
workVirtualPath,
type SourceReference,
} from './command-utils';
import {
CommandPlanError,
freezeCommandPlan,
type CommandDiagnostic,
type FFmpegCommandPlan,
type PlannedTemporaryFile,
} from './command-plan';
export interface ConcatStreamDescription {
readonly kind: 'video' | 'audio' | 'subtitle';
readonly codec: string;
readonly width?: number;
readonly height?: number;
readonly pixelFormat?: string;
readonly timeBase?: string;
readonly sampleRate?: number;
readonly channelLayout?: string;
}
export interface ConcatSource extends SourceReference {
/** Full probed source duration. */
readonly durationSeconds: number;
readonly sourceInSeconds?: number;
readonly sourceOutSeconds?: number;
readonly streams: readonly ConcatStreamDescription[];
}
export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject';
const MAX_CONCAT_SOURCES = 100;
export interface FastConcatOptions {
readonly jobId: string;
readonly sources: readonly ConcatSource[];
readonly targetExtension: string;
readonly targetMuxer: string;
}
export interface NormalizedConcatOptions extends FastConcatOptions {
readonly preset: ExportPreset;
readonly width: number;
readonly height: number;
readonly frameRate: number;
readonly sampleRate?: number;
readonly channelLayout?: 'mono' | 'stereo' | '5.1';
readonly missingAudioPolicy: MissingAudioPolicy;
readonly subtitlePolicy?: 'drop-all' | 'reject';
}
export function validateConcatCompatibility(
sources: readonly ConcatSource[]
): readonly CommandDiagnostic[] {
const diagnostics: CommandDiagnostic[] = [];
if (sources.length < 2) {
diagnostics.push(
diagnostic(
'concat-input-count',
'error',
'Concatenation requires at least two inputs.'
)
);
return diagnostics;
}
if (sources.length > MAX_CONCAT_SOURCES) {
diagnostics.push(
diagnostic(
'concat-input-limit',
'error',
`Concatenation is limited to ${MAX_CONCAT_SOURCES} browser inputs per operation.`
)
);
return diagnostics;
}
sources.forEach((source, index) => {
if (
!Number.isFinite(source.durationSeconds) ||
source.durationSeconds <= 0
) {
diagnostics.push(
diagnostic(
'source-duration',
'error',
`Clip ${index + 1} must have a positive finite duration.`,
`sources.${index}.durationSeconds`
)
);
}
try {
selectedSourceRange(source);
} catch (error) {
diagnostics.push(
diagnostic(
'source-range',
'error',
error instanceof Error
? `Clip ${index + 1}: ${error.message}`
: `Clip ${index + 1} has an invalid source range.`,
`sources.${index}.sourceInSeconds`
)
);
}
});
const baseline = sources[0]?.streams ?? [];
sources.slice(1).forEach((source, sourceIndex) => {
if (source.streams.length !== baseline.length) {
diagnostics.push(
diagnostic(
'stream-arrangement',
'error',
`Clip ${sourceIndex + 2} has a different stream count.`
)
);
return;
}
source.streams.forEach((stream, streamIndex) => {
const expected = baseline[streamIndex];
if (!expected || stream.kind !== expected.kind) {
diagnostics.push(
diagnostic(
'stream-arrangement',
'error',
`Clip ${sourceIndex + 2}, stream ${streamIndex} has a different stream type.`
)
);
return;
}
for (const key of comparableKeys(stream.kind)) {
if (stream[key] !== expected[key]) {
diagnostics.push(
diagnostic(
'stream-property',
'error',
`Clip ${sourceIndex + 2} has incompatible ${key}: ${String(
stream[key]
)} instead of ${String(expected[key])}.`,
`sources.${sourceIndex + 1}.streams.${streamIndex}.${key}`
)
);
}
}
});
});
return Object.freeze(diagnostics);
}
export function buildFastConcatPlan(
options: FastConcatOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
const diagnostics = validateConcatCompatibility(options.sources);
if (diagnostics.some((entry) => entry.severity === 'error')) {
throw new CommandPlanError(
'Inputs are not stream-copy compatible',
diagnostics
);
}
const inputs = options.sources.map((source) =>
createPlannedInput(options.jobId, source)
);
const ranges = options.sources.map(selectedSourceRange);
const concatPath = workVirtualPath(options.jobId, 'concat-list.txt');
const temporaryFile: PlannedTemporaryFile = {
path: concatPath,
content: createConcatListEntries(
inputs.map((input, index) => {
const source = options.sources[index];
const range = ranges[index];
if (!source || !range) {
throw new TypeError(`Missing concat source ${index + 1}`);
}
const hasExplicitRange =
source.sourceInSeconds !== undefined ||
source.sourceOutSeconds !== undefined;
return {
path: input.path,
...(hasExplicitRange
? {
inPointSeconds: range.startSeconds,
outPointSeconds: range.endSeconds,
}
: {}),
};
})
),
purpose: 'concat-list',
};
const output = createPlannedOutput(options.jobId, {
baseName: options.sources[0]?.fileName ?? 'sequence',
operation: 'concat',
extension: options.targetExtension,
});
const duration = ranges.reduce(
(sum, range) => sum + range.durationSeconds,
0
);
const hasTrimmedClips = options.sources.some(
(source) =>
source.sourceInSeconds !== undefined ||
source.sourceOutSeconds !== undefined
);
return freezeCommandPlan({
id: `${options.jobId}:concat`,
operation: 'concat-fast',
inputs,
temporaryFiles: [temporaryFile],
args: [
'-f',
'concat',
'-safe',
'0',
'-i',
concatPath,
'-map',
'0',
'-c',
'copy',
'-progress',
'pipe:1',
'-nostats',
output.path,
],
outputs: [output],
expectedDurationSeconds: duration,
requiredCapabilities: { muxers: [options.targetMuxer] },
diagnostics: [
diagnostic(
'generated-concat-list',
'info',
'The concat list contains only generated virtual paths; original filenames are not inserted.'
),
...(hasTrimmedClips
? [
diagnostic(
'concat-keyframe-boundaries',
'warning' as const,
'Fast concat in/out points use stream copy; packets around sparse keyframes can extend beyond the requested clip boundaries.'
),
]
: []),
],
});
}
export function buildNormalizedConcatPlan(
options: NormalizedConcatOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
if (
options.sources.length < 2 ||
options.sources.length > MAX_CONCAT_SOURCES
) {
throw new RangeError(
`Concatenation requires 2${MAX_CONCAT_SOURCES} inputs`
);
}
if (!options.preset.video) {
throw new TypeError('Normalized concatenation requires a video preset');
}
if (
options.targetExtension.replace(/^\.+/u, '').toLowerCase() !==
options.preset.fileExtension
) {
throw new TypeError(
`Normalized concat extension must match the preset (.${options.preset.fileExtension})`
);
}
validateOutputGeometry(options.width, options.height, options.frameRate);
if (
options.sampleRate !== undefined &&
(!Number.isSafeInteger(options.sampleRate) ||
options.sampleRate < 8_000 ||
options.sampleRate > 192_000)
) {
throw new RangeError(
'Normalized audio sample rate must be from 8000 to 192000 Hz'
);
}
const ranges = options.sources.map(selectedSourceRange);
for (const [index, source] of options.sources.entries()) {
if (
!Number.isFinite(source.durationSeconds) ||
source.durationSeconds <= 0
) {
throw new RangeError(`Clip ${index + 1} must have a positive duration`);
}
if (!source.streams.some((stream) => stream.kind === 'video')) {
throw new TypeError(
`Clip ${index + 1} has no video; synthetic video gaps are not implemented`
);
}
}
const hasAudio = options.sources.map((source) =>
source.streams.some((stream) => stream.kind === 'audio')
);
const mixedAudioPresence =
hasAudio.some(Boolean) && hasAudio.some((value) => !value);
if (mixedAudioPresence && options.missingAudioPolicy === 'reject') {
throw new CommandPlanError('Some clips have no audio stream', [
diagnostic(
'missing-audio',
'error',
'Choose insert silence or drop audio from every clip before concatenating.'
),
]);
}
const hasSubtitles = options.sources.some((source) =>
source.streams.some((stream) => stream.kind === 'subtitle')
);
if (hasSubtitles && options.subtitlePolicy !== 'drop-all') {
throw new CommandPlanError(
'Normalized concatenation cannot retain subtitle streams implicitly',
[
diagnostic(
'subtitle-policy-required',
'error',
'Choose the explicit drop-all subtitle policy, or remove subtitles before normalized concatenation.'
),
]
);
}
const includeAudio =
options.missingAudioPolicy !== 'drop-all' && hasAudio.some(Boolean);
const inputs = options.sources.map((source) =>
createPlannedInput(options.jobId, source)
);
const args: string[] = [];
inputs.forEach((input) => args.push('-i', input.path));
const graphParts: string[] = [];
options.sources.forEach((_, index) => {
const range = ranges[index];
if (!range) {
throw new TypeError(`Missing normalized concat range ${index + 1}`);
}
graphParts.push(
`[${index}:v:0]trim=start=${normalizeSeconds(range.startSeconds)}:end=${normalizeSeconds(range.endSeconds)},setpts=PTS-STARTPTS,scale=${options.width}:${options.height}:force_original_aspect_ratio=decrease,pad=${options.width}:${options.height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${options.frameRate},format=yuv420p[v${index}]`
);
if (includeAudio) {
if (hasAudio[index]) {
graphParts.push(
`[${index}:a:0]atrim=start=${normalizeSeconds(range.startSeconds)}:end=${normalizeSeconds(range.endSeconds)},asetpts=PTS-STARTPTS,aresample=${options.sampleRate ?? 48_000},aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}[a${index}]`
);
} else {
graphParts.push(
`anullsrc=r=${options.sampleRate ?? 48_000}:cl=${options.channelLayout ?? 'stereo'},atrim=duration=${normalizeSeconds(range.durationSeconds)},aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}[a${index}]`
);
}
}
});
const labels = options.sources
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
.join('');
graphParts.push(
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
);
args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]');
if (includeAudio) {
args.push('-map', '[aout]');
}
args.push(...presetEncodingArguments(options.preset));
if (!includeAudio) {
args.push('-an');
}
args.push(
...metadataPolicyArguments(options.preset),
'-map_chapters',
'-1',
'-sn'
);
const output = createPlannedOutput(options.jobId, {
baseName: options.sources[0]?.fileName ?? 'sequence',
operation: 'concat',
extension: options.targetExtension,
});
args.push('-progress', 'pipe:1', '-nostats', output.path);
const presetRequirements =
'builtIn' in options.preset
? options.preset.requirements
: requirementsForUserPreset(options.preset);
return freezeCommandPlan({
id: `${options.jobId}:concat`,
operation: 'concat-normalized',
inputs,
temporaryFiles: [],
args,
outputs: [output],
expectedDurationSeconds: ranges.reduce(
(sum, range) => sum + range.durationSeconds,
0
),
requiredCapabilities: mergeRequirements(presetRequirements, {
muxers: [options.targetMuxer],
filters: [
'trim',
'setpts',
'scale',
'pad',
'setsar',
'fps',
'format',
'concat',
...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []),
...(mixedAudioPresence &&
options.missingAudioPolicy === 'insert-silence'
? ['anullsrc', 'atrim']
: []),
],
}),
diagnostics: [
diagnostic(
'normalized-concat',
'info',
'Clips are normalized to common video and audio properties, then re-encoded.'
),
...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence'
? [
diagnostic(
'inserted-silence',
'warning' as const,
'Silence is generated for clips without audio.'
),
]
: []),
...(hasSubtitles
? [
diagnostic(
'dropped-subtitles',
'warning' as const,
'Subtitle streams are explicitly omitted from every normalized concat input.'
),
]
: []),
],
});
}
function comparableKeys(
kind: ConcatStreamDescription['kind']
): readonly (keyof ConcatStreamDescription)[] {
if (kind === 'video') {
return ['kind', 'codec', 'width', 'height', 'pixelFormat', 'timeBase'];
}
if (kind === 'audio') {
return ['kind', 'codec', 'sampleRate', 'channelLayout', 'timeBase'];
}
return ['kind', 'codec', 'timeBase'];
}
function validateOutputGeometry(
width: number,
height: number,
frameRate: number
): void {
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width < 2 ||
height < 2 ||
width > 8192 ||
height > 8192 ||
width % 2 !== 0 ||
height % 2 !== 0
) {
throw new RangeError(
'Output dimensions must be even integer values from 2 to 8192'
);
}
if (!Number.isFinite(frameRate) || frameRate <= 0 || frameRate > 240) {
throw new RangeError('frameRate must be between 0 and 240');
}
}
function selectedSourceRange(source: ConcatSource): {
readonly startSeconds: number;
readonly endSeconds: number;
readonly durationSeconds: number;
} {
const startSeconds = source.sourceInSeconds ?? 0;
const endSeconds = source.sourceOutSeconds ?? source.durationSeconds;
validateRange(startSeconds, endSeconds, source.durationSeconds);
return Object.freeze({
startSeconds,
endSeconds,
durationSeconds: endSeconds - startSeconds,
});
}
+242
View File
@@ -0,0 +1,242 @@
import type {
BuiltInExportPreset,
UserExportPreset,
} from '../presets/preset.types';
import { presetEncodingArguments } from '../presets/preset-registry';
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
mergeRequirements,
streamMapArguments,
type SourceReference,
type StreamSelection,
} from './command-utils';
import {
freezeCommandPlan,
type FFmpegCommandPlan,
type PresetRequirements,
} from './command-plan';
export type ExportPreset = BuiltInExportPreset | UserExportPreset;
export interface ConvertOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly preset: ExportPreset;
readonly streamSelection?: StreamSelection;
readonly expectedDurationSeconds?: number;
readonly timeoutMs?: number;
readonly operation?: string;
readonly videoFiltergraph?: string;
readonly audioFiltergraph?: string;
readonly additionalRequirements?: PresetRequirements;
}
export function buildConvertPlan(options: ConvertOptions): FFmpegCommandPlan {
assertMuxerMatchesExtension(
options.preset.container,
options.preset.fileExtension
);
const operation = options.operation ?? 'convert';
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation,
extension: options.preset.fileExtension,
role: options.preset.kind === 'audio' ? 'audio' : 'media',
});
const args: string[] = ['-i', input.path];
const paletteGif = options.preset.video?.codec === 'gif';
if (options.streamSelection && !paletteGif) {
const mapArgs = streamMapArguments(options.streamSelection);
if (mapArgs.length === 0) {
throw new RangeError('At least one output stream must be selected');
}
args.push(...mapArgs);
}
const videoFilters = [
options.videoFiltergraph,
options.preset.video?.width || options.preset.video?.height
? `scale=${options.preset.video.width ?? -2}:${options.preset.video.height ?? -2}`
: undefined,
].filter((entry): entry is string => Boolean(entry));
if (paletteGif) {
const selection = options.streamSelection;
if (
(selection?.video?.length ?? 1) !== 1 ||
(selection?.audio?.length ?? 0) > 0 ||
(selection?.subtitles?.length ?? 0) > 0 ||
(selection?.attachments?.length ?? 0) > 0 ||
(selection?.data?.length ?? 0) > 0
) {
throw new RangeError(
'Animated GIF export requires exactly one selected video stream'
);
}
const videoIndex = selection?.video?.[0] ?? 0;
if (!Number.isSafeInteger(videoIndex) || videoIndex < 0) {
throw new RangeError('Invalid GIF video stream index');
}
const prefix = videoFilters.length > 0 ? `${videoFilters.join(',')},` : '';
args.push(
'-filter_complex',
`[0:${videoIndex}]${prefix}split[gif-base][gif-palette];[gif-palette]palettegen[gif-p];[gif-base][gif-p]paletteuse[gif-out]`,
'-map',
'[gif-out]'
);
} else if (videoFilters.length > 0) {
args.push('-vf', videoFilters.join(','));
}
if (options.audioFiltergraph) {
args.push('-af', options.audioFiltergraph);
}
args.push(...presetEncodingArguments(options.preset));
const subtitleHandling = subtitleArguments(options);
args.push(...subtitleHandling.args);
if ((options.streamSelection?.attachments?.length ?? 0) > 0) {
args.push('-c:t', 'copy');
}
if ((options.streamSelection?.data?.length ?? 0) > 0) {
args.push('-c:d', 'copy');
}
args.push(...metadataPolicyArguments(options.preset));
args.push('-progress', 'pipe:1', '-nostats', output.path);
const requirements = isBuiltIn(options.preset)
? options.preset.requirements
: requirementsForUserPreset(options.preset);
return freezeCommandPlan({
id: `${options.jobId}:${operation}`,
operation,
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
...(options.timeoutMs !== undefined
? { timeoutMs: options.timeoutMs }
: {}),
requiredCapabilities: mergeRequirements(
requirements,
options.additionalRequirements ?? {},
subtitleHandling.requirements,
options.preset.video?.width || options.preset.video?.height
? { filters: ['scale'] }
: {}
),
diagnostics: [
diagnostic(
'conversion-reencodes',
'info',
'Selected audio or video streams are re-encoded according to the preset.'
),
...(paletteGif
? [
diagnostic(
'gif-palette',
'info' as const,
'GIF export uses one palette-generation and palette-application filter graph.'
),
]
: []),
],
});
}
export function requirementsForUserPreset(
preset: UserExportPreset
): PresetRequirements {
const encoders = [
...(preset.video ? [preset.video.codec] : []),
...(preset.audio ? [preset.audio.codec] : []),
];
return {
muxers: [preset.container],
...(encoders.length > 0 ? { encoders } : {}),
...(preset.subtitlePolicy === 'burn-in' ? { filters: ['subtitles'] } : {}),
};
}
export function metadataPolicyArguments(
preset: Pick<
ExportPreset,
'metadataPolicy' | 'chapterPolicy' | 'subtitlePolicy'
>
): readonly string[] {
const args: string[] = [];
if (preset.metadataPolicy === 'remove') {
args.push('-map_metadata', '-1');
} else {
args.push('-map_metadata', '0');
}
args.push('-map_chapters', preset.chapterPolicy === 'remove' ? '-1' : '0');
if (preset.subtitlePolicy === 'none') {
args.push('-sn');
}
return Object.freeze(args);
}
function isBuiltIn(preset: ExportPreset): preset is BuiltInExportPreset {
return 'builtIn' in preset && preset.builtIn;
}
function subtitleArguments(options: ConvertOptions): {
readonly args: readonly string[];
readonly requirements: PresetRequirements;
} {
const count = options.streamSelection?.subtitles?.length ?? 0;
if (count === 0) {
return { args: [], requirements: {} };
}
if (
options.preset.subtitlePolicy === 'none' ||
options.preset.subtitlePolicy === 'burn-in'
) {
throw new RangeError(
`Preset subtitle policy ${options.preset.subtitlePolicy} cannot emit selected soft-subtitle streams`
);
}
return softSubtitlePolicyConfiguration(options.preset);
}
export function softSubtitlePolicyConfiguration(
preset: Pick<ExportPreset, 'container' | 'subtitlePolicy'>
): {
readonly args: readonly string[];
readonly requirements: PresetRequirements;
} {
if (preset.subtitlePolicy === 'none') {
return { args: [], requirements: {} };
}
if (preset.subtitlePolicy === 'burn-in') {
throw new TypeError(
'Burn-in requires an explicit local subtitle filter input'
);
}
if (preset.subtitlePolicy === 'copy-compatible') {
return { args: ['-c:s', 'copy'], requirements: {} };
}
const encoder =
preset.container === 'mp4' || preset.container === 'ipod'
? 'mov_text'
: preset.container === 'webm'
? 'webvtt'
: undefined;
if (!encoder && preset.container === 'matroska') {
return { args: ['-c:s', 'copy'], requirements: {} };
}
if (!encoder) {
throw new TypeError(
`No reviewed subtitle conversion policy exists for ${preset.container}`
);
}
return {
args: ['-c:s', encoder],
requirements: { encoders: [encoder] },
};
}
+122
View File
@@ -0,0 +1,122 @@
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import type { SourceReference } from './command-utils';
import type { FFmpegCommandPlan } from './command-plan';
import {
audioFadeFilters,
videoFadeFilters,
type FadeCurve,
type AudioFilterStage,
type FilterNode,
type VideoFilterStage,
} from './filtergraph-builder';
export {
audioFadeFilters,
videoFadeFilters,
type AudioFilterStage,
type FilterNode,
type VideoFilterStage,
};
export interface FadePlanOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly preset: ExportPreset;
readonly durationSeconds: number;
readonly audioFadeInSeconds?: number;
readonly audioFadeOutSeconds?: number;
readonly videoFadeInSeconds?: number;
readonly videoFadeOutSeconds?: number;
readonly audioCurve?: FadeCurve;
readonly videoCurve?: FadeCurve;
}
export function buildFadePlan(options: FadePlanOptions): FFmpegCommandPlan {
if (
!Number.isFinite(options.durationSeconds) ||
options.durationSeconds <= 0
) {
throw new RangeError('Fade clip duration must be positive');
}
validatePair(
options.durationSeconds,
options.audioFadeInSeconds,
options.audioFadeOutSeconds,
'Audio'
);
validatePair(
options.durationSeconds,
options.videoFadeInSeconds,
options.videoFadeOutSeconds,
'Video'
);
const audioNodes = audioFadeFilters(
options.durationSeconds,
options.audioFadeInSeconds,
options.audioFadeOutSeconds,
options.audioCurve
);
const videoNodes = videoFadeFilters(
options.durationSeconds,
options.videoFadeInSeconds,
options.videoFadeOutSeconds,
options.videoCurve
);
if (audioNodes.length > 0 && !options.preset.audio) {
throw new TypeError('Audio fades require a preset with audio');
}
if (videoNodes.length > 0 && !options.preset.video) {
throw new TypeError('Video fades require a preset with video');
}
if (audioNodes.length === 0 && videoNodes.length === 0) {
throw new TypeError('At least one fade duration is required');
}
return buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
operation: 'fades',
expectedDurationSeconds: options.durationSeconds,
...(audioNodes.length > 0
? {
audioFiltergraph: audioNodes.map((node) => node.expression).join(','),
}
: {}),
...(videoNodes.length > 0
? {
videoFiltergraph: videoNodes.map((node) => node.expression).join(','),
}
: {}),
additionalRequirements: {
filters: [
...new Set(
[...audioNodes, ...videoNodes].flatMap(
(node) => node.requiredCapabilities?.filters ?? []
)
),
],
},
});
}
function validatePair(
durationSeconds: number,
fadeInSeconds: number | undefined,
fadeOutSeconds: number | undefined,
label: string
): void {
const fadeIn = fadeInSeconds ?? 0;
const fadeOut = fadeOutSeconds ?? 0;
if (
!Number.isFinite(fadeIn) ||
!Number.isFinite(fadeOut) ||
fadeIn < 0 ||
fadeOut < 0 ||
fadeIn + fadeOut > durationSeconds
) {
throw new RangeError(
`${label} fade durations must be nonnegative and cannot overlap`
);
}
}
+224
View File
@@ -0,0 +1,224 @@
import {
escapeFilterValue,
mergeRequirements,
normalizeSeconds,
} from './command-utils';
import type { PresetRequirements } from './command-plan';
export type VideoFilterStage =
| 'trim'
| 'crop'
| 'rotate'
| 'resize'
| 'frame-rate'
| 'format'
| 'subtitles'
| 'fade';
export type AudioFilterStage =
'trim' | 'format' | 'gain' | 'normalization' | 'fade' | 'concat';
export const FADE_CURVES = ['tri', 'qsin', 'exp'] as const;
export type FadeCurve = (typeof FADE_CURVES)[number];
export interface FilterNode<TStage extends string> {
readonly id: string;
readonly stage: TStage;
readonly expression: string;
readonly requiredCapabilities?: PresetRequirements;
}
const VIDEO_ORDER: readonly VideoFilterStage[] = [
'trim',
'crop',
'rotate',
'resize',
'frame-rate',
'format',
'subtitles',
'fade',
];
const AUDIO_ORDER: readonly AudioFilterStage[] = [
'trim',
'format',
'gain',
'normalization',
'fade',
'concat',
];
export class FiltergraphBuilder<TStage extends string> {
readonly #order: readonly TStage[];
readonly #nodes: FilterNode<TStage>[] = [];
constructor(order: readonly TStage[]) {
this.#order = order;
}
add(node: FilterNode<TStage>): this {
if (!node.id.trim() || !node.expression.trim()) {
throw new TypeError('Filter node id and expression are required');
}
if (!this.#order.includes(node.stage)) {
throw new TypeError(`Unknown filter stage: ${node.stage}`);
}
if (this.#nodes.some((existing) => existing.id === node.id)) {
throw new TypeError(`Duplicate filter node id: ${node.id}`);
}
this.#nodes.push(Object.freeze({ ...node }));
return this;
}
build(): {
readonly graph?: string;
readonly requirements: PresetRequirements;
} {
const order = new Map(this.#order.map((stage, index) => [stage, index]));
const nodes = [...this.#nodes].sort(
(left, right) =>
(order.get(left.stage) ?? Number.MAX_SAFE_INTEGER) -
(order.get(right.stage) ?? Number.MAX_SAFE_INTEGER) ||
left.id.localeCompare(right.id)
);
const graph = nodes.map((node) => node.expression).join(',');
return {
...(graph ? { graph } : {}),
requirements: mergeRequirements(
...nodes.map((node) => node.requiredCapabilities ?? {})
),
};
}
}
export function createVideoFiltergraph(): FiltergraphBuilder<VideoFilterStage> {
return new FiltergraphBuilder(VIDEO_ORDER);
}
export function createAudioFiltergraph(): FiltergraphBuilder<AudioFilterStage> {
return new FiltergraphBuilder(AUDIO_ORDER);
}
export function subtitleFilter(path: string): FilterNode<VideoFilterStage> {
return {
id: 'subtitles',
stage: 'subtitles',
expression: `subtitles=filename='${escapeFilterValue(path)}'`,
requiredCapabilities: { filters: ['subtitles'] },
};
}
export function audioFadeFilters(
durationSeconds: number,
fadeInSeconds?: number,
fadeOutSeconds?: number,
curve: FadeCurve = 'tri'
): readonly FilterNode<AudioFilterStage>[] {
assertFadeCurve(curve);
const nodes: FilterNode<AudioFilterStage>[] = [];
if (fadeInSeconds !== undefined && fadeInSeconds > 0) {
if (fadeInSeconds > durationSeconds) {
throw new RangeError('Audio fade-in exceeds clip duration');
}
nodes.push({
id: 'audio-fade-in',
stage: 'fade',
expression: `afade=t=in:st=0:d=${normalizeSeconds(fadeInSeconds)}:curve=${curve}`,
requiredCapabilities: { filters: ['afade'] },
});
}
if (fadeOutSeconds !== undefined && fadeOutSeconds > 0) {
if (fadeOutSeconds > durationSeconds) {
throw new RangeError('Audio fade-out exceeds clip duration');
}
nodes.push({
id: 'audio-fade-out',
stage: 'fade',
expression: `afade=t=out:st=${normalizeSeconds(
durationSeconds - fadeOutSeconds
)}:d=${normalizeSeconds(fadeOutSeconds)}:curve=${curve}`,
requiredCapabilities: { filters: ['afade'] },
});
}
return Object.freeze(nodes);
}
export function videoFadeFilters(
durationSeconds: number,
fadeInSeconds?: number,
fadeOutSeconds?: number,
curve: FadeCurve = 'tri'
): readonly FilterNode<VideoFilterStage>[] {
assertFadeCurve(curve);
const nodes: FilterNode<VideoFilterStage>[] = [];
if (fadeInSeconds !== undefined && fadeInSeconds > 0) {
if (fadeInSeconds > durationSeconds) {
throw new RangeError('Video fade-in exceeds clip duration');
}
nodes.push({
id: 'video-fade-in',
stage: 'fade',
expression:
curve === 'tri'
? `fade=t=in:st=0:d=${normalizeSeconds(fadeInSeconds)}:color=black`
: curvedVideoFadeExpression(
durationSeconds,
fadeInSeconds,
'in',
curve
),
requiredCapabilities: { filters: [curve === 'tri' ? 'fade' : 'geq'] },
});
}
if (fadeOutSeconds !== undefined && fadeOutSeconds > 0) {
if (fadeOutSeconds > durationSeconds) {
throw new RangeError('Video fade-out exceeds clip duration');
}
nodes.push({
id: 'video-fade-out',
stage: 'fade',
expression:
curve === 'tri'
? `fade=t=out:st=${normalizeSeconds(
durationSeconds - fadeOutSeconds
)}:d=${normalizeSeconds(fadeOutSeconds)}:color=black`
: curvedVideoFadeExpression(
durationSeconds,
fadeOutSeconds,
'out',
curve
),
requiredCapabilities: { filters: [curve === 'tri' ? 'fade' : 'geq'] },
});
}
return Object.freeze(nodes);
}
function assertFadeCurve(value: unknown): asserts value is FadeCurve {
if (!FADE_CURVES.includes(value as FadeCurve)) {
throw new TypeError('Fade curve must be tri, qsin, or exp');
}
}
function curvedVideoFadeExpression(
durationSeconds: number,
fadeSeconds: number,
direction: 'in' | 'out',
curve: Exclude<FadeCurve, 'tri'>
): string {
const duration = normalizeSeconds(durationSeconds);
const fade = normalizeSeconds(fadeSeconds);
const progress =
direction === 'in'
? `clip(T/${fade},0,1)`
: `clip((${duration}-T)/${fade},0,1)`;
const gain =
curve === 'qsin'
? `sin(PI/2*${progress})`
: `((exp(5*${progress})-1)/(exp(5)-1))`;
return [
`geq=lum_expr='lum(X,Y)*${gain}'`,
`cb_expr='128+(cb(X,Y)-128)*${gain}'`,
`cr_expr='128+(cr(X,Y)-128)*${gain}'`,
].join(':');
}
+20
View File
@@ -0,0 +1,20 @@
export * from './chapters';
export * from './command-plan';
export * from './command-utils';
export * from './concatenate';
export * from './convert';
export * from './fades';
export * from './filtergraph-builder';
export * from './loudness';
export * from './metadata';
export * from './preview-proxy';
export * from './probe';
export * from './remux';
export * from './split';
export * from './streams';
export * from './subtitles';
export * from './thumbnails';
export * from './timeline-export';
export * from './transform';
export * from './trim';
export * from './waveform';
+457
View File
@@ -0,0 +1,457 @@
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import {
createPlannedInput,
diagnostic,
normalizeSeconds,
type SourceReference,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export interface LoudnessMeasurement {
readonly inputIntegratedLufs: number;
readonly inputTruePeakDbtp: number;
readonly inputLoudnessRangeLu: number;
readonly inputThresholdLufs: number;
readonly targetOffsetLu?: number;
}
export interface LoudnessTargets {
readonly integratedLufs: number;
readonly truePeakDbtp: number;
readonly loudnessRangeLu: number;
}
export interface LoudnessAnalysisOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly targets: LoudnessTargets;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly expectedDurationSeconds?: number;
}
export interface LoudnessApplyOptions extends LoudnessAnalysisOptions {
readonly measurement: LoudnessMeasurement;
readonly preset: ExportPreset;
}
export interface PeakNormalizationOptions {
readonly jobId: string;
readonly source: SourceReference;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly targetPeakDb: number;
readonly measurement: PeakMeasurement;
readonly preset: ExportPreset;
readonly expectedDurationSeconds?: number;
}
export interface PeakMeasurement {
readonly inputPeakDbfs: number;
}
export interface PeakAnalysisOptions {
readonly jobId: string;
readonly source: SourceReference;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly preset: ExportPreset;
readonly expectedDurationSeconds?: number;
}
export const LOUDNESS_PROFILES: Readonly<
Record<'web-video' | 'podcast' | 'broadcast-style', LoudnessTargets>
> = Object.freeze({
'web-video': Object.freeze({
integratedLufs: -14,
truePeakDbtp: -1,
loudnessRangeLu: 11,
}),
podcast: Object.freeze({
integratedLufs: -16,
truePeakDbtp: -1,
loudnessRangeLu: 11,
}),
'broadcast-style': Object.freeze({
integratedLufs: -23,
truePeakDbtp: -2,
loudnessRangeLu: 7,
}),
});
export function buildLoudnessAnalysisPlan(
options: LoudnessAnalysisOptions
): FFmpegCommandPlan {
validateLoudnessTargets(options.targets);
const streamIndex = options.audioStreamIndex;
if (!Number.isSafeInteger(streamIndex) || streamIndex < 0) {
throw new RangeError('Invalid audio stream index');
}
const input = createPlannedInput(options.jobId, options.source);
const filter = loudnormFilter(options.targets, undefined, true);
return freezeCommandPlan({
id: `${options.jobId}:loudness-analysis`,
operation: 'loudness-analysis',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-map',
`0:${streamIndex}`,
'-af',
filter,
'-f',
'null',
'-',
],
outputs: [],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { filters: ['loudnorm'], muxers: ['null'] },
diagnostics: [
diagnostic(
'loudnorm-first-pass',
'info',
'First pass measures loudness only and preserves its logs for diagnostics.'
),
],
});
}
export function buildLoudnessApplyPlan(
options: LoudnessApplyOptions
): FFmpegCommandPlan {
validateLoudnessTargets(options.targets);
validateLoudnessMeasurement(options.measurement);
if (!options.preset.audio || options.preset.video) {
throw new TypeError(
'Measured loudness normalization requires an audio-only export preset'
);
}
return buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
streamSelection: { audio: [options.audioStreamIndex] },
expectedDurationSeconds: options.expectedDurationSeconds,
operation: 'normalize',
audioFiltergraph: loudnormFilter(
options.targets,
options.measurement,
false
),
additionalRequirements: { filters: ['loudnorm'] },
});
}
export function buildPeakAnalysisPlan(
options: PeakAnalysisOptions
): FFmpegCommandPlan {
if (
!Number.isSafeInteger(options.audioStreamIndex) ||
options.audioStreamIndex < 0
) {
throw new RangeError('Invalid audio stream index');
}
validateAudioOnlyPreset(options.preset, 'Peak analysis');
const input = createPlannedInput(options.jobId, options.source);
const formatFilters = audioPresetFormatFilters(options.preset);
return freezeCommandPlan({
id: `${options.jobId}:peak-analysis`,
operation: 'peak-analysis',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-map',
`0:${options.audioStreamIndex}`,
'-af',
[...formatFilters, 'volumedetect'].join(','),
'-f',
'null',
'-',
],
outputs: [],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: {
filters: [
...formatFilters.map((filter) => filter.split('=')[0] as string),
'volumedetect',
],
muxers: ['null'],
},
diagnostics: [
diagnostic(
'peak-first-pass',
'info',
'A complete local first pass measures the selected stream peak before a constant gain is calculated.'
),
],
});
}
export function parsePeakMeasurement(logText: string): PeakMeasurement {
const matches = [
...logText.matchAll(
/max_volume:\s*(-?(?:\d+(?:\.\d+)?|\.\d+|inf))\s*dB/giu
),
];
const raw = matches.at(-1)?.[1];
if (!raw || raw.toLowerCase().includes('inf')) {
throw new TypeError(
'No finite max_volume measurement was found; silent audio cannot be peak-normalized'
);
}
const measurement = Object.freeze({ inputPeakDbfs: Number(raw) });
validatePeakMeasurement(measurement);
return measurement;
}
export function validatePeakMeasurement(measurement: PeakMeasurement): void {
if (
!Number.isFinite(measurement.inputPeakDbfs) ||
measurement.inputPeakDbfs < -200 ||
measurement.inputPeakDbfs > 100
) {
throw new TypeError(
'Peak measurement is incomplete or outside safe bounds'
);
}
}
export function buildPeakNormalizationFilter(
targetPeakDb: number,
measurement: PeakMeasurement
): string {
validatePeakMeasurement(measurement);
const inputPeakDbfs = measurement.inputPeakDbfs;
if (
!Number.isFinite(targetPeakDb) ||
targetPeakDb > 0 ||
targetPeakDb < -20
) {
throw new RangeError('Peak target must be between -20 and 0 dB');
}
if (!Number.isFinite(inputPeakDbfs)) {
throw new TypeError('Measured input peak must be finite');
}
const gainDb = targetPeakDb - inputPeakDbfs;
if (Math.abs(gainDb) > 60) {
throw new RangeError(
'Peak normalization would require more than 60 dB of gain change'
);
}
const linear = 10 ** (gainDb / 20);
return `volume=${linear.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`;
}
export function buildPeakNormalizationPlan(
options: PeakNormalizationOptions
): FFmpegCommandPlan {
validateAudioOnlyPreset(options.preset, 'Peak normalization');
if (
!Number.isSafeInteger(options.audioStreamIndex) ||
options.audioStreamIndex < 0
) {
throw new RangeError('Invalid audio stream index');
}
validatePeakMeasurement(options.measurement);
const filter = buildPeakNormalizationFilter(
options.targetPeakDb,
options.measurement
);
const base = buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
streamSelection: { audio: [options.audioStreamIndex] },
operation: 'peak-normalize',
audioFiltergraph: [
...audioPresetFormatFilters(options.preset),
filter,
].join(','),
additionalRequirements: {
filters: [
...audioPresetFormatFilters(options.preset).map(
(entry) => entry.split('=')[0] as string
),
'volume',
],
},
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
});
return freezeCommandPlan({
...base,
diagnostics: [
diagnostic(
'measured-peak-gain',
'info',
`Measured ${normalizeSigned(options.measurement.inputPeakDbfs)} dBFS; applying ${normalizeSigned(options.targetPeakDb - options.measurement.inputPeakDbfs)} dB constant gain to target ${normalizeSigned(options.targetPeakDb)} dBFS.`
),
...base.diagnostics,
],
});
}
function validateAudioOnlyPreset(
preset: ExportPreset,
operation: string
): void {
if (!preset.audio || preset.video) {
throw new TypeError(`${operation} requires an audio-only export preset`);
}
}
function audioPresetFormatFilters(preset: ExportPreset): readonly string[] {
if (!preset.audio) {
return [];
}
const filters: string[] = [];
if (preset.audio.sampleRate !== undefined) {
filters.push(`aresample=${preset.audio.sampleRate}`);
}
if (preset.audio.channels !== undefined) {
const layout =
preset.audio.channels === 1
? 'mono'
: preset.audio.channels === 2
? 'stereo'
: '5.1';
filters.push(`aformat=channel_layouts=${layout}`);
}
return filters;
}
export function parseLoudnessMeasurement(logText: string): LoudnessMeasurement {
const blocks = logText.match(/\{[\s\S]*?\}/gu) ?? [];
for (const block of blocks.reverse()) {
let candidate: unknown;
try {
candidate = JSON.parse(block) as unknown;
} catch {
continue;
}
if (!isRecord(candidate) || !('input_i' in candidate)) {
continue;
}
const measurement: LoudnessMeasurement = {
inputIntegratedLufs: parseMeasurement(candidate.input_i, 'input_i'),
inputTruePeakDbtp: parseMeasurement(candidate.input_tp, 'input_tp'),
inputLoudnessRangeLu: parseMeasurement(candidate.input_lra, 'input_lra'),
inputThresholdLufs: parseMeasurement(
candidate.input_thresh,
'input_thresh'
),
...('target_offset' in candidate
? {
targetOffsetLu: parseMeasurement(
candidate.target_offset,
'target_offset'
),
}
: {}),
};
validateLoudnessMeasurement(measurement);
return Object.freeze(measurement);
}
throw new TypeError('No complete loudnorm measurement JSON was found');
}
export function validateLoudnessMeasurement(
measurement: LoudnessMeasurement
): void {
const values = [
measurement.inputIntegratedLufs,
measurement.inputTruePeakDbtp,
measurement.inputLoudnessRangeLu,
measurement.inputThresholdLufs,
...(measurement.targetOffsetLu === undefined
? []
: [measurement.targetOffsetLu]),
];
if (values.some((value) => !Number.isFinite(value))) {
throw new TypeError('Loudness measurement is incomplete or non-finite');
}
}
function loudnormFilter(
targets: LoudnessTargets,
measurement?: LoudnessMeasurement,
printJson = false
): string {
const values = [
`I=${normalizeSigned(targets.integratedLufs)}`,
`TP=${normalizeSigned(targets.truePeakDbtp)}`,
`LRA=${normalizeSeconds(targets.loudnessRangeLu)}`,
];
if (measurement) {
values.push(
`measured_I=${normalizeSigned(measurement.inputIntegratedLufs)}`,
`measured_TP=${normalizeSigned(measurement.inputTruePeakDbtp)}`,
`measured_LRA=${normalizeSeconds(measurement.inputLoudnessRangeLu)}`,
`measured_thresh=${normalizeSigned(measurement.inputThresholdLufs)}`,
`offset=${normalizeSigned(measurement.targetOffsetLu ?? 0)}`,
'linear=true'
);
}
if (printJson) {
values.push('print_format=json');
}
return `loudnorm=${values.join(':')}`;
}
function validateLoudnessTargets(targets: LoudnessTargets): void {
if (
!Number.isFinite(targets.integratedLufs) ||
targets.integratedLufs < -70 ||
targets.integratedLufs > -5
) {
throw new RangeError(
'Integrated loudness target must be between -70 and -5 LUFS'
);
}
if (
!Number.isFinite(targets.truePeakDbtp) ||
targets.truePeakDbtp < -9 ||
targets.truePeakDbtp > 0
) {
throw new RangeError('True peak target must be between -9 and 0 dBTP');
}
if (
!Number.isFinite(targets.loudnessRangeLu) ||
targets.loudnessRangeLu < 1 ||
targets.loudnessRangeLu > 50
) {
throw new RangeError('Loudness range target must be between 1 and 50 LU');
}
}
function normalizeSigned(value: number): string {
if (!Number.isFinite(value)) {
throw new RangeError('Value must be finite');
}
return value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '');
}
function parseMeasurement(value: unknown, label: string): number {
if (typeof value !== 'string' && typeof value !== 'number') {
throw new TypeError(`${label} is missing`);
}
const parsed = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(parsed)) {
throw new TypeError(`${label} is not finite`);
}
return parsed;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
+321
View File
@@ -0,0 +1,321 @@
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
escapeMetadataValue,
streamMapArguments,
type SourceReference,
type StreamSelection,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export type MetadataValue = string | number;
export const EDITABLE_STREAM_DISPOSITIONS = [
'default',
'dub',
'original',
'comment',
'lyrics',
'karaoke',
'forced',
'hearing_impaired',
'visual_impaired',
'clean_effects',
'attached_pic',
'timed_thumbnails',
'captions',
'descriptions',
'metadata',
'dependent',
'still_image',
] as const;
export type StreamDisposition = (typeof EDITABLE_STREAM_DISPOSITIONS)[number];
export interface MetadataEdits {
readonly format?: Readonly<Record<string, MetadataValue | null>>;
readonly streams?: Readonly<
Record<number, Readonly<Record<string, MetadataValue | null>>>
>;
readonly dispositions?: Readonly<
Record<number, readonly StreamDisposition[]>
>;
}
export interface MetadataEditPlanOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly edits: MetadataEdits;
readonly targetExtension: string;
readonly targetMuxer: string;
readonly streamSelection?: StreamSelection;
readonly sourceMetadataPolicy?: 'copy' | 'remove';
readonly chapterPolicy?: 'keep' | 'remove';
readonly expectedDurationSeconds?: number;
readonly outputFileName?: string;
}
export function metadataArguments(edits: MetadataEdits): readonly string[] {
const args: string[] = [];
for (const [key, value] of sortedEntries(edits.format ?? {})) {
validateMetadataKey(key);
args.push(
'-metadata',
`${key}=${value === null ? '' : escapeMetadataValue(String(value))}`
);
}
const streamEntries = Object.entries(edits.streams ?? {}).sort(
([left], [right]) => Number(left) - Number(right)
);
for (const [indexText, tags] of streamEntries) {
const index = parseStreamIndex(indexText);
for (const [key, value] of sortedEntries(tags)) {
validateMetadataKey(key);
args.push(
`-metadata:s:${index}`,
`${key}=${value === null ? '' : escapeMetadataValue(String(value))}`
);
}
}
const dispositionEntries = Object.entries(edits.dispositions ?? {}).sort(
([left], [right]) => Number(left) - Number(right)
);
for (const [indexText, dispositions] of dispositionEntries) {
const index = parseStreamIndex(indexText);
const unique = [...new Set(dispositions)];
if (unique.length !== dispositions.length) {
throw new TypeError(
`Stream ${index} dispositions must not contain duplicates`
);
}
for (const disposition of unique) {
if (
!EDITABLE_STREAM_DISPOSITIONS.includes(disposition as StreamDisposition)
) {
throw new TypeError(
`Unsupported stream disposition: ${String(disposition)}`
);
}
}
const ordered = EDITABLE_STREAM_DISPOSITIONS.filter((disposition) =>
unique.includes(disposition)
);
args.push(
`-disposition:${index}`,
ordered.length > 0 ? ordered.join('+') : '0'
);
}
return Object.freeze(args);
}
export function buildMetadataEditPlan(
options: MetadataEditPlanOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
const edits = metadataArguments(options.edits);
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'metadata',
extension: options.targetExtension,
...(options.outputFileName ? { fileName: options.outputFileName } : {}),
});
const args: string[] = ['-i', input.path];
if (options.streamSelection) {
const maps = streamMapArguments(options.streamSelection);
if (maps.length === 0) {
throw new RangeError('At least one output stream must remain');
}
args.push(...maps);
} else {
args.push('-map', '0');
}
args.push(
'-c',
'copy',
'-map_metadata',
options.sourceMetadataPolicy === 'remove' ? '-1' : '0',
'-map_chapters',
options.chapterPolicy === 'remove' ? '-1' : '0',
...edits,
output.path
);
return freezeCommandPlan({
id: `${options.jobId}:metadata`,
operation: 'metadata',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { muxers: [options.targetMuxer] },
diagnostics: [
diagnostic(
'metadata-round-trip',
'info',
'Re-probe the result to report which container and stream tags survived muxing.'
),
...(options.sourceMetadataPolicy === 'remove'
? [
diagnostic(
'metadata-removal-scope',
'warning' as const,
'Common removable tags are not copied, but container-specific structural metadata may remain.'
),
]
: []),
],
});
}
export function serializeMetadataJson(edits: MetadataEdits): string {
metadataArguments(edits);
return `${JSON.stringify(edits, null, 2)}\n`;
}
export function parseMetadataJson(json: string): MetadataEdits {
let value: unknown;
try {
value = JSON.parse(json) as unknown;
} catch {
throw new TypeError('Metadata JSON is malformed');
}
if (!isRecord(value)) {
throw new TypeError('Metadata JSON must be an object');
}
const unknown = Object.keys(value).filter(
(key) => key !== 'format' && key !== 'streams' && key !== 'dispositions'
);
if (unknown.length > 0) {
throw new TypeError(`Unsupported metadata field: ${unknown[0]}`);
}
const format = parseTagRecord(value.format, 'format');
const streams: Record<
number,
Readonly<Record<string, MetadataValue | null>>
> = {};
if (value.streams !== undefined) {
if (!isRecord(value.streams)) {
throw new TypeError('streams must be an object');
}
for (const [indexText, tags] of Object.entries(value.streams)) {
if (!/^(?:0|[1-9][0-9]*)$/u.test(indexText)) {
throw new TypeError(`Invalid stream index: ${indexText}`);
}
const parsedTags = parseTagRecord(tags, `streams.${indexText}`);
if (!parsedTags) {
throw new TypeError(`streams.${indexText} must be an object`);
}
streams[Number(indexText)] = parsedTags;
}
}
const dispositions: Record<number, readonly StreamDisposition[]> = {};
if (value.dispositions !== undefined) {
if (!isRecord(value.dispositions)) {
throw new TypeError('dispositions must be an object');
}
for (const [indexText, entry] of Object.entries(value.dispositions)) {
const index = parseStreamIndex(indexText);
if (
!Array.isArray(entry) ||
!entry.every((disposition) => typeof disposition === 'string')
) {
throw new TypeError(
`dispositions.${indexText} must be an array of disposition names`
);
}
const parsed = entry as string[];
if (
parsed.some(
(disposition) =>
!EDITABLE_STREAM_DISPOSITIONS.includes(
disposition as StreamDisposition
)
)
) {
throw new TypeError(
`dispositions.${indexText} contains an unsupported disposition`
);
}
if (new Set(parsed).size !== parsed.length) {
throw new TypeError(
`dispositions.${indexText} must not contain duplicates`
);
}
dispositions[index] = Object.freeze(
EDITABLE_STREAM_DISPOSITIONS.filter((disposition) =>
parsed.includes(disposition)
)
);
}
}
return Object.freeze({
...(format ? { format: Object.freeze(format) } : {}),
...(Object.keys(streams).length > 0
? { streams: Object.freeze(streams) }
: {}),
...(Object.keys(dispositions).length > 0
? { dispositions: Object.freeze(dispositions) }
: {}),
});
}
function parseStreamIndex(indexText: string): number {
if (!/^(?:0|[1-9][0-9]*)$/u.test(indexText)) {
throw new TypeError(`Invalid stream index: ${indexText}`);
}
const index = Number(indexText);
if (!Number.isSafeInteger(index)) {
throw new TypeError(`Invalid stream index: ${indexText}`);
}
return index;
}
function parseTagRecord(
value: unknown,
label: string
): Readonly<Record<string, MetadataValue | null>> | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
throw new TypeError(`${label} must be an object`);
}
const result: Record<string, MetadataValue | null> = {};
for (const [key, entry] of Object.entries(value)) {
validateMetadataKey(key);
if (
entry !== null &&
typeof entry !== 'string' &&
(typeof entry !== 'number' || !Number.isFinite(entry))
) {
throw new TypeError(
`${label}.${key} must be a string, finite number or null`
);
}
result[key] = entry as MetadataValue | null;
}
return result;
}
function validateMetadataKey(key: string): void {
if (!/^[a-zA-Z0-9_.-]{1,64}$/u.test(key)) {
throw new TypeError(`Invalid metadata key: ${key}`);
}
}
function sortedEntries<T>(
value: Readonly<Record<string, T>>
): readonly (readonly [string, T])[] {
return Object.entries(value).sort(([left], [right]) =>
left.localeCompare(right)
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
+145
View File
@@ -0,0 +1,145 @@
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
normalizeSeconds,
type SourceReference,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export interface PreviewProxyOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly sourceDurationSeconds: number;
readonly startSeconds?: number;
readonly maxDurationSeconds?: number;
readonly maxWidth?: number;
readonly includeAudio?: boolean;
/**
* Defaults to true for backwards compatibility. Set false for an
* audio-only source; the proxy then becomes a bounded AAC/M4A derivative.
*/
readonly hasVideo?: boolean;
}
/**
* Builds a deliberately bounded H.264/AAC MP4 or audio-only AAC/M4A preview
* derivative. The proxy is disposable cache data and never replaces or
* mutates the source.
*/
export function buildPreviewProxyPlan(
options: PreviewProxyOptions
): FFmpegCommandPlan {
if (
!Number.isFinite(options.sourceDurationSeconds) ||
options.sourceDurationSeconds <= 0
) {
throw new RangeError('Preview source duration must be positive');
}
const startSeconds = options.startSeconds ?? 0;
const maxDurationSeconds = options.maxDurationSeconds ?? 30;
const maxWidth = options.maxWidth ?? 960;
const hasVideo = options.hasVideo !== false;
const includeAudio = options.includeAudio !== false;
if (
!Number.isFinite(startSeconds) ||
startSeconds < 0 ||
startSeconds >= options.sourceDurationSeconds
) {
throw new RangeError('Preview start must be inside the source duration');
}
if (
!Number.isFinite(maxDurationSeconds) ||
maxDurationSeconds <= 0 ||
maxDurationSeconds > 300
) {
throw new RangeError('Preview duration must be from 0 to 300 seconds');
}
if (
hasVideo &&
(!Number.isSafeInteger(maxWidth) || maxWidth < 160 || maxWidth > 1920)
) {
throw new RangeError(
'Preview maximum width must be an integer from 160 to 1920'
);
}
if (!hasVideo && !includeAudio) {
throw new RangeError(
'An audio-only preview proxy must include an audio stream'
);
}
const durationSeconds = Math.min(
maxDurationSeconds,
options.sourceDurationSeconds - startSeconds
);
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'preview',
extension: hasVideo ? 'mp4' : 'm4a',
});
const args = [
'-ss',
normalizeSeconds(startSeconds),
'-t',
normalizeSeconds(durationSeconds),
'-i',
input.path,
...(hasVideo
? [
'-map',
'0:v:0',
...(includeAudio ? ['-map', '0:a:0?'] : []),
'-vf',
`scale=w='max(2,trunc(min(${maxWidth},iw)/2)*2)':h=-2`,
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
'28',
'-pix_fmt',
'yuv420p',
]
: ['-map', '0:a:0', '-vn']),
...(includeAudio
? ['-c:a', 'aac', '-b:a', '96k', '-ac', '2', '-ar', '48000']
: ['-an']),
'-movflags',
'+faststart',
'-map_metadata',
'-1',
'-map_chapters',
'-1',
'-progress',
'pipe:1',
'-nostats',
output.path,
];
return freezeCommandPlan({
id: `${options.jobId}:preview-proxy`,
operation: 'preview-proxy',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
expectedDurationSeconds: durationSeconds,
requiredCapabilities: {
muxers: [hasVideo ? 'mp4' : 'ipod'],
encoders: [
...(hasVideo ? ['libx264'] : []),
...(includeAudio ? ['aac'] : []),
],
filters: hasVideo ? ['scale'] : [],
},
diagnostics: [
diagnostic(
'disposable-preview',
'info',
hasVideo
? `Preview is limited to ${normalizeSeconds(durationSeconds)} seconds and ${maxWidth}px width.`
: `Audio preview is limited to ${normalizeSeconds(durationSeconds)} seconds at 96 kbit/s AAC.`
),
],
});
}
+15
View File
@@ -0,0 +1,15 @@
export function ffprobeArguments(inputPath: string): readonly string[] {
if (!inputPath.startsWith('/') || inputPath.includes('\0')) {
throw new TypeError('Probe input must be a safe absolute virtual path');
}
return Object.freeze([
'-v',
'error',
'-show_format',
'-show_streams',
'-show_chapters',
'-print_format',
'json',
inputPath,
]);
}
+198
View File
@@ -0,0 +1,198 @@
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
streamMapArguments,
type SourceReference,
type StreamSelection,
} from './command-utils';
import {
CommandPlanError,
freezeCommandPlan,
type CommandDiagnostic,
type FFmpegCommandPlan,
} from './command-plan';
export type StreamKind = 'video' | 'audio' | 'subtitle' | 'attachment' | 'data';
export interface RemuxStream {
readonly index: number;
readonly kind: StreamKind;
readonly codec: string;
}
export interface RemuxInputDescription {
readonly streams: readonly RemuxStream[];
readonly hasChapters?: boolean;
readonly hasMetadata?: boolean;
}
export interface RemuxOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly targetContainer: 'mp4' | 'webm' | 'matroska' | 'ogg' | 'ipod';
readonly fileExtension: string;
readonly description: RemuxInputDescription;
readonly streamSelection?: StreamSelection;
readonly metadataPolicy?: 'copy' | 'remove';
readonly chapterPolicy?: 'keep' | 'remove';
readonly expectedDurationSeconds?: number;
}
const CONTAINER_CODECS: Readonly<
Record<string, Readonly<Partial<Record<StreamKind, ReadonlySet<string>>>>>
> = {
mp4: {
video: new Set(['h264', 'hevc', 'av1', 'mpeg4', 'mjpeg']),
audio: new Set(['aac', 'mp3', 'alac', 'ac3', 'eac3']),
subtitle: new Set(['mov_text']),
attachment: new Set(),
data: new Set(['bin_data']),
},
webm: {
video: new Set(['vp8', 'vp9', 'av1']),
audio: new Set(['opus', 'vorbis']),
subtitle: new Set(['webvtt']),
attachment: new Set(),
data: new Set(),
},
matroska: {},
ogg: {
video: new Set(['theora']),
audio: new Set(['vorbis', 'opus', 'flac']),
subtitle: new Set(),
attachment: new Set(),
data: new Set(),
},
ipod: {
video: new Set(),
audio: new Set(['aac', 'alac', 'mp3']),
subtitle: new Set(),
attachment: new Set(),
data: new Set(),
},
};
export function validateRemuxCompatibility(
description: RemuxInputDescription,
container: RemuxOptions['targetContainer'],
selection?: StreamSelection
): readonly CommandDiagnostic[] {
const diagnostics: CommandDiagnostic[] = [];
const rules = CONTAINER_CODECS[container];
const selectedStreams = description.streams.filter((stream) =>
isSelected(stream, selection)
);
if (selectedStreams.length === 0) {
diagnostics.push(
diagnostic(
'no-streams',
'error',
'At least one media stream must be selected.'
)
);
}
for (const stream of selectedStreams) {
const allowed = rules?.[stream.kind];
if (allowed && !allowed.has(stream.codec)) {
diagnostics.push(
diagnostic(
'incompatible-codec',
'error',
`${stream.kind} codec ${stream.codec} is not stream-copy compatible with ${container}.`,
`stream.${stream.index}`
)
);
}
}
if (
description.hasChapters &&
(container === 'webm' || container === 'ogg')
) {
diagnostics.push(
diagnostic(
'chapter-support',
'warning',
`Chapter preservation should be verified after remuxing to ${container}.`
)
);
}
return Object.freeze(diagnostics);
}
export function buildRemuxPlan(options: RemuxOptions): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetContainer, options.fileExtension);
const diagnostics = validateRemuxCompatibility(
options.description,
options.targetContainer,
options.streamSelection
);
if (diagnostics.some((entry) => entry.severity === 'error')) {
throw new CommandPlanError(
'The selected streams cannot be remuxed',
diagnostics
);
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'remux',
extension: options.fileExtension,
});
const args: string[] = ['-i', input.path];
if (options.streamSelection) {
args.push(...streamMapArguments(options.streamSelection));
} else {
args.push('-map', '0');
}
args.push('-c', 'copy');
if (options.metadataPolicy === 'remove') {
args.push('-map_metadata', '-1');
} else {
args.push('-map_metadata', '0');
}
args.push('-map_chapters', options.chapterPolicy === 'remove' ? '-1' : '0');
if (options.targetContainer === 'mp4' || options.targetContainer === 'ipod') {
args.push('-movflags', '+faststart');
}
args.push('-progress', 'pipe:1', '-nostats', output.path);
return freezeCommandPlan({
id: `${options.jobId}:remux`,
operation: 'remux',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { muxers: [options.targetContainer] },
diagnostics: [
diagnostic(
'stream-copy',
'info',
'Fast remux copies compatible streams without re-encoding; it does not change their quality.'
),
...diagnostics,
],
});
}
function isSelected(stream: RemuxStream, selection?: StreamSelection): boolean {
if (!selection) {
return true;
}
const indexes =
stream.kind === 'video'
? selection.video
: stream.kind === 'audio'
? selection.audio
: stream.kind === 'subtitle'
? selection.subtitles
: stream.kind === 'attachment'
? selection.attachments
: selection.data;
return indexes?.includes(stream.index) ?? false;
}
+273
View File
@@ -0,0 +1,273 @@
import type { ExportPreset } from './convert';
import { presetEncodingArguments } from '../presets/preset-registry';
import {
metadataPolicyArguments,
softSubtitlePolicyConfiguration,
} from './convert';
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
mergeRequirements,
muxerForExtension,
normalizeSeconds,
validateRange,
type SourceReference,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
import {
requirementsForUserPreset,
type ExportPreset as Preset,
} from './convert';
import {
GENERATED_OUTPUT_LIMIT_EXPLANATION,
MAX_GENERATED_OUTPUT_FILES,
MAX_SPLIT_MARKERS,
} from '../limits';
export interface TimeRange {
readonly startSeconds: number;
readonly endSeconds: number;
readonly label?: string;
}
export type SplitDefinition =
| { readonly type: 'markers'; readonly markers: readonly number[] }
| { readonly type: 'equal-duration'; readonly segmentDurationSeconds: number }
| { readonly type: 'part-count'; readonly parts: number }
| { readonly type: 'chapters'; readonly chapters: readonly TimeRange[] }
| { readonly type: 'clips'; readonly clips: readonly TimeRange[] };
export interface SplitOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly durationSeconds: number;
readonly definition: SplitDefinition;
readonly mode: 'fast' | 'accurate';
readonly targetExtension: string;
readonly preset?: ExportPreset;
}
export function rangesForSplit(
definition: SplitDefinition,
durationSeconds: number
): readonly TimeRange[] {
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
throw new RangeError('durationSeconds must be positive');
}
let ranges: TimeRange[];
if (definition.type === 'markers') {
const markers = validateSplitMarkers(definition.markers, durationSeconds);
const boundaries = [0, ...markers, durationSeconds];
ranges = boundaries.slice(0, -1).map((start, index) => ({
startSeconds: start,
endSeconds: boundaries[index + 1] as number,
}));
} else if (definition.type === 'equal-duration') {
if (
!Number.isFinite(definition.segmentDurationSeconds) ||
definition.segmentDurationSeconds <= 0
) {
throw new RangeError('segmentDurationSeconds must be positive');
}
if (
Math.ceil(durationSeconds / definition.segmentDurationSeconds) >
MAX_GENERATED_OUTPUT_FILES
) {
throw new RangeError(
`A split operation can create at most ${MAX_GENERATED_OUTPUT_FILES} outputs. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
ranges = [];
for (
let start = 0;
start < durationSeconds;
start += definition.segmentDurationSeconds
) {
ranges.push({
startSeconds: start,
endSeconds: Math.min(
durationSeconds,
start + definition.segmentDurationSeconds
),
});
}
} else if (definition.type === 'part-count') {
if (
!Number.isSafeInteger(definition.parts) ||
definition.parts < 1 ||
definition.parts > MAX_GENERATED_OUTPUT_FILES
) {
throw new RangeError(
`parts must be an integer between 1 and ${MAX_GENERATED_OUTPUT_FILES}. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
const segmentDuration = durationSeconds / definition.parts;
ranges = Array.from({ length: definition.parts }, (_, index) => ({
startSeconds: segmentDuration * index,
endSeconds:
index === definition.parts - 1
? durationSeconds
: segmentDuration * (index + 1),
}));
} else {
ranges = [
...(definition.type === 'chapters'
? definition.chapters
: definition.clips),
];
}
if (ranges.length === 0 || ranges.length > MAX_GENERATED_OUTPUT_FILES) {
throw new RangeError(
`A split operation must create 1${MAX_GENERATED_OUTPUT_FILES} outputs. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
ranges.forEach((range) =>
validateRange(range.startSeconds, range.endSeconds, durationSeconds)
);
return Object.freeze(ranges.map((range) => Object.freeze({ ...range })));
}
export function validateSplitMarkers(
markers: readonly number[],
durationSeconds: number
): readonly number[] {
if (markers.length > MAX_SPLIT_MARKERS) {
throw new RangeError(
`A split operation accepts at most ${MAX_SPLIT_MARKERS} markers (${MAX_GENERATED_OUTPUT_FILES} outputs). ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
const sorted = [...markers].sort((left, right) => left - right);
for (let index = 0; index < sorted.length; index += 1) {
const marker = sorted[index] as number;
if (!Number.isFinite(marker) || marker <= 0 || marker >= durationSeconds) {
throw new RangeError('Split markers must be inside the source duration');
}
if (index > 0 && Math.abs(marker - (sorted[index - 1] as number)) < 0.04) {
throw new RangeError(
'Split markers must be unique and at least 0.04 seconds apart'
);
}
}
return Object.freeze(sorted);
}
export function buildSplitPlan(options: SplitOptions): FFmpegCommandPlan {
const ranges = rangesForSplit(options.definition, options.durationSeconds);
if (options.mode === 'accurate' && !options.preset) {
throw new TypeError('Accurate split requires an export preset');
}
if (
options.mode === 'accurate' &&
options.preset &&
options.targetExtension.replace(/^\.+/u, '').toLowerCase() !==
options.preset.fileExtension
) {
throw new TypeError(
`Accurate split extension must match the preset (.${options.preset.fileExtension})`
);
}
const input = createPlannedInput(options.jobId, options.source);
const outputs = ranges.map((range, index) =>
createPlannedOutput(options.jobId, {
id: `segment-${index + 1}`,
baseName: options.source.fileName,
operation: 'split',
suffix: range.label
? `${String(index + 1).padStart(3, '0')}-${range.label}`
: String(index + 1).padStart(3, '0'),
extension: options.targetExtension,
timeRange: {
startSeconds: range.startSeconds,
endSeconds: range.endSeconds,
},
})
);
const args: string[] = [];
if (options.mode === 'fast') {
// Each output needs its own demuxer cursor. Reusing one input while
// stream-copying several timestamp windows can leave later outputs without
// video when keyframes are sparse.
ranges.forEach((range) => {
args.push('-ss', normalizeSeconds(range.startSeconds), '-i', input.path);
});
} else {
args.push('-i', input.path);
}
args.push('-progress', 'pipe:1', '-nostats');
ranges.forEach((range, index) => {
if (options.mode === 'accurate') {
args.push('-ss', normalizeSeconds(range.startSeconds));
}
args.push(
'-t',
normalizeSeconds(range.endSeconds - range.startSeconds),
'-map',
options.mode === 'fast' ? String(index) : '0'
);
if (options.mode === 'fast') {
args.push(
'-c',
'copy',
'-map_metadata',
String(index),
'-map_chapters',
'-1',
'-avoid_negative_ts',
'make_zero'
);
} else {
args.push(...presetEncodingArguments(options.preset as Preset));
args.push(
...softSubtitlePolicyConfiguration(options.preset as Preset).args
);
args.push(
...metadataPolicyArguments(options.preset as Preset),
'-map_chapters',
'-1'
);
}
const output = outputs[index];
if (!output) {
throw new TypeError(`Missing planned split output ${index + 1}`);
}
args.push(output.path);
});
const requirements =
options.mode === 'accurate' && options.preset
? mergeRequirements(
'builtIn' in options.preset
? options.preset.requirements
: requirementsForUserPreset(options.preset),
softSubtitlePolicyConfiguration(options.preset).requirements
)
: {
...(muxerForExtension(options.targetExtension)
? { muxers: [muxerForExtension(options.targetExtension)!] }
: {}),
};
return freezeCommandPlan({
id: `${options.jobId}:split`,
operation: options.mode === 'fast' ? 'split-fast' : 'split-accurate',
inputs: [input],
temporaryFiles: [],
args,
outputs,
expectedDurationSeconds: options.durationSeconds,
requiredCapabilities: requirements,
diagnostics: [
diagnostic(
options.mode === 'fast' ? 'split-keyframes' : 'split-reencode',
options.mode === 'fast' ? 'warning' : 'info',
options.mode === 'fast'
? 'Fast split boundaries can move to nearby keyframes.'
: 'Accurate split re-encodes each segment at the requested boundaries.'
),
diagnostic(
'split-chapters-omitted',
'info',
'Source chapters are omitted because their timestamps do not automatically match individual split outputs.'
),
],
});
}
+184
View File
@@ -0,0 +1,184 @@
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
streamMapArguments,
type SourceReference,
type StreamSelection,
} from './command-utils';
import {
CommandPlanError,
freezeCommandPlan,
type FFmpegCommandPlan,
} from './command-plan';
export interface AudioExtractionOptions {
readonly jobId: string;
readonly source: SourceReference;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly expectedDurationSeconds?: number;
readonly mode: 'copy' | 'encode';
readonly inputCodec?: string;
readonly targetMuxer?: string;
readonly targetExtension?: string;
readonly preset?: ExportPreset;
}
export function buildAudioExtractionPlan(
options: AudioExtractionOptions
): FFmpegCommandPlan {
const audioStreamIndex = options.audioStreamIndex;
if (!Number.isSafeInteger(audioStreamIndex) || audioStreamIndex < 0) {
throw new RangeError('Invalid audio stream index');
}
if (options.mode === 'encode') {
if (!options.preset || options.preset.video) {
throw new TypeError(
'Encoded audio extraction requires an audio-only preset'
);
}
return buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
streamSelection: { audio: [audioStreamIndex] },
operation: 'audio',
expectedDurationSeconds: options.expectedDurationSeconds,
});
}
if (!options.inputCodec || !options.targetMuxer || !options.targetExtension) {
throw new TypeError(
'Stream-copy audio extraction requires codec, target muxer, and extension'
);
}
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
if (!isAudioCopyCompatible(options.inputCodec, options.targetMuxer)) {
throw new CommandPlanError(
'Audio codec is not compatible with the target container',
[
diagnostic(
'audio-copy-incompatible',
'error',
`${options.inputCodec} cannot be safely copied into ${options.targetMuxer}.`
),
]
);
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'audio',
extension: options.targetExtension,
role: 'audio',
});
return freezeCommandPlan({
id: `${options.jobId}:audio-extract`,
operation: 'audio-extract-copy',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-map',
`0:${audioStreamIndex}`,
'-vn',
'-sn',
'-dn',
'-c:a',
'copy',
output.path,
],
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { muxers: [options.targetMuxer] },
diagnostics: [
diagnostic(
'audio-stream-copy',
'info',
'The selected compatible audio stream is retained without re-encoding.'
),
],
});
}
export interface StreamRemovalOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly selection: StreamSelection;
readonly targetExtension: string;
readonly targetMuxer: string;
readonly expectedDurationSeconds?: number;
}
export function buildStreamRemovalPlan(
options: StreamRemovalOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
const mapArgs = streamMapArguments(options.selection);
if (mapArgs.length === 0) {
throw new RangeError('At least one output stream must remain');
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'streams',
extension: options.targetExtension,
});
return freezeCommandPlan({
id: `${options.jobId}:stream-removal`,
operation: 'stream-removal',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
...mapArgs,
'-c',
'copy',
'-map_metadata',
'0',
'-map_chapters',
'0',
output.path,
],
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { muxers: [options.targetMuxer] },
diagnostics: [
diagnostic(
'stream-removal-copy',
'info',
'Only explicitly selected streams are copied to the output.'
),
],
});
}
function isAudioCopyCompatible(codec: string, muxer: string): boolean {
const rules: Readonly<Record<string, ReadonlySet<string>>> = {
mp3: new Set(['mp3']),
ipod: new Set(['aac', 'alac', 'mp3']),
ogg: new Set(['vorbis', 'opus', 'flac']),
opus: new Set(['opus']),
wav: new Set(['pcm_s16le', 'pcm_s24le', 'pcm_f32le']),
flac: new Set(['flac']),
matroska: new Set([
'aac',
'alac',
'mp3',
'vorbis',
'opus',
'flac',
'pcm_s16le',
]),
};
return rules[muxer]?.has(codec) ?? false;
}
+428
View File
@@ -0,0 +1,428 @@
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
escapeFilterValue,
normalizeSeconds,
type SourceReference,
} from './command-utils';
import {
CommandPlanError,
freezeCommandPlan,
type CommandDiagnostic,
type FFmpegCommandPlan,
} from './command-plan';
import { validateRemuxCompatibility, type RemuxStream } from './remux';
export type SubtitleFormat = 'srt' | 'vtt' | 'ass';
export type SubtitleClassification = 'text' | 'bitmap' | 'unknown';
export interface SubtitleStreamDescription {
readonly index: number;
readonly codec: string;
readonly language?: string;
readonly title?: string;
readonly default?: boolean;
readonly forced?: boolean;
}
const TEXT_CODECS = new Set([
'subrip',
'srt',
'webvtt',
'ass',
'ssa',
'mov_text',
'text',
]);
const BITMAP_CODECS = new Set([
'dvd_subtitle',
'hdmv_pgs_subtitle',
'dvb_subtitle',
'xsub',
]);
export function classifySubtitleCodec(codec: string): SubtitleClassification {
if (TEXT_CODECS.has(codec)) {
return 'text';
}
if (BITMAP_CODECS.has(codec)) {
return 'bitmap';
}
return 'unknown';
}
export function validateSoftSubtitleCompatibility(
container: 'mp4' | 'webm' | 'matroska',
codec: string
): readonly CommandDiagnostic[] {
const compatible =
container === 'mp4'
? codec === 'mov_text'
: container === 'webm'
? codec === 'webvtt'
: classifySubtitleCodec(codec) !== 'unknown';
return compatible
? Object.freeze([])
: Object.freeze([
diagnostic(
'subtitle-container-incompatible',
'error',
`${codec} subtitles are not soft-mux compatible with ${container}; choose an explicit text conversion or burn-in.`
),
]);
}
export interface SubtitleExtractionOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly stream: SubtitleStreamDescription;
readonly outputFormat: SubtitleFormat;
}
export function buildSubtitleExtractionPlan(
options: SubtitleExtractionOptions
): FFmpegCommandPlan {
if (!Number.isSafeInteger(options.stream.index) || options.stream.index < 0) {
throw new RangeError('Invalid subtitle stream index');
}
const classification = classifySubtitleCodec(options.stream.codec);
if (classification !== 'text') {
throw new CommandPlanError(
classification === 'bitmap'
? 'Bitmap subtitles cannot be converted to text automatically'
: 'Unknown subtitle codecs cannot be converted to text automatically',
[
diagnostic(
classification === 'bitmap'
? 'bitmap-not-text'
: 'unknown-subtitle-codec',
'error',
classification === 'bitmap'
? 'Extraction can preserve bitmap data in a compatible format, but it is not OCR or text conversion.'
: 'Only a recognized text subtitle codec can be converted to SRT, WebVTT, or ASS.'
),
]
);
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'subtitle',
suffix: options.stream.language ?? `stream-${options.stream.index}`,
extension: options.outputFormat,
role: 'subtitle',
});
const encoder = subtitleEncoder(options.outputFormat);
return freezeCommandPlan({
id: `${options.jobId}:subtitle-extract`,
operation: 'subtitle-extract',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-map',
`0:${options.stream.index}`,
'-c:s',
encoder,
output.path,
],
outputs: [output],
requiredCapabilities: {
encoders: [encoder],
muxers: [subtitleMuxer(options.outputFormat)],
},
diagnostics: [
diagnostic(
'subtitle-extraction',
'info',
`Text subtitle stream ${options.stream.index} is converted to ${options.outputFormat.toUpperCase()}.`
),
],
});
}
export interface SubtitleConversionOptions {
readonly jobId: string;
readonly subtitle: SourceReference;
readonly outputFormat: SubtitleFormat;
readonly offsetSeconds?: number;
}
export function buildSubtitleConversionPlan(
options: SubtitleConversionOptions
): FFmpegCommandPlan {
const input = createPlannedInput(options.jobId, options.subtitle, 'subtitle');
const output = createPlannedOutput(options.jobId, {
baseName: options.subtitle.fileName,
operation: 'subtitle',
extension: options.outputFormat,
role: 'subtitle',
});
const encoder = subtitleEncoder(options.outputFormat);
const args: string[] = [];
if (options.offsetSeconds !== undefined) {
if (
!Number.isFinite(options.offsetSeconds) ||
Math.abs(options.offsetSeconds) > 86_400
) {
throw new RangeError('Subtitle offset must be within 24 hours');
}
args.push('-itsoffset', signedSeconds(options.offsetSeconds));
}
args.push('-i', input.path, '-map', '0:s:0', '-c:s', encoder, output.path);
return freezeCommandPlan({
id: `${options.jobId}:subtitle-convert`,
operation: 'subtitle-convert',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
requiredCapabilities: {
encoders: [encoder],
muxers: [subtitleMuxer(options.outputFormat)],
},
diagnostics: [],
});
}
export interface SubtitleMuxOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly subtitle: SourceReference;
readonly subtitleCodec: string;
readonly sourceStreams: readonly RemuxStream[];
readonly targetContainer: 'mp4' | 'webm' | 'matroska';
readonly targetExtension: string;
readonly language?: string;
readonly title?: string;
readonly default?: boolean;
readonly forced?: boolean;
readonly expectedDurationSeconds?: number;
}
export function buildSubtitleMuxPlan(
options: SubtitleMuxOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetContainer, options.targetExtension);
const outputCodec =
options.targetContainer === 'mp4'
? 'mov_text'
: options.targetContainer === 'webm'
? 'webvtt'
: options.subtitleCodec;
const diagnostics = [
...validateSoftSubtitleCompatibility(options.targetContainer, outputCodec),
...validateRemuxCompatibility(
{ streams: options.sourceStreams },
options.targetContainer
),
];
validateSourceStreams(options.sourceStreams);
if (options.sourceStreams.length === 0) {
throw new CommandPlanError('No source media streams were declared', [
diagnostic(
'no-source-streams',
'error',
'Soft-subtitle muxing requires inspected source streams.'
),
]);
}
const existingSubtitleCount = options.sourceStreams.filter(
(stream) => stream.kind === 'subtitle'
).length;
if (diagnostics.some((entry) => entry.severity === 'error')) {
throw new CommandPlanError(
'Subtitle is incompatible with target container',
diagnostics
);
}
if (
classifySubtitleCodec(options.subtitleCodec) === 'bitmap' &&
outputCodec !== options.subtitleCodec
) {
throw new CommandPlanError('Bitmap subtitle conversion is not supported', [
diagnostic(
'bitmap-not-text',
'error',
'Bitmap subtitles cannot be converted to a text subtitle codec automatically.'
),
]);
}
const mediaInput = createPlannedInput(options.jobId, options.source);
const subtitleInput = createPlannedInput(
options.jobId,
options.subtitle,
'subtitle'
);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'subtitled',
extension: options.targetExtension,
});
const requiresConversion = outputCodec !== options.subtitleCodec;
if (
requiresConversion &&
classifySubtitleCodec(options.subtitleCodec) !== 'text'
) {
throw new CommandPlanError(
'Only text subtitles can be converted for this target container',
[
diagnostic(
'subtitle-conversion-unsupported',
'error',
'Choose a recognized SRT, WebVTT, ASS/SSA, mov_text, or text subtitle input.'
),
]
);
}
const args = [
'-i',
mediaInput.path,
'-i',
subtitleInput.path,
...[...options.sourceStreams]
.sort((left, right) => left.index - right.index)
.flatMap((stream) => ['-map', `0:${stream.index}`]),
'-map',
'1:s:0',
'-c',
'copy',
`-c:s:${existingSubtitleCount}`,
requiresConversion ? outputCodec : 'copy',
];
if (options.language) {
args.push(
`-metadata:s:s:${existingSubtitleCount}`,
`language=${safeSubtitleTag(options.language)}`
);
}
if (options.title) {
const title = safeSubtitleTag(options.title);
args.push(`-metadata:s:s:${existingSubtitleCount}`, `title=${title}`);
if (options.targetContainer === 'mp4') {
args.push(
`-metadata:s:s:${existingSubtitleCount}`,
`handler_name=${title}`
);
}
}
const dispositions =
[
options.default ? 'default' : undefined,
options.forced ? 'forced' : undefined,
]
.filter((value): value is string => Boolean(value))
.join('+') || '0';
args.push(
`-disposition:s:${existingSubtitleCount}`,
dispositions,
output.path
);
return freezeCommandPlan({
id: `${options.jobId}:subtitle-mux`,
operation: 'subtitle-mux',
inputs: [mediaInput, subtitleInput],
temporaryFiles: [],
args,
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: {
muxers: [options.targetContainer],
...(requiresConversion ? { encoders: [outputCodec] } : {}),
},
diagnostics,
});
}
export interface SubtitleBurnOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly subtitle: SourceReference;
readonly preset: ExportPreset;
readonly expectedDurationSeconds?: number;
}
export function buildSubtitleBurnPlan(
options: SubtitleBurnOptions
): FFmpegCommandPlan {
if (!options.preset.video) {
throw new TypeError('Subtitle burn-in requires a video export preset');
}
const subtitleInput = createPlannedInput(
options.jobId,
options.subtitle,
'subtitle'
);
const base = buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
operation: 'subtitle-burn',
expectedDurationSeconds: options.expectedDurationSeconds,
videoFiltergraph: `subtitles=filename='${escapeFilterValue(subtitleInput.path)}'`,
additionalRequirements: { filters: ['subtitles'] },
});
return freezeCommandPlan({
...base,
inputs: [...base.inputs, subtitleInput],
diagnostics: [
diagnostic(
'subtitle-burn-in',
'info',
'Subtitle pixels are rendered locally into the video using the bundled subtitle filter.'
),
],
});
}
function subtitleEncoder(format: SubtitleFormat): string {
return format === 'srt' ? 'srt' : format === 'vtt' ? 'webvtt' : 'ass';
}
function subtitleMuxer(format: SubtitleFormat): string {
return format === 'vtt' ? 'webvtt' : format;
}
function signedSeconds(value: number): string {
return value < 0
? `-${normalizeSeconds(Math.abs(value))}`
: normalizeSeconds(value);
}
function safeSubtitleTag(value: string): string {
if (
value.includes('\0') ||
value.includes('\n') ||
value.includes('\r') ||
value.length > 128
) {
throw new TypeError('Subtitle tag contains unsupported characters');
}
return value;
}
function validateSourceStreams(streams: readonly RemuxStream[]): void {
const indexes = new Set<number>();
for (const stream of streams) {
if (
!Number.isSafeInteger(stream.index) ||
stream.index < 0 ||
indexes.has(stream.index)
) {
throw new RangeError(
'Source stream indexes must be unique nonnegative integers'
);
}
if (!stream.codec.trim()) {
throw new TypeError(`Source stream ${stream.index} codec is required`);
}
indexes.add(stream.index);
}
}
+393
View File
@@ -0,0 +1,393 @@
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
normalizeSeconds,
safePathComponent,
type SourceReference,
} from './command-utils';
import {
GENERATED_OUTPUT_LIMIT_EXPLANATION,
MAX_GENERATED_OUTPUT_FILES,
} from '../limits';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export type ImageFormat = 'png' | 'jpeg' | 'webp';
export interface ThumbnailSize {
readonly width?: number;
readonly height?: number;
readonly maintainAspect?: boolean;
}
export interface SingleThumbnailOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly timeSeconds: number;
readonly size?: ThumbnailSize;
readonly format: ImageFormat;
readonly quality?: number;
}
export interface ThumbnailSeriesOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly durationSeconds: number;
readonly timesSeconds?: readonly number[];
readonly intervalSeconds?: number;
readonly count?: number;
readonly size?: ThumbnailSize;
readonly format: ImageFormat;
readonly quality?: number;
}
export interface ContactSheetOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly durationSeconds: number;
readonly frameCount: number;
readonly rows: number;
readonly columns: number;
readonly cellWidth: number;
readonly spacing?: number;
readonly background?: string;
readonly maintainAspect?: boolean;
readonly timestampLabels?: boolean;
readonly sourceLabel?: boolean;
readonly format: ImageFormat;
readonly quality?: number;
readonly drawtextAvailable?: boolean;
}
export function buildSingleThumbnailPlan(
options: SingleThumbnailOptions
): FFmpegCommandPlan {
if (!Number.isFinite(options.timeSeconds) || options.timeSeconds < 0) {
throw new RangeError('Thumbnail time must be nonnegative');
}
const input = createPlannedInput(options.jobId, options.source);
const extension = extensionForImageFormat(options.format);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'thumbnail',
suffix: `${Math.round(options.timeSeconds * 1000)}ms`,
extension,
role: 'thumbnail',
});
const args = [
'-ss',
normalizeSeconds(options.timeSeconds),
'-i',
input.path,
'-frames:v',
'1',
...imageFilterArguments(options.size),
...imageQualityArguments(options.format, options.quality),
output.path,
];
return freezeCommandPlan({
id: `${options.jobId}:thumbnail`,
operation: 'thumbnail',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
requiredCapabilities: imageRequirements(options.format),
diagnostics: [],
});
}
export function buildThumbnailSeriesPlan(
options: ThumbnailSeriesOptions
): FFmpegCommandPlan {
const times = resolveThumbnailTimes(options);
const input = createPlannedInput(options.jobId, options.source);
const extension = extensionForImageFormat(options.format);
const outputs = times.map((time, index) =>
createPlannedOutput(options.jobId, {
id: `thumbnail-${index + 1}`,
baseName: options.source.fileName,
operation: 'thumbnail',
suffix: `${String(index + 1).padStart(3, '0')}-${Math.round(time * 1000)}ms`,
extension,
role: 'thumbnail',
})
);
const size = scaleFilter(options.size);
const splitOutputs = times
.map((_, index) => `[thumb-source-${index}]`)
.join('');
const filtergraph = [
`[0:v]split=${times.length}${splitOutputs}`,
...times.map((time, index) => {
const filters = [
`select='gte(t\\,${normalizeSeconds(time)})'`,
'setpts=N/FRAME_RATE/TB',
...(size ? [size] : []),
];
return `[thumb-source-${index}]${filters.join(',')}[thumb-${index}]`;
}),
].join(';');
const outputArguments = outputs.flatMap((output, index) => [
'-map',
`[thumb-${index}]`,
'-frames:v',
'1',
...imageQualityArguments(options.format, options.quality),
output.path,
]);
return freezeCommandPlan({
id: `${options.jobId}:thumbnail-series`,
operation: 'thumbnail-series',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-filter_complex',
filtergraph,
...outputArguments,
],
outputs,
expectedDurationSeconds: options.durationSeconds,
requiredCapabilities: {
...imageRequirements(options.format),
filters: ['split', 'select', 'setpts', ...(size ? ['scale'] : [])],
},
diagnostics: [
diagnostic(
'single-pass-series',
'info',
'The thumbnail series is decoded in one FFmpeg invocation.'
),
],
});
}
export function resolveThumbnailTimes(
options: Pick<
ThumbnailSeriesOptions,
'durationSeconds' | 'timesSeconds' | 'intervalSeconds' | 'count'
>
): readonly number[] {
if (
!Number.isFinite(options.durationSeconds) ||
options.durationSeconds <= 0
) {
throw new RangeError('Source duration must be positive');
}
let times: number[];
if (options.timesSeconds) {
times = [...options.timesSeconds];
} else if (options.intervalSeconds !== undefined) {
if (
!Number.isFinite(options.intervalSeconds) ||
options.intervalSeconds <= 0
) {
throw new RangeError('Thumbnail interval must be positive');
}
if (
Math.ceil(options.durationSeconds / options.intervalSeconds) >
MAX_GENERATED_OUTPUT_FILES
) {
throw new RangeError(
`A thumbnail series can contain at most ${MAX_GENERATED_OUTPUT_FILES} images. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
times = [];
for (
let time = 0;
time < options.durationSeconds;
time += options.intervalSeconds
) {
times.push(time);
}
} else {
const count = options.count ?? 12;
if (
!Number.isSafeInteger(count) ||
count < 1 ||
count > MAX_GENERATED_OUTPUT_FILES
) {
throw new RangeError(
`Thumbnail count must be an integer from 1 to ${MAX_GENERATED_OUTPUT_FILES}. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
times = Array.from(
{ length: count },
(_, index) => (options.durationSeconds * (index + 1)) / (count + 1)
);
}
const unique = [...new Set(times)].sort((left, right) => left - right);
if (
unique.length === 0 ||
unique.length > MAX_GENERATED_OUTPUT_FILES ||
unique.some(
(time) =>
!Number.isFinite(time) || time < 0 || time >= options.durationSeconds
)
) {
throw new RangeError('Thumbnail times must be within the source duration');
}
return Object.freeze(unique);
}
export function buildContactSheetPlan(
options: ContactSheetOptions
): FFmpegCommandPlan {
if (
!Number.isFinite(options.durationSeconds) ||
options.durationSeconds <= 0
) {
throw new RangeError('Contact sheet source duration must be positive');
}
if (
!Number.isSafeInteger(options.frameCount) ||
options.frameCount < 1 ||
options.frameCount > 500 ||
!Number.isSafeInteger(options.rows) ||
!Number.isSafeInteger(options.columns) ||
options.rows < 1 ||
options.columns < 1 ||
options.rows * options.columns < options.frameCount ||
options.rows * options.columns > 500
) {
throw new RangeError(
'Contact sheet grid must contain 1500 cells and fit all frames'
);
}
if (
!Number.isSafeInteger(options.cellWidth) ||
options.cellWidth < 32 ||
options.cellWidth > 4096
) {
throw new RangeError('Contact sheet cell width must be 324096 pixels');
}
const spacing = options.spacing ?? 4;
if (!Number.isSafeInteger(spacing) || spacing < 0 || spacing > 100) {
throw new RangeError('Contact sheet spacing must be 0100 pixels');
}
const background = options.background ?? 'black';
if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) {
throw new TypeError('Background must be a named colour or #RRGGBB');
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'contact-sheet',
extension: extensionForImageFormat(options.format),
role: 'contact-sheet',
});
const fps = options.frameCount / options.durationSeconds;
const filters = [
`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`,
options.maintainAspect === false
? `scale=${options.cellWidth}:${options.cellWidth}`
: `scale=${options.cellWidth}:-2:force_original_aspect_ratio=decrease`,
];
let labelsEnabled = Boolean(options.timestampLabels || options.sourceLabel);
const diagnostics = [];
if (labelsEnabled && !options.drawtextAvailable) {
labelsEnabled = false;
diagnostics.push(
diagnostic(
'labels-unavailable',
'warning',
'Timestamp and filename labels were omitted because drawtext or a safe local font is unavailable.'
)
);
}
if (labelsEnabled) {
const text = options.sourceLabel
? `${safePathComponent(options.source.fileName)} %{pts\\:hms}`
: '%{pts\\:hms}';
filters.push(
`drawtext=text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6`
);
}
filters.push(
`tile=${options.columns}x${options.rows}:padding=${spacing}:margin=${spacing}:color=${background}`
);
return freezeCommandPlan({
id: `${options.jobId}:contact-sheet`,
operation: 'contact-sheet',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-vf',
filters.join(','),
'-frames:v',
'1',
...imageQualityArguments(options.format, options.quality),
output.path,
],
outputs: [output],
expectedDurationSeconds: options.durationSeconds,
requiredCapabilities: {
...imageRequirements(options.format),
filters: ['fps', 'scale', 'tile', ...(labelsEnabled ? ['drawtext'] : [])],
},
diagnostics,
});
}
function imageFilterArguments(size?: ThumbnailSize): readonly string[] {
const filter = scaleFilter(size);
return filter ? ['-vf', filter] : [];
}
function scaleFilter(size?: ThumbnailSize): string | undefined {
if (!size || (size.width === undefined && size.height === undefined)) {
return undefined;
}
for (const value of [size.width, size.height]) {
if (
value !== undefined &&
(!Number.isSafeInteger(value) || value < 1 || value > 8192)
) {
throw new RangeError(
'Thumbnail dimensions must be integer values from 1 to 8192'
);
}
}
const dimensions = `scale=${size.width ?? -2}:${size.height ?? -2}`;
return size.maintainAspect === false
? dimensions
: `${dimensions}:force_original_aspect_ratio=decrease`;
}
function imageQualityArguments(
format: ImageFormat,
quality?: number
): readonly string[] {
if (quality === undefined) {
return [];
}
if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
throw new RangeError('Image quality must be between 1 and 100');
}
if (format === 'jpeg') {
return ['-q:v', String(Math.max(2, Math.round(31 - quality * 0.29)))];
}
if (format === 'webp') {
return ['-quality', String(Math.round(quality))];
}
return [];
}
function imageRequirements(format: ImageFormat): {
readonly encoders: readonly string[];
readonly muxers: readonly string[];
} {
return format === 'jpeg'
? { encoders: ['mjpeg'], muxers: ['image2'] }
: format === 'png'
? { encoders: ['png'], muxers: ['image2'] }
: { encoders: ['libwebp'], muxers: ['webp'] };
}
function extensionForImageFormat(format: ImageFormat): string {
return format === 'jpeg' ? 'jpg' : format;
}
+407
View File
@@ -0,0 +1,407 @@
import { presetEncodingArguments } from '../presets/preset-registry';
import type { ExportPreset } from './convert';
import { requirementsForUserPreset } from './convert';
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
mergeRequirements,
normalizeSeconds,
validateRange,
type SourceReference,
} from './command-utils';
import { audioFadeFilters, videoFadeFilters } from './filtergraph-builder';
import type { FadeCurve } from './filtergraph-builder';
import type {
LoudnessMeasurement,
LoudnessTargets,
PeakMeasurement,
} from './loudness';
import {
buildPeakNormalizationFilter,
validateLoudnessMeasurement,
} from './loudness';
import { buildTransformFiltergraph, type TransformSettings } from './transform';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export type TimelineAudioNormalization =
| {
readonly mode: 'peak';
readonly targetPeakDb: number;
readonly measurement: PeakMeasurement;
}
| {
readonly mode: 'loudness';
readonly targets: LoudnessTargets;
readonly measurement: LoudnessMeasurement;
};
export interface TimelineExportClip {
readonly id: string;
readonly source: SourceReference;
readonly sourceInSeconds: number;
readonly sourceOutSeconds: number;
readonly sourceDurationSeconds?: number;
readonly hasVideo: boolean;
readonly hasAudio: boolean;
readonly transform?: Omit<
TransformSettings,
'frameRate' | 'pixelFormat' | 'fadeNodes' | 'subtitleFilterExpression'
>;
readonly audioGainDb?: number;
readonly audioNormalization?: TimelineAudioNormalization;
readonly audioFadeInSeconds?: number;
readonly audioFadeOutSeconds?: number;
readonly audioFadeCurve?: FadeCurve;
readonly videoFadeInSeconds?: number;
readonly videoFadeOutSeconds?: number;
readonly videoFadeCurve?: FadeCurve;
}
export interface TimelineExportOptions {
readonly jobId: string;
readonly clips: readonly TimelineExportClip[];
readonly preset: ExportPreset;
readonly outputWidth: number;
readonly outputHeight: number;
readonly frameRate: number;
readonly sampleRate?: number;
readonly channelLayout?: 'mono' | 'stereo' | '5.1';
readonly missingAudioPolicy: 'insert-silence' | 'drop-all' | 'reject';
readonly outputFileName?: string;
}
/**
* Creates one normalized sequential export command. It composes per-clip
* transforms into a deterministic filter_complex graph and emits one output.
* Loudness clips require a measurement produced by the first-pass builder.
*/
export function buildTimelineExportPlan(
options: TimelineExportOptions
): FFmpegCommandPlan {
if (options.clips.length === 0 || options.clips.length > 100) {
throw new RangeError('Timeline export requires 1100 clips');
}
if (!options.preset.video) {
throw new TypeError('Timeline export requires a video preset');
}
validateOutput(options.outputWidth, options.outputHeight, options.frameRate);
if (
options.sampleRate !== undefined &&
(!Number.isSafeInteger(options.sampleRate) ||
options.sampleRate < 8_000 ||
options.sampleRate > 192_000)
) {
throw new RangeError(
'Timeline audio sample rate must be from 8000 to 192000 Hz'
);
}
const ids = new Set<string>();
for (const clip of options.clips) {
if (!clip.id || ids.has(clip.id)) {
throw new TypeError('Timeline clip IDs must be nonempty and unique');
}
ids.add(clip.id);
if (!clip.hasVideo) {
throw new TypeError(
'Timeline export currently requires video in every clip'
);
}
validateRange(
clip.sourceInSeconds,
clip.sourceOutSeconds,
clip.sourceDurationSeconds
);
}
const audioPresence = options.clips.map((clip) => clip.hasAudio);
const mixedAudio =
audioPresence.some(Boolean) && audioPresence.some((value) => !value);
if (mixedAudio && options.missingAudioPolicy === 'reject') {
throw new TypeError(
'Some clips have no audio; choose insert-silence or drop-all explicitly'
);
}
const includeAudio =
options.missingAudioPolicy !== 'drop-all' && audioPresence.some(Boolean);
if (includeAudio && !options.preset.audio) {
throw new TypeError('Selected preset does not include an audio encoder');
}
const inputs = options.clips.map((clip) =>
createPlannedInput(options.jobId, clip.source)
);
const args: string[] = [];
inputs.forEach((input) => args.push('-i', input.path));
const graph: string[] = [];
const requiredFilters = new Set<string>([
'trim',
'setpts',
'scale',
'pad',
'setsar',
'fps',
'format',
'concat',
]);
if (includeAudio) {
['atrim', 'asetpts', 'aresample', 'aformat'].forEach((filter) =>
requiredFilters.add(filter)
);
}
options.clips.forEach((clip, index) => {
const duration = clip.sourceOutSeconds - clip.sourceInSeconds;
validateFadePair(
duration,
clip.videoFadeInSeconds,
clip.videoFadeOutSeconds,
'Video'
);
validateFadePair(
duration,
clip.audioFadeInSeconds,
clip.audioFadeOutSeconds,
'Audio'
);
const transform = clip.transform
? buildTransformFiltergraph(clip.transform)
: { graph: undefined, requirements: {} };
for (const filter of transform.requirements?.filters ?? []) {
requiredFilters.add(filter);
}
const videoChain = [
`trim=start=${normalizeSeconds(clip.sourceInSeconds)}:end=${normalizeSeconds(clip.sourceOutSeconds)}`,
'setpts=PTS-STARTPTS',
...(transform.graph ? [transform.graph] : []),
`scale=${options.outputWidth}:${options.outputHeight}:force_original_aspect_ratio=decrease`,
`pad=${options.outputWidth}:${options.outputHeight}:(ow-iw)/2:(oh-ih)/2`,
'setsar=1',
`fps=${options.frameRate}`,
'format=yuv420p',
...videoFadeFilters(
duration,
clip.videoFadeInSeconds,
clip.videoFadeOutSeconds,
clip.videoFadeCurve
).map((node) => {
for (const filter of node.requiredCapabilities?.filters ?? []) {
requiredFilters.add(filter);
}
return node.expression;
}),
];
graph.push(`[${index}:v:0]${videoChain.join(',')}[v${index}]`);
if (includeAudio) {
if (clip.hasAudio) {
const audioChain = [
`atrim=start=${normalizeSeconds(clip.sourceInSeconds)}:end=${normalizeSeconds(clip.sourceOutSeconds)}`,
'asetpts=PTS-STARTPTS',
`aresample=${options.sampleRate ?? 48_000}`,
`aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}`,
];
if (clip.audioGainDb !== undefined) {
if (
!Number.isFinite(clip.audioGainDb) ||
Math.abs(clip.audioGainDb) > 60
) {
throw new RangeError('Audio gain must be between -60 and 60 dB');
}
audioChain.push(`volume=${signed(clip.audioGainDb)}dB`);
requiredFilters.add('volume');
}
if (clip.audioNormalization) {
audioChain.push(normalizationFilter(clip.audioNormalization));
requiredFilters.add(
clip.audioNormalization.mode === 'peak' ? 'volume' : 'loudnorm'
);
}
audioChain.push(
...audioFadeFilters(
duration,
clip.audioFadeInSeconds,
clip.audioFadeOutSeconds,
clip.audioFadeCurve
).map((node) => {
requiredFilters.add('afade');
return node.expression;
})
);
graph.push(`[${index}:a:0]${audioChain.join(',')}[a${index}]`);
} else {
requiredFilters.add('anullsrc');
graph.push(
`anullsrc=r=${options.sampleRate ?? 48_000}:cl=${options.channelLayout ?? 'stereo'},atrim=duration=${normalizeSeconds(duration)},aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}[a${index}]`
);
}
}
});
const labels = options.clips
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
.join('');
graph.push(
`${labels}concat=n=${options.clips.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
);
args.push('-filter_complex', graph.join(';'), '-map', '[vout]');
if (includeAudio) {
args.push('-map', '[aout]');
}
args.push(...presetEncodingArguments(options.preset));
if (!includeAudio) {
args.push('-an');
}
args.push(
'-map_metadata',
options.preset.metadataPolicy === 'remove' ? '-1' : '0',
'-map_chapters',
'-1',
'-sn'
);
const output = createPlannedOutput(options.jobId, {
baseName: options.clips[0]?.source.fileName ?? 'timeline',
operation: 'timeline',
extension: options.preset.fileExtension,
...(options.outputFileName ? { fileName: options.outputFileName } : {}),
});
args.push('-progress', 'pipe:1', '-nostats', output.path);
const presetRequirements =
'builtIn' in options.preset
? options.preset.requirements
: requirementsForUserPreset(options.preset);
return freezeCommandPlan({
id: `${options.jobId}:timeline-export`,
operation: 'timeline-export',
inputs,
temporaryFiles: [],
args,
outputs: [output],
expectedDurationSeconds: options.clips.reduce(
(sum, clip) => sum + clip.sourceOutSeconds - clip.sourceInSeconds,
0
),
requiredCapabilities: mergeRequirements(presetRequirements, {
filters: [...requiredFilters],
}),
diagnostics: [
diagnostic(
'timeline-reencode',
'info',
'Sequential timeline clips are trimmed, normalized, concatenated, and re-encoded in one command.'
),
...(options.preset.chapterPolicy !== 'remove'
? [
diagnostic(
'timeline-chapters-omitted',
'warning' as const,
'Source chapters are omitted because their times do not automatically match the edited timeline; mux validated project chapters explicitly.'
),
]
: []),
...(options.preset.subtitlePolicy !== 'none'
? [
diagnostic(
'timeline-subtitles-omitted',
'warning' as const,
'Subtitle streams are omitted from this filtered timeline plan; use an explicit compatible mux or burn-in plan.'
),
]
: []),
...(mixedAudio && options.missingAudioPolicy === 'insert-silence'
? [
diagnostic(
'inserted-silence',
'warning' as const,
'Silence is inserted for timeline clips without audio.'
),
]
: []),
],
});
}
function normalizationFilter(
normalization: TimelineAudioNormalization
): string {
if (normalization.mode === 'peak') {
return buildPeakNormalizationFilter(
normalization.targetPeakDb,
normalization.measurement
);
}
validateLoudnessMeasurement(normalization.measurement);
const { targets, measurement } = normalization;
if (
!Number.isFinite(targets.integratedLufs) ||
targets.integratedLufs < -70 ||
targets.integratedLufs > -5 ||
!Number.isFinite(targets.truePeakDbtp) ||
targets.truePeakDbtp < -9 ||
targets.truePeakDbtp > 0 ||
!Number.isFinite(targets.loudnessRangeLu) ||
targets.loudnessRangeLu < 1 ||
targets.loudnessRangeLu > 50
) {
throw new RangeError('Loudness targets are outside supported ranges');
}
return [
`loudnorm=I=${signed(targets.integratedLufs)}`,
`TP=${signed(targets.truePeakDbtp)}`,
`LRA=${normalizeSeconds(targets.loudnessRangeLu)}`,
`measured_I=${signed(measurement.inputIntegratedLufs)}`,
`measured_TP=${signed(measurement.inputTruePeakDbtp)}`,
`measured_LRA=${normalizeSeconds(measurement.inputLoudnessRangeLu)}`,
`measured_thresh=${signed(measurement.inputThresholdLufs)}`,
`offset=${signed(measurement.targetOffsetLu ?? 0)}`,
'linear=true',
].join(':');
}
function validateOutput(
width: number,
height: number,
frameRate: number
): void {
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width < 2 ||
height < 2 ||
width > 8192 ||
height > 8192 ||
width % 2 !== 0 ||
height % 2 !== 0
) {
throw new RangeError(
'Timeline dimensions must be even integers from 2 to 8192'
);
}
if (!Number.isFinite(frameRate) || frameRate <= 0 || frameRate > 240) {
throw new RangeError('Timeline frame rate must be between 0 and 240');
}
}
function signed(value: number): string {
if (!Number.isFinite(value)) {
throw new RangeError('Filter value must be finite');
}
return value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '');
}
function validateFadePair(
durationSeconds: number,
fadeInSeconds: number | undefined,
fadeOutSeconds: number | undefined,
label: string
): void {
const fadeIn = fadeInSeconds ?? 0;
const fadeOut = fadeOutSeconds ?? 0;
if (
!Number.isFinite(fadeIn) ||
!Number.isFinite(fadeOut) ||
fadeIn < 0 ||
fadeOut < 0 ||
fadeIn + fadeOut > durationSeconds
) {
throw new RangeError(
`${label} fade durations must be nonnegative and cannot overlap`
);
}
}
+290
View File
@@ -0,0 +1,290 @@
import {
createVideoFiltergraph,
type FilterNode,
type VideoFilterStage,
} from './filtergraph-builder';
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import type { SourceReference } from './command-utils';
import type { FFmpegCommandPlan } from './command-plan';
export interface CropSettings {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly sourceWidth: number;
readonly sourceHeight: number;
readonly requireEvenDimensions?: boolean;
}
export type ResizeMode = 'fit' | 'contain' | 'cover' | 'stretch';
export interface ResizeSettings {
readonly sourceWidth: number;
readonly sourceHeight: number;
readonly width?: number;
readonly height?: number;
readonly mode: ResizeMode;
readonly noUpscaling?: boolean;
readonly algorithm?: 'fast_bilinear' | 'bilinear' | 'bicubic' | 'lanczos';
readonly paddingColor?: string;
}
export interface TransformSettings {
readonly crop?: CropSettings;
readonly resize?: ResizeSettings;
readonly rotation?: 0 | 90 | 180 | 270;
readonly frameRate?: number;
readonly pixelFormat?: 'yuv420p' | 'yuv422p' | 'yuv444p' | 'rgba';
readonly subtitleFilterExpression?: string;
readonly fadeNodes?: readonly FilterNode<VideoFilterStage>[];
}
export interface Dimensions {
readonly width: number;
readonly height: number;
}
export interface TransformPlanOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly preset: ExportPreset;
readonly settings: TransformSettings;
readonly expectedDurationSeconds?: number;
}
export function buildTransformPlan(
options: TransformPlanOptions
): FFmpegCommandPlan {
const filtergraph = buildTransformFiltergraph(options.settings);
if (!filtergraph.graph) {
throw new TypeError(
'At least one crop, resize, rotation, frame-rate, or format transform is required'
);
}
return buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
operation: 'transform',
videoFiltergraph: filtergraph.graph,
additionalRequirements: filtergraph.requirements,
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
});
}
export function validateCrop(settings: CropSettings): CropSettings {
const fields = [
settings.x,
settings.y,
settings.width,
settings.height,
settings.sourceWidth,
settings.sourceHeight,
];
if (fields.some((value) => !Number.isSafeInteger(value))) {
throw new RangeError('Crop coordinates and dimensions must be integers');
}
if (
settings.x < 0 ||
settings.y < 0 ||
settings.width <= 0 ||
settings.height <= 0 ||
settings.x + settings.width > settings.sourceWidth ||
settings.y + settings.height > settings.sourceHeight
) {
throw new RangeError('Crop rectangle must be inside the source dimensions');
}
if (!settings.requireEvenDimensions) {
return Object.freeze({ ...settings });
}
const width = settings.width - (settings.width % 2);
const height = settings.height - (settings.height % 2);
const x = settings.x - (settings.x % 2);
const y = settings.y - (settings.y % 2);
if (width < 2 || height < 2) {
throw new RangeError('Even crop dimensions must be at least 2 × 2');
}
return Object.freeze({ ...settings, x, y, width, height });
}
export function calculateResize(settings: ResizeSettings): Dimensions {
const { sourceWidth, sourceHeight } = settings;
if (
!Number.isFinite(sourceWidth) ||
!Number.isFinite(sourceHeight) ||
sourceWidth <= 0 ||
sourceHeight <= 0
) {
throw new RangeError('Source dimensions must be positive');
}
if (settings.width === undefined && settings.height === undefined) {
throw new RangeError('At least one output dimension is required');
}
for (const dimension of [settings.width, settings.height]) {
if (
dimension !== undefined &&
(!Number.isSafeInteger(dimension) || dimension < 1 || dimension > 8192)
) {
throw new RangeError(
'Requested output dimensions must be integers from 1 to 8192'
);
}
}
const requestedWidth =
settings.width ??
(sourceWidth * (settings.height as number)) / sourceHeight;
const requestedHeight =
settings.height ??
(sourceHeight * (settings.width as number)) / sourceWidth;
if (
!Number.isFinite(requestedWidth) ||
!Number.isFinite(requestedHeight) ||
requestedWidth < 1 ||
requestedHeight < 1 ||
requestedWidth > 8192 ||
requestedHeight > 8192
) {
throw new RangeError('Output dimensions must be between 1 and 8192');
}
if (settings.mode === 'stretch') {
return {
width: even(requestedWidth),
height: even(requestedHeight),
};
}
const scale =
settings.mode === 'cover'
? Math.max(requestedWidth / sourceWidth, requestedHeight / sourceHeight)
: Math.min(requestedWidth / sourceWidth, requestedHeight / sourceHeight);
const boundedScale = settings.noUpscaling ? Math.min(1, scale) : scale;
if (
settings.mode === 'cover' &&
settings.noUpscaling &&
boundedScale < scale
) {
throw new RangeError(
'Cover resize cannot fill the requested dimensions without upscaling'
);
}
return {
width: even(sourceWidth * boundedScale),
height: even(sourceHeight * boundedScale),
};
}
export function buildTransformFiltergraph(settings: TransformSettings): {
readonly graph?: string;
readonly requirements: ReturnType<
ReturnType<typeof createVideoFiltergraph>['build']
>['requirements'];
} {
const builder = createVideoFiltergraph();
if (settings.crop) {
const crop = validateCrop(settings.crop);
builder.add({
id: 'crop',
stage: 'crop',
expression: `crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}`,
requiredCapabilities: { filters: ['crop'] },
});
}
if (settings.rotation) {
const expression =
settings.rotation === 90
? 'transpose=clock'
: settings.rotation === 270
? 'transpose=cclock'
: 'hflip,vflip';
builder.add({
id: 'rotation',
stage: 'rotate',
expression,
requiredCapabilities: {
filters: settings.rotation === 180 ? ['hflip', 'vflip'] : ['transpose'],
},
});
}
if (settings.resize) {
if (
settings.resize.paddingColor !== undefined &&
!/^(?:#[0-9a-f]{6}|[a-z]{3,20})$/iu.test(settings.resize.paddingColor)
) {
throw new TypeError(
'Resize padding colour must be a named colour or #RRGGBB'
);
}
const size = calculateResize(settings.resize);
const flags = settings.resize.algorithm ?? 'lanczos';
let expression = `scale=${size.width}:${size.height}:flags=${flags}`;
if (
settings.resize.mode === 'contain' &&
settings.resize.width &&
settings.resize.height
) {
expression += `,pad=${settings.resize.width}:${settings.resize.height}:(ow-iw)/2:(oh-ih)/2:${settings.resize.paddingColor ?? 'black'}`;
} else if (
settings.resize.mode === 'cover' &&
settings.resize.width &&
settings.resize.height
) {
expression += `,crop=${settings.resize.width}:${settings.resize.height}`;
}
builder.add({
id: 'resize',
stage: 'resize',
expression,
requiredCapabilities: {
filters:
settings.resize.mode === 'contain'
? ['scale', 'pad']
: settings.resize.mode === 'cover'
? ['scale', 'crop']
: ['scale'],
},
});
}
if (settings.frameRate !== undefined) {
if (
!Number.isFinite(settings.frameRate) ||
settings.frameRate <= 0 ||
settings.frameRate > 240
) {
throw new RangeError('Frame rate must be between 0 and 240');
}
builder.add({
id: 'frame-rate',
stage: 'frame-rate',
expression: `fps=${settings.frameRate}`,
requiredCapabilities: { filters: ['fps'] },
});
}
if (settings.pixelFormat) {
builder.add({
id: 'pixel-format',
stage: 'format',
expression: `format=${settings.pixelFormat}`,
requiredCapabilities: { filters: ['format'] },
});
}
if (settings.subtitleFilterExpression) {
builder.add({
id: 'subtitles',
stage: 'subtitles',
expression: settings.subtitleFilterExpression,
requiredCapabilities: { filters: ['subtitles'] },
});
}
for (const node of settings.fadeNodes ?? []) {
builder.add(node);
}
return builder.build();
}
function even(value: number): number {
const rounded = Math.max(2, Math.round(value));
return rounded - (rounded % 2);
}
+111
View File
@@ -0,0 +1,111 @@
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
normalizeSeconds,
streamMapArguments,
validateRange,
type SourceReference,
type StreamSelection,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export interface TrimOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly mode: 'fast' | 'accurate';
readonly startSeconds: number;
readonly endSeconds: number;
readonly sourceDurationSeconds?: number;
readonly streamSelection?: StreamSelection;
readonly preset?: ExportPreset;
readonly targetExtension?: string;
}
export function buildTrimPlan(options: TrimOptions): FFmpegCommandPlan {
validateRange(
options.startSeconds,
options.endSeconds,
options.sourceDurationSeconds
);
const expectedDurationSeconds = options.endSeconds - options.startSeconds;
if (options.mode === 'accurate') {
if (!options.preset) {
throw new TypeError('Accurate trim requires an export preset');
}
const base = buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
...(options.streamSelection
? { streamSelection: options.streamSelection }
: {}),
expectedDurationSeconds,
operation: 'trim',
});
const inputIndex = base.args.indexOf('-i');
const args = [...base.args];
args.splice(
inputIndex,
0,
'-ss',
normalizeSeconds(options.startSeconds),
'-to',
normalizeSeconds(options.endSeconds)
);
return freezeCommandPlan({
...base,
args,
diagnostics: [
diagnostic(
'accurate-trim',
'info',
'Accurate trim decodes and re-encodes selected streams at the requested boundaries.'
),
],
});
}
if (!options.targetExtension) {
throw new TypeError('Fast trim requires a targetExtension');
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'trim',
extension: options.targetExtension,
});
const args: string[] = [
'-ss',
normalizeSeconds(options.startSeconds),
'-to',
normalizeSeconds(options.endSeconds),
'-i',
input.path,
];
if (options.streamSelection) {
args.push(...streamMapArguments(options.streamSelection));
} else {
args.push('-map', '0');
}
args.push('-c', 'copy', '-progress', 'pipe:1', '-nostats', output.path);
return freezeCommandPlan({
id: `${options.jobId}:trim`,
operation: 'trim-fast',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
expectedDurationSeconds,
requiredCapabilities: {},
diagnostics: [
diagnostic(
'keyframe-boundary',
'warning',
'Fast trim uses stream copy and can begin at a nearby keyframe rather than the exact requested frame.'
),
],
});
}
+165
View File
@@ -0,0 +1,165 @@
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
type SourceReference,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export interface WaveformOptions {
readonly jobId: string;
readonly source: SourceReference;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly sampleRate?: number;
readonly expectedDurationSeconds?: number;
}
export interface StaticWaveformOptions {
readonly jobId: string;
readonly source: SourceReference;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly width?: number;
readonly height?: number;
readonly color?: string;
readonly background?: string;
readonly expectedDurationSeconds?: number;
}
export function buildStaticWaveformPlan(
options: StaticWaveformOptions
): FFmpegCommandPlan {
const width = options.width ?? 1200;
const height = options.height ?? 240;
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width < 64 ||
height < 32 ||
width > 8192 ||
height > 8192
) {
throw new RangeError(
'Static waveform dimensions must be integers from 64×32 through 8192×8192'
);
}
if (
!Number.isSafeInteger(options.audioStreamIndex) ||
options.audioStreamIndex < 0
) {
throw new RangeError('Invalid audio stream index');
}
const color = options.color ?? '#62d6c8';
const background = options.background ?? '#101b22';
if (
!/^(?:#[0-9a-f]{6}|[a-z]{3,20})$/iu.test(color) ||
!/^(?:#[0-9a-f]{6}|[a-z]{3,20})$/iu.test(background)
) {
throw new TypeError('Waveform colours must be named colours or #RRGGBB');
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'waveform',
suffix: 'static',
extension: 'png',
role: 'analysis',
mediaType: 'image/png',
});
return freezeCommandPlan({
id: `${options.jobId}:static-waveform`,
operation: 'waveform-static',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-filter_complex',
`color=c=${background}:s=${width}x${height}:d=1[background];[0:${options.audioStreamIndex}]aformat=channel_layouts=mono,showwavespic=s=${width}x${height}:colors=${color}[foreground];[background][foreground]overlay=shortest=1[waveform]`,
'-map',
'[waveform]',
'-frames:v',
'1',
output.path,
],
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: {
filters: ['color', 'aformat', 'showwavespic', 'overlay'],
encoders: ['png'],
muxers: ['image2'],
},
diagnostics: [
diagnostic(
'static-waveform',
'info',
'A bounded PNG waveform is generated from the selected local audio stream.'
),
],
});
}
export function buildWaveformAnalysisPlan(
options: WaveformOptions
): FFmpegCommandPlan {
const sampleRate = options.sampleRate ?? 8_000;
if (
!Number.isSafeInteger(sampleRate) ||
sampleRate < 1_000 ||
sampleRate > 48_000
) {
throw new RangeError(
'Waveform sample rate must be between 1000 and 48000 Hz'
);
}
const streamIndex = options.audioStreamIndex;
if (!Number.isSafeInteger(streamIndex) || streamIndex < 0) {
throw new RangeError('Invalid audio stream index');
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'waveform',
extension: 'wav',
role: 'analysis',
mediaType: 'audio/wav',
});
return freezeCommandPlan({
id: `${options.jobId}:waveform`,
operation: 'waveform-analysis',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-map',
`0:${streamIndex}`,
'-vn',
'-ac',
'1',
'-ar',
String(sampleRate),
'-c:a',
'pcm_s16le',
'-progress',
'pipe:1',
'-nostats',
output.path,
],
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { encoders: ['pcm_s16le'], muxers: ['wav'] },
diagnostics: [
diagnostic(
'reduced-analysis-audio',
'info',
'Waveform analysis uses a reduced-rate mono PCM derivative; source media is unchanged.'
),
],
});
}
+852
View File
@@ -0,0 +1,852 @@
.advanced-operations {
min-width: 0;
overflow: hidden;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) + 0.25rem);
background: var(--toolbox-surface);
color: var(--toolbox-text);
box-shadow: 0 8px 25px rgb(24 34 68 / 5%);
}
.advanced-operations *,
.advanced-operations *::before,
.advanced-operations *::after {
box-sizing: border-box;
}
.advanced-operations h2,
.advanced-operations h3,
.advanced-operations h4,
.advanced-operations p {
margin-block-start: 0;
}
.advanced-operations__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
padding: 1.1rem 1.2rem;
border-bottom: 1px solid var(--toolbox-border);
}
.advanced-operations__header h2 {
margin-block-end: 0.28rem;
font-size: 1.05rem;
}
.advanced-operations__header p:not(.advanced-operations__eyebrow) {
max-width: 50rem;
margin-block-end: 0;
color: var(--toolbox-muted);
font-size: 0.78rem;
line-height: 1.55;
}
.advanced-operations__eyebrow {
margin-block-end: 0.25rem;
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.advanced-operations__local {
flex: none;
padding: 0.45rem 0.65rem;
border: 1px solid
color-mix(
in srgb,
var(--toolbox-success, #2f8f5b) 34%,
var(--toolbox-border)
);
border-radius: 999px;
background: color-mix(
in srgb,
var(--toolbox-success, #2f8f5b) 8%,
var(--toolbox-surface)
);
color: var(--toolbox-success, #2f8f5b);
font-size: 0.68rem;
font-weight: 800;
}
.advanced-operations__tabs {
display: flex;
gap: 0.25rem;
padding: 0.45rem;
overflow-x: auto;
border-bottom: 1px solid var(--toolbox-border);
background: var(--toolbox-surface-soft);
scrollbar-width: thin;
}
.advanced-operations__tabs button {
min-height: 2.35rem;
flex: 1 0 auto;
padding: 0.45rem 0.7rem;
border: 1px solid transparent;
border-radius: calc(var(--toolbox-radius) - 0.12rem);
background: transparent;
color: var(--toolbox-muted);
cursor: pointer;
font-size: 0.7rem;
font-weight: 760;
white-space: nowrap;
}
.advanced-operations__tabs button[aria-selected='true'] {
border-color: var(--toolbox-border);
background: var(--toolbox-surface);
color: var(--toolbox-accent);
box-shadow: 0 2px 8px rgb(24 34 68 / 8%);
}
.advanced-operations__tab-short {
display: none;
}
.advanced-operations__panel {
padding: 1rem;
}
.advanced-operations__panel[hidden] {
display: none;
}
.advanced-operations__section-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 1rem;
}
.advanced-operations__stack {
display: grid;
gap: 1rem;
}
.advanced-operation-card {
min-width: 0;
padding: 1rem;
border: 1px solid var(--toolbox-border);
border-radius: var(--toolbox-radius);
background: color-mix(
in srgb,
var(--toolbox-surface) 94%,
var(--toolbox-background)
);
}
.advanced-operation-card--wide {
grid-column: 1 / -1;
}
.advanced-operation-card > header {
display: flex;
align-items: flex-start;
gap: 0.7rem;
margin-block-end: 1rem;
}
.advanced-operation-card > header h3 {
margin: 0 0 0.2rem;
font-size: 0.87rem;
}
.advanced-operation-card > header p {
max-width: 48rem;
margin-block-end: 0;
color: var(--toolbox-muted);
font-size: 0.72rem;
line-height: 1.45;
}
.advanced-operation-card > h4 {
margin: 1.2rem 0 0.65rem;
color: var(--toolbox-muted);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.advanced-operation-card__step {
width: 1.8rem;
height: 1.8rem;
display: inline-grid;
flex: none;
place-items: center;
border-radius: 0.55rem;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.72rem;
font-weight: 850;
}
.advanced-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-block-end: 0.85rem;
}
.advanced-form-grid--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.advanced-form-grid--four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.advanced-field,
.advanced-file-field {
min-width: 0;
display: grid;
align-content: start;
gap: 0.34rem;
margin-block-end: 0.85rem;
color: var(--toolbox-text);
font-size: 0.7rem;
font-weight: 740;
}
.advanced-field > span:first-child,
.advanced-file-field > span:first-child {
min-height: 1rem;
}
.advanced-field > span small {
color: var(--toolbox-muted);
font-weight: 650;
}
.advanced-field input,
.advanced-field select,
.advanced-file-field input {
width: 100%;
min-height: 2.5rem;
padding: 0.48rem 0.62rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface);
color: var(--toolbox-text);
font: inherit;
font-weight: 620;
}
.advanced-field input:focus-visible,
.advanced-field select:focus-visible,
.advanced-file-field input:focus-visible,
.advanced-operations button:focus-visible {
outline: 3px solid var(--toolbox-focus);
outline-offset: 2px;
}
.advanced-field > small,
.advanced-file-field > small {
color: var(--toolbox-muted);
font-size: 0.64rem;
font-weight: 580;
line-height: 1.4;
}
.advanced-field--color > span:last-child {
min-height: 2.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.32rem 0.5rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface);
}
.advanced-field--color input {
width: 2rem;
min-height: 1.75rem;
padding: 0;
border: 0;
cursor: pointer;
}
.advanced-field--color code {
color: var(--toolbox-muted);
font-size: 0.68rem;
}
.advanced-inline-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.6rem;
}
.advanced-inline-fields .advanced-button {
margin-block-end: 0.85rem;
}
.advanced-choice-cards {
display: grid;
gap: 0.55rem;
margin: 0 0 0.85rem;
padding: 0;
border: 0;
}
.advanced-choice-cards label {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.72rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.12rem);
background: var(--toolbox-surface);
cursor: pointer;
}
.advanced-choice-cards label:has(input:checked) {
border-color: color-mix(
in srgb,
var(--toolbox-accent) 48%,
var(--toolbox-border)
);
background: var(--toolbox-accent-soft);
}
.advanced-choice-cards input {
margin-block-start: 0.15rem;
accent-color: var(--toolbox-accent);
}
.advanced-choice-cards span,
.advanced-choice-cards strong,
.advanced-choice-cards small {
display: block;
}
.advanced-choice-cards strong {
margin-block-end: 0.18rem;
font-size: 0.74rem;
}
.advanced-choice-cards small {
color: var(--toolbox-muted);
font-size: 0.65rem;
line-height: 1.4;
}
.advanced-check-grid {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-block-end: 0.9rem;
}
.advanced-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-height: 2.2rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface);
cursor: pointer;
font-size: 0.68rem;
font-weight: 680;
}
.advanced-check input {
accent-color: var(--toolbox-accent);
}
.advanced-stream-metadata {
display: grid;
gap: 0.7rem;
margin-block: 0.85rem;
}
.advanced-stream-metadata fieldset {
min-width: 0;
margin: 0;
padding: 0.75rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.08rem);
background: var(--toolbox-surface-soft);
}
.advanced-stream-metadata legend {
padding-inline: 0.35rem;
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 780;
}
.advanced-disposition-details {
border-block-start: 1px solid var(--toolbox-border);
padding-block-start: 0.55rem;
}
.advanced-disposition-details summary {
cursor: pointer;
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 720;
}
.advanced-disposition-details .advanced-check-grid {
margin-block: 0.65rem 0;
}
.advanced-button,
.advanced-file-button {
min-height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.58rem 0.82rem;
border: 1px solid transparent;
border-radius: var(--toolbox-radius);
cursor: pointer;
font: inherit;
font-size: 0.72rem;
font-weight: 780;
line-height: 1.1;
text-align: center;
}
.advanced-button--primary {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.advanced-button--primary:hover:not(:disabled) {
border-color: var(--toolbox-accent-hover);
background: var(--toolbox-accent-hover);
}
.advanced-button--secondary,
.advanced-file-button {
border-color: var(--toolbox-border);
background: var(--toolbox-surface);
color: var(--toolbox-accent);
}
.advanced-button--secondary:hover:not(:disabled),
.advanced-file-button:hover {
border-color: color-mix(
in srgb,
var(--toolbox-accent) 35%,
var(--toolbox-border)
);
background: var(--toolbox-accent-soft);
}
.advanced-button--danger {
border-color: color-mix(
in srgb,
var(--toolbox-danger) 35%,
var(--toolbox-border)
);
background: color-mix(
in srgb,
var(--toolbox-danger) 8%,
var(--toolbox-surface)
);
color: var(--toolbox-danger);
}
.advanced-button:disabled,
.advanced-file-button--disabled {
cursor: not-allowed;
opacity: 0.52;
}
.advanced-file-button {
position: relative;
overflow: hidden;
}
.advanced-file-button input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.advanced-action {
min-width: min(100%, 13rem);
display: grid;
align-content: start;
gap: 0.28rem;
}
.advanced-action .advanced-button {
width: 100%;
}
.advanced-action__reason {
max-width: 22rem;
color: var(--toolbox-muted);
font-size: 0.61rem;
line-height: 1.35;
}
.advanced-action-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.6rem;
margin-block-start: 0.85rem;
}
.advanced-divider {
height: 1px;
margin: 1rem 0;
background: var(--toolbox-border);
}
.advanced-chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0 0 0.85rem;
padding: 0;
list-style: none;
}
.advanced-chip-list li {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.35rem 0.25rem 0.55rem;
border: 1px solid var(--toolbox-border);
border-radius: 999px;
background: var(--toolbox-surface-soft);
font-size: 0.67rem;
font-weight: 700;
}
.advanced-chip-list button {
width: 1.35rem;
height: 1.35rem;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--toolbox-muted);
cursor: pointer;
}
.advanced-chip-list button:hover {
background: var(--toolbox-surface);
color: var(--toolbox-danger);
}
.advanced-segment-list {
display: grid;
gap: 0.45rem;
margin-block-end: 0.85rem;
}
.advanced-segment-list > strong {
font-size: 0.67rem;
}
.advanced-segment-list ol,
.advanced-segment-status {
max-height: 16rem;
display: grid;
gap: 0.3rem;
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
scrollbar-width: thin;
}
.advanced-segment-list li,
.advanced-segment-status li {
min-width: 0;
display: grid;
grid-template-columns: minmax(7rem, 0.8fr) minmax(9rem, 1fr) minmax(
7rem,
0.8fr
);
gap: 0.55rem;
align-items: center;
padding: 0.48rem 0.58rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.18rem);
background: var(--toolbox-surface-soft);
font-size: 0.63rem;
font-variant-numeric: tabular-nums;
}
.advanced-segment-list li span,
.advanced-segment-list li small,
.advanced-segment-status li span,
.advanced-segment-status li small {
color: var(--toolbox-muted);
}
.advanced-segment-status {
max-height: 20rem;
}
.advanced-segment-status li {
grid-template-columns: minmax(10rem, 1fr) auto;
}
.advanced-segment-status li > div {
min-width: 0;
display: grid;
gap: 0.12rem;
}
.advanced-segment-status li > div strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.advanced-segment-status li > b {
padding: 0.18rem 0.38rem;
border-radius: 999px;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.56rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.advanced-segment-status li > small {
grid-column: 1 / -1;
color: var(--toolbox-warning, #a96700);
line-height: 1.4;
}
.advanced-segment-status--failed,
.advanced-segment-status--cancelled {
border-color: color-mix(
in srgb,
var(--toolbox-danger) 35%,
var(--toolbox-border)
) !important;
}
.advanced-summary,
.advanced-hint,
.advanced-empty,
.advanced-warning {
margin-block-end: 0.8rem;
padding: 0.58rem 0.68rem;
border-radius: calc(var(--toolbox-radius) - 0.15rem);
color: var(--toolbox-muted);
font-size: 0.67rem;
line-height: 1.45;
}
.advanced-summary,
.advanced-hint,
.advanced-empty {
background: var(--toolbox-surface-soft);
}
.advanced-warning {
border: 1px solid
color-mix(
in srgb,
var(--toolbox-warning, #a96700) 28%,
var(--toolbox-border)
);
background: color-mix(
in srgb,
var(--toolbox-warning, #a96700) 8%,
var(--toolbox-surface)
);
color: color-mix(
in srgb,
var(--toolbox-warning, #a96700) 72%,
var(--toolbox-text)
);
}
.advanced-measurement {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
margin: 0.8rem 0;
}
.advanced-measurement div {
min-width: 0;
padding: 0.6rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface-soft);
}
.advanced-measurement dt {
margin-block-end: 0.22rem;
color: var(--toolbox-muted);
font-size: 0.62rem;
}
.advanced-measurement dd {
margin: 0;
font-size: 0.74rem;
font-weight: 780;
}
.advanced-stream-list {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.advanced-stream-list li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.2rem 0.55rem;
padding: 0.65rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface);
font-size: 0.68rem;
}
.advanced-stream-list li > span {
padding: 0.18rem 0.38rem;
border-radius: 999px;
background: var(--toolbox-surface-soft);
color: var(--toolbox-muted);
font-size: 0.58rem;
font-weight: 800;
text-transform: uppercase;
}
.advanced-stream-list small {
grid-column: 1 / -1;
color: var(--toolbox-muted);
}
.advanced-preset-toolbar {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.6rem;
}
.advanced-preset-toolbar .advanced-field {
min-width: min(100%, 18rem);
flex: 1;
margin-block-end: 0;
}
.advanced-preset-toolbar .advanced-action {
min-width: auto;
}
.advanced-operations__status {
min-height: 1.25rem;
padding: 0 1rem 0.7rem;
color: var(--toolbox-muted);
font-size: 0.68rem;
}
.advanced-visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
@media (max-width: 64rem) {
.advanced-operations__section-grid {
grid-template-columns: 1fr;
}
.advanced-form-grid--four {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.advanced-operations__tab-long {
display: none;
}
.advanced-operations__tab-short {
display: inline;
}
}
@media (max-width: 40rem) {
.advanced-operations__header {
display: grid;
gap: 0.75rem;
}
.advanced-operations__local {
justify-self: start;
}
.advanced-operations__panel {
padding: 0.7rem;
}
.advanced-operation-card {
padding: 0.8rem;
}
.advanced-form-grid,
.advanced-form-grid--three,
.advanced-form-grid--four,
.advanced-inline-fields,
.advanced-measurement {
grid-template-columns: 1fr;
}
.advanced-inline-fields .advanced-button {
margin-block: -0.35rem 0.85rem;
}
.advanced-action,
.advanced-action .advanced-button,
.advanced-action-row > .advanced-button,
.advanced-action-row > .advanced-file-button {
width: 100%;
}
.advanced-segment-list li,
.advanced-segment-status li {
grid-template-columns: 1fr;
}
.advanced-segment-status li > b {
justify-self: start;
}
.advanced-segment-status li > small {
grid-column: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.advanced-operations *,
.advanced-operations *::before,
.advanced-operations *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
File diff suppressed because it is too large Load Diff
+327
View File
@@ -0,0 +1,327 @@
import { FFMPEG_CORE_VERSION, FFMPEG_WRAPPER_VERSION } from '../version';
import type { EnginePreference, EngineState } from '../ffmpeg/ffmpeg.types';
import {
describeEngineMode,
detectEngineEnvironment,
} from '../ffmpeg/engine-mode';
import type { ResourcePolicy } from '../storage';
import { Icon } from './Icon';
export interface CapabilityPanelProps {
state: EngineState;
preference: EnginePreference;
resourcePolicy: ResourcePolicy;
deviceMemoryGiB?: number;
onPreferenceChange: (preference: EnginePreference) => void;
onInitialize: () => void;
}
export function CapabilityPanel({
state,
preference,
resourcePolicy,
deviceMemoryGiB,
onPreferenceChange,
onInitialize,
}: CapabilityPanelProps) {
const environment = detectEngineEnvironment();
const mode =
state.status === 'ready' ||
state.status === 'loading' ||
state.status === 'running'
? state.mode
: undefined;
return (
<details className="capability-panel">
<summary>
<span>
<Icon name="settings" />
Engine & browser
</span>
<span className={`engine-pill engine-pill--${state.status}`}>
{state.status === 'idle'
? 'Not loaded'
: state.status === 'loading'
? 'Loading'
: state.status === 'ready'
? describeEngineMode(state.mode)
: state.status === 'running'
? 'Processing'
: state.status === 'recovering'
? 'Recovering'
: 'Unavailable'}
</span>
</summary>
<div className="capability-panel__body">
<label>
Engine mode
<select
value={preference}
disabled={state.status === 'running'}
onChange={(event) =>
onPreferenceChange(event.currentTarget.value as EnginePreference)
}
>
<option value="automatic">Automatic</option>
<option value="prefer-multithread">Prefer multithread</option>
<option value="force-single-thread">Force single-thread</option>
</select>
</label>
<dl className="capability-grid">
<div>
<dt>Selected</dt>
<dd>{mode ? describeEngineMode(mode) : 'After first import'}</dd>
</div>
<div>
<dt>Isolation</dt>
<dd>
{environment.crossOriginIsolated ? 'Enabled' : 'Unavailable'}
</dd>
</div>
<div>
<dt>Processors</dt>
<dd>{environment.hardwareConcurrency ?? 'Not exposed'}</dd>
</div>
<div>
<dt>Wrapper / core</dt>
<dd>
{FFMPEG_WRAPPER_VERSION} / {FFMPEG_CORE_VERSION}
</dd>
</div>
{state.status === 'ready' ? (
<>
<div>
<dt>FFmpeg</dt>
<dd title={state.capabilities.versionText}>
{versionLine(state.capabilities.versionText)}
</dd>
</div>
<div>
<dt>Demuxers</dt>
<dd>{state.capabilities.demuxers.size}</dd>
</div>
<div>
<dt>Muxers</dt>
<dd>{state.capabilities.muxers.size}</dd>
</div>
<div>
<dt>Decoders</dt>
<dd>{state.capabilities.decoders.size}</dd>
</div>
<div>
<dt>Encoders</dt>
<dd>{state.capabilities.encoders.size}</dd>
</div>
<div>
<dt>Filters</dt>
<dd>{state.capabilities.filters.size}</dd>
</div>
</>
) : null}
</dl>
<section
className="capability-limits"
aria-labelledby="active-resource-limits"
>
<div className="capability-limits__heading">
<h3 id="active-resource-limits">Active resource limits</h3>
<span>
{deviceMemoryGiB === undefined
? 'Conservative defaults'
: `Adjusted for ${deviceMemoryGiB} GiB device memory`}
</span>
</div>
<dl className="capability-grid capability-grid--limits">
<div>
<dt>Input warning / file limit</dt>
<dd>
{formatBytes(resourcePolicy.softInputBytes)} /{' '}
{formatBytes(resourcePolicy.hardSingleInputBytes)}
</dd>
</div>
<div>
<dt>Total input limit</dt>
<dd>{formatBytes(resourcePolicy.hardTotalInputBytes)}</dd>
</div>
<div>
<dt>Output warning / limit</dt>
<dd>
{formatBytes(resourcePolicy.softOutputEstimateBytes)} /{' '}
{formatBytes(resourcePolicy.hardOutputEstimateBytes)}
</dd>
</div>
<div>
<dt>Image limit</dt>
<dd>
{resourcePolicy.maxImageDimension.toLocaleString()} px / side
</dd>
</div>
<div>
<dt>Timeline / streams</dt>
<dd>
{resourcePolicy.maxTimelineClips} clips /{' '}
{resourcePolicy.maxStreamsPerInput} streams
</dd>
</div>
<div>
<dt>Generated files / images</dt>
<dd>
{resourcePolicy.maxGeneratedOutputs} /{' '}
{resourcePolicy.maxThumbnailCount}
</dd>
</div>
<div>
<dt>FFmpeg concurrency</dt>
<dd>{resourcePolicy.maxConcurrentFFmpegJobs}</dd>
</div>
<div>
<dt>Runtime limit</dt>
<dd>{formatDuration(resourcePolicy.maxJobRuntimeMs)}</dd>
</div>
</dl>
<p>
These product safety limits are conservative estimates, not media
format limits or a guarantee that a browser job will complete. The
generated-file limit reserves one ZIP entry for the optional export
report.
</p>
</section>
{state.status === 'idle' ? (
<button className="button button--secondary" onClick={onInitialize}>
Initialize engine
</button>
) : null}
{state.status === 'loading' ? (
<div className="load-stage" role="status">
<progress value={state.progress} max="1" />
<span>{state.stage ?? 'Loading local engine…'}</span>
</div>
) : null}
{state.status === 'ready' && state.warning ? (
<p className="notice notice--warning">
<Icon name="warning" />
{state.warning.message}
</p>
) : null}
{state.status === 'ready' ? (
<>
<details className="capability-build">
<summary>
Detected capability inventory (
{capabilityCount(state.capabilities)} entries)
</summary>
<pre>{capabilityInventory(state.capabilities)}</pre>
</details>
<details className="capability-build">
<summary>
FFmpeg build configuration (
{state.capabilities.buildConfiguration.length} flags)
</summary>
<pre>
{state.capabilities.buildConfiguration.length > 0
? state.capabilities.buildConfiguration.join('\n')
: 'No build flags were reported.'}
</pre>
</details>
</>
) : null}
{state.status === 'error' ? (
<div className="capability-error" role="alert">
<p className="notice notice--error">
<Icon name="warning" />
<span>
<strong>Engine initialization failed</strong>
{state.error.message}
</span>
</p>
<dl>
<div>
<dt>Error code</dt>
<dd>{state.error.code}</dd>
</div>
{state.error.exitCode === undefined ? null : (
<div>
<dt>FFmpeg exit code</dt>
<dd>{state.error.exitCode}</dd>
</div>
)}
</dl>
{state.error.details ? (
<details>
<summary>Technical details</summary>
<pre>{state.error.details}</pre>
</details>
) : null}
<button className="button button--secondary" onClick={onInitialize}>
Retry initialization
</button>
</div>
) : null}
</div>
</details>
);
}
function capabilityCount(
capabilities: Extract<EngineState, { status: 'ready' }>['capabilities']
): number {
return (
capabilities.demuxers.size +
capabilities.muxers.size +
capabilities.decoders.size +
capabilities.encoders.size +
capabilities.filters.size
);
}
function capabilityInventory(
capabilities: Extract<EngineState, { status: 'ready' }>['capabilities']
): string {
return (
[
['Demuxers', capabilities.demuxers],
['Muxers', capabilities.muxers],
['Decoders', capabilities.decoders],
['Encoders', capabilities.encoders],
['Filters', capabilities.filters],
] as const
)
.map(
([label, values]) =>
`${label} (${values.size})\n${[...values].sort().join(', ')}`
)
.join('\n\n');
}
function versionLine(versionText: string): string {
return (
versionText
.split(/\r?\n/u)
.map((line) => line.trim())
.find((line) => /^ffmpeg version\b/iu.test(line)) ??
versionText.trim().split(/\r?\n/u)[0] ??
'Unknown'
);
}
function formatBytes(bytes: number): string {
const mebibytes = bytes / (1024 * 1024);
if (mebibytes >= 1024) {
return `${new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
}).format(mebibytes / 1024)} GiB`;
}
return `${new Intl.NumberFormat(undefined, {
maximumFractionDigits: 0,
}).format(mebibytes)} MiB`;
}
function formatDuration(milliseconds: number): string {
const hours = milliseconds / (60 * 60 * 1000);
return Number.isInteger(hours)
? `${hours} h`
: `${new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
}).format(hours)} h`;
}
+952
View File
@@ -0,0 +1,952 @@
import {
useId,
useMemo,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from 'react';
import type { CropSettings } from '../project';
import './visual-editor.css';
type Orientation = 0 | 90 | 180 | 270;
type ResizeHandle = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
type DragMode = 'move' | ResizeHandle;
type CropField = keyof CropSettings;
type RatioChoice = 'current' | 'source' | '16:9' | '4:3' | '1:1' | '9:16';
interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
interface Dimensions {
width: number;
height: number;
}
interface ActiveDrag {
mode: DragMode;
pointerId: number;
startClientX: number;
startClientY: number;
startRectangle: Rectangle;
}
interface CropDraftState {
sourceKey: string;
values: Record<CropField, string>;
}
export interface CropOverlayProps {
/** Media element rendered below the crop controls. */
children: ReactNode;
/** Encoded source width, before applying display orientation. */
sourceWidth: number;
/** Encoded source height, before applying display orientation. */
sourceHeight: number;
/** Clockwise display orientation applied by this component. */
orientation?: Orientation;
/** Crop rectangle in encoded source pixels. */
value?: CropSettings;
onChange: (crop: CropSettings) => void;
disabled?: boolean;
requireEvenDimensions?: boolean;
minimumSize?: number;
ariaLabel?: string;
}
const handles: readonly ResizeHandle[] = [
'n',
'ne',
'e',
'se',
's',
'sw',
'w',
'nw',
];
const handleNames: Readonly<Record<ResizeHandle, string>> = {
n: 'top',
ne: 'top right',
e: 'right',
se: 'bottom right',
s: 'bottom',
sw: 'bottom left',
w: 'left',
nw: 'top left',
};
const ratioValues: Readonly<Record<Exclude<RatioChoice, 'current'>, number>> = {
source: 1,
'16:9': 16 / 9,
'4:3': 4 / 3,
'1:1': 1,
'9:16': 9 / 16,
};
/**
* A visual and numeric editor for one crop rectangle. Values emitted to the
* project remain in encoded-source coordinates even when the preview is
* displayed at a rotated orientation.
*/
export function CropOverlay({
children,
sourceWidth,
sourceHeight,
orientation = 0,
value,
onChange,
disabled = false,
requireEvenDimensions = false,
minimumSize = requireEvenDimensions ? 2 : 1,
ariaLabel = 'Visual crop editor',
}: CropOverlayProps) {
const instanceId = useId();
const errorId = `${instanceId}-crop-errors`;
const stageRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<ActiveDrag | undefined>(undefined);
const sourceDimensions = useMemo(
() => validateSourceDimensions(sourceWidth, sourceHeight),
[sourceHeight, sourceWidth]
);
const effectiveMinimum = normalizeMinimumSize(
minimumSize,
requireEvenDimensions
);
validateCropBounds(sourceDimensions, effectiveMinimum, requireEvenDimensions);
const initialCrop = useMemo(
() =>
resolveCropValue(
value,
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
),
[effectiveMinimum, requireEvenDimensions, sourceDimensions, value]
);
const cropSourceKey = createCropSourceKey(
initialCrop,
sourceDimensions,
requireEvenDimensions
);
const [draftState, setDraftState] = useState<CropDraftState>(() => ({
sourceKey: cropSourceKey,
values: cropToDraft(initialCrop),
}));
const [aspectLocked, setAspectLocked] = useState(false);
const [ratioChoice, setRatioChoice] = useState<RatioChoice>('current');
const [lockedDisplayRatio, setLockedDisplayRatio] = useState(() => {
const displayed = cropToDisplayedRectangle(
initialCrop,
sourceDimensions,
orientation
);
return displayed.width / displayed.height;
});
const crop = initialCrop;
const draft =
draftState.sourceKey === cropSourceKey
? draftState.values
: cropToDraft(initialCrop);
const displayedDimensions = getDisplayedDimensions(
sourceDimensions,
orientation
);
const displayedCrop = cropToDisplayedRectangle(
crop,
sourceDimensions,
orientation
);
const draftResult = validateCropDraft(
draft,
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
);
const externalValueErrors =
value === undefined
? []
: validateCrop(
value,
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
);
const visibleErrors =
draftResult.errors.length > 0 ? draftResult.errors : externalValueErrors;
const displayStyle = rectangleToPercentStyle(
displayedCrop,
displayedDimensions
);
const mediaStyle = getOrientedMediaStyle(
sourceDimensions,
displayedDimensions,
orientation
);
const emitDisplayedRectangle = (rectangle: Rectangle) => {
const encoded = displayedRectangleToCrop(
rectangle,
sourceDimensions,
orientation
);
const normalized = normalizeCrop(
encoded,
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
);
setDraftState({
sourceKey: cropSourceKey,
values: cropToDraft(normalized),
});
onChange(normalized);
};
const beginDrag = (
event: PointerEvent<HTMLButtonElement>,
mode: DragMode
) => {
if (disabled) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
dragRef.current = {
mode,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startRectangle: displayedCrop,
};
};
const continueDrag = (event: PointerEvent<HTMLButtonElement>) => {
const active = dragRef.current;
const bounds = stageRef.current?.getBoundingClientRect();
if (
disabled ||
active === undefined ||
active.pointerId !== event.pointerId ||
bounds === undefined ||
bounds.width <= 0 ||
bounds.height <= 0
) {
return;
}
event.preventDefault();
const deltaX =
((event.clientX - active.startClientX) / bounds.width) *
displayedDimensions.width;
const deltaY =
((event.clientY - active.startClientY) / bounds.height) *
displayedDimensions.height;
const next =
active.mode === 'move'
? moveRectangle(
active.startRectangle,
deltaX,
deltaY,
displayedDimensions
)
: resizeRectangle(
active.startRectangle,
active.mode,
deltaX,
deltaY,
displayedDimensions,
effectiveMinimum,
aspectLocked ? lockedDisplayRatio : undefined
);
emitDisplayedRectangle(next);
};
const endDrag = (event: PointerEvent<HTMLButtonElement>) => {
if (dragRef.current?.pointerId === event.pointerId) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
dragRef.current = undefined;
}
};
const handleKeyboard = (
event: KeyboardEvent<HTMLButtonElement>,
mode: DragMode
) => {
if (disabled) return;
const step = event.shiftKey ? 10 : 1;
const delta = arrowDelta(event.key, step);
if (delta === undefined) return;
event.preventDefault();
const next =
mode === 'move'
? moveRectangle(displayedCrop, delta.x, delta.y, displayedDimensions)
: resizeRectangle(
displayedCrop,
mode,
delta.x,
delta.y,
displayedDimensions,
effectiveMinimum,
aspectLocked ? lockedDisplayRatio : undefined
);
emitDisplayedRectangle(next);
};
const updateDraft = (
field: CropField,
event: ChangeEvent<HTMLInputElement>
) => {
const nextDraft = { ...draft, [field]: event.currentTarget.value };
setDraftState({ sourceKey: cropSourceKey, values: nextDraft });
const result = validateCropDraft(
nextDraft,
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
);
if (result.crop !== undefined) {
onChange(result.crop);
}
};
const reset = () => {
const fullCrop = normalizeCrop(
{
x: 0,
y: 0,
width: sourceDimensions.width,
height: sourceDimensions.height,
},
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
);
setRatioChoice('source');
setLockedDisplayRatio(
getDisplayedDimensions(sourceDimensions, orientation).width /
getDisplayedDimensions(sourceDimensions, orientation).height
);
setDraftState({
sourceKey: cropSourceKey,
values: cropToDraft(fullCrop),
});
onChange(fullCrop);
};
const selectRatio = (event: ChangeEvent<HTMLSelectElement>) => {
const choice = event.currentTarget.value as RatioChoice;
const ratio =
choice === 'current'
? displayedCrop.width / displayedCrop.height
: choice === 'source'
? displayedDimensions.width / displayedDimensions.height
: ratioValues[choice];
setRatioChoice(choice);
setAspectLocked(true);
setLockedDisplayRatio(ratio);
emitDisplayedRectangle(
fitRectangleToRatio(displayedCrop, ratio, displayedDimensions)
);
};
const toggleAspectLock = (event: ChangeEvent<HTMLInputElement>) => {
const locked = event.currentTarget.checked;
setAspectLocked(locked);
if (locked) {
setRatioChoice('current');
setLockedDisplayRatio(displayedCrop.width / displayedCrop.height);
}
};
return (
<section
className="crop-editor"
aria-label={ariaLabel}
data-orientation={orientation}
>
<div
ref={stageRef}
className="crop-editor__stage"
style={{
aspectRatio: `${displayedDimensions.width} / ${displayedDimensions.height}`,
}}
>
<div className="crop-editor__media" style={mediaStyle}>
{children}
</div>
<div className="crop-editor__shade" aria-hidden="true" />
<div className="crop-editor__rectangle" style={displayStyle}>
<button
type="button"
className="crop-editor__move"
aria-label="Move crop rectangle"
aria-keyshortcuts="ArrowUp ArrowRight ArrowDown ArrowLeft"
disabled={disabled}
onPointerDown={(event) => beginDrag(event, 'move')}
onPointerMove={continueDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={(event) => handleKeyboard(event, 'move')}
>
<span aria-hidden="true">Move</span>
</button>
{handles.map((handle) => (
<button
key={handle}
type="button"
className={`crop-editor__handle crop-editor__handle--${handle}`}
aria-label={`Resize crop from ${handleNames[handle]}`}
aria-keyshortcuts="ArrowUp ArrowRight ArrowDown ArrowLeft"
disabled={disabled}
onPointerDown={(event) => beginDrag(event, handle)}
onPointerMove={continueDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={(event) => handleKeyboard(event, handle)}
/>
))}
</div>
</div>
<div className="crop-editor__toolbar">
<label className="crop-editor__lock">
<input
type="checkbox"
checked={aspectLocked}
disabled={disabled}
onChange={toggleAspectLock}
/>
Lock aspect ratio
</label>
<label>
<span>Common ratio</span>
<select
value={ratioChoice}
disabled={disabled}
onChange={selectRatio}
>
<option value="current">Current crop</option>
<option value="source">Source display ratio</option>
<option value="16:9">16:9</option>
<option value="4:3">4:3</option>
<option value="1:1">1:1</option>
<option value="9:16">9:16</option>
</select>
</label>
<button type="button" disabled={disabled} onClick={reset}>
Reset crop
</button>
</div>
<fieldset className="crop-editor__numeric" disabled={disabled}>
<legend>Encoded source pixels</legend>
{(['x', 'y', 'width', 'height'] as const).map((field) => (
<label key={field}>
<span>
{field === 'x' || field === 'y'
? field.toUpperCase()
: capitalize(field)}
</span>
<input
type="number"
inputMode="numeric"
step={requireEvenDimensions ? 2 : 1}
min={
field === 'width' || field === 'height' ? effectiveMinimum : 0
}
max={
field === 'x' || field === 'width'
? sourceDimensions.width
: sourceDimensions.height
}
value={draft[field]}
aria-invalid={visibleErrors.length > 0}
aria-describedby={visibleErrors.length > 0 ? errorId : undefined}
onChange={(event) => updateDraft(field, event)}
/>
</label>
))}
</fieldset>
<p className="crop-editor__hint">
Display orientation: {orientation}°. Crop values are saved against the
encoded {sourceDimensions.width}×{sourceDimensions.height} source.
</p>
{visibleErrors.length > 0 ? (
<ul id={errorId} className="crop-editor__errors" role="alert">
{visibleErrors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
) : null}
</section>
);
}
function validateSourceDimensions(width: number, height: number): Dimensions {
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width <= 0 ||
height <= 0
) {
throw new RangeError(
'Crop source dimensions must be positive safe integers.'
);
}
return Object.freeze({ width, height });
}
function normalizeMinimumSize(value: number, requireEven: boolean): number {
if (!Number.isSafeInteger(value) || value < 1) {
throw new RangeError('Crop minimum size must be a positive safe integer.');
}
return requireEven && value % 2 !== 0 ? value + 1 : value;
}
function validateCropBounds(
bounds: Dimensions,
minimum: number,
requireEven: boolean
): void {
if (bounds.width < minimum || bounds.height < minimum) {
throw new RangeError(
'Crop source dimensions are smaller than the minimum crop size.'
);
}
if (
requireEven &&
(bounds.width < 2 ||
bounds.height < 2 ||
bounds.width - (bounds.width % 2) < minimum ||
bounds.height - (bounds.height % 2) < minimum)
) {
throw new RangeError(
'Crop source dimensions cannot contain the required even crop.'
);
}
}
function resolveCropValue(
value: CropSettings | undefined,
bounds: Dimensions,
minimum: number,
requireEven: boolean
): CropSettings {
const fallback = normalizeCrop(
{ x: 0, y: 0, width: bounds.width, height: bounds.height },
bounds,
minimum,
requireEven
);
if (value === undefined) return fallback;
return validateCrop(value, bounds, minimum, requireEven).length === 0
? Object.freeze({ ...value })
: fallback;
}
function normalizeCrop(
value: Rectangle,
bounds: Dimensions,
minimum: number,
requireEven: boolean
): CropSettings {
let x = Math.round(value.x);
let y = Math.round(value.y);
let width = Math.round(value.width);
let height = Math.round(value.height);
width = Math.max(minimum, Math.min(width, bounds.width));
height = Math.max(minimum, Math.min(height, bounds.height));
x = clamp(Math.round(x), 0, bounds.width - width);
y = clamp(Math.round(y), 0, bounds.height - height);
if (requireEven) {
width -= width % 2;
height -= height % 2;
x -= x % 2;
y -= y % 2;
width = Math.max(minimum, width);
height = Math.max(minimum, height);
x = Math.min(x, bounds.width - width);
y = Math.min(y, bounds.height - height);
}
return Object.freeze({ x, y, width, height });
}
function validateCrop(
crop: CropSettings,
bounds: Dimensions,
minimum: number,
requireEven: boolean
): string[] {
const errors: string[] = [];
for (const [field, value] of Object.entries(crop)) {
if (!Number.isSafeInteger(value)) {
errors.push(`${capitalize(field)} must be a whole number.`);
}
}
if (crop.x < 0 || crop.y < 0) {
errors.push('X and Y must not be negative.');
}
if (crop.width < minimum || crop.height < minimum) {
errors.push(`Width and height must be at least ${String(minimum)} pixels.`);
}
if (crop.x + crop.width > bounds.width) {
errors.push('The crop extends beyond the source width.');
}
if (crop.y + crop.height > bounds.height) {
errors.push('The crop extends beyond the source height.');
}
if (
requireEven &&
(crop.x % 2 !== 0 ||
crop.y % 2 !== 0 ||
crop.width % 2 !== 0 ||
crop.height % 2 !== 0)
) {
errors.push('X, Y, width and height must be even for this output.');
}
return errors;
}
function cropToDraft(crop: CropSettings): Record<CropField, string> {
return {
x: String(crop.x),
y: String(crop.y),
width: String(crop.width),
height: String(crop.height),
};
}
function createCropSourceKey(
crop: CropSettings,
source: Dimensions,
requireEven: boolean
): string {
return [
crop.x,
crop.y,
crop.width,
crop.height,
source.width,
source.height,
requireEven ? 1 : 0,
].join(':');
}
function validateCropDraft(
draft: Record<CropField, string>,
bounds: Dimensions,
minimum: number,
requireEven: boolean
): { crop?: CropSettings; errors: string[] } {
const values = Object.fromEntries(
Object.entries(draft).map(([field, value]) => [field, Number(value)])
) as unknown as CropSettings;
const errors = Object.values(draft).some((value) => value.trim() === '')
? ['All crop fields are required.']
: validateCrop(values, bounds, minimum, requireEven);
return errors.length === 0
? { crop: Object.freeze({ ...values }), errors }
: { errors };
}
function getDisplayedDimensions(
source: Dimensions,
orientation: Orientation
): Dimensions {
return orientation === 90 || orientation === 270
? { width: source.height, height: source.width }
: source;
}
function cropToDisplayedRectangle(
crop: CropSettings,
source: Dimensions,
orientation: Orientation
): Rectangle {
switch (orientation) {
case 90:
return {
x: source.height - crop.y - crop.height,
y: crop.x,
width: crop.height,
height: crop.width,
};
case 180:
return {
x: source.width - crop.x - crop.width,
y: source.height - crop.y - crop.height,
width: crop.width,
height: crop.height,
};
case 270:
return {
x: crop.y,
y: source.width - crop.x - crop.width,
width: crop.height,
height: crop.width,
};
default:
return { ...crop };
}
}
function displayedRectangleToCrop(
rectangle: Rectangle,
source: Dimensions,
orientation: Orientation
): Rectangle {
switch (orientation) {
case 90:
return {
x: rectangle.y,
y: source.height - rectangle.x - rectangle.width,
width: rectangle.height,
height: rectangle.width,
};
case 180:
return {
x: source.width - rectangle.x - rectangle.width,
y: source.height - rectangle.y - rectangle.height,
width: rectangle.width,
height: rectangle.height,
};
case 270:
return {
x: source.width - rectangle.y - rectangle.height,
y: rectangle.x,
width: rectangle.height,
height: rectangle.width,
};
default:
return { ...rectangle };
}
}
function rectangleToPercentStyle(
rectangle: Rectangle,
bounds: Dimensions
): React.CSSProperties {
return {
left: `${(rectangle.x / bounds.width) * 100}%`,
top: `${(rectangle.y / bounds.height) * 100}%`,
width: `${(rectangle.width / bounds.width) * 100}%`,
height: `${(rectangle.height / bounds.height) * 100}%`,
};
}
function getOrientedMediaStyle(
source: Dimensions,
displayed: Dimensions,
orientation: Orientation
): React.CSSProperties {
const rotated = orientation === 90 || orientation === 270;
return {
width: rotated ? `${(source.width / displayed.width) * 100}%` : '100%',
height: rotated ? `${(source.height / displayed.height) * 100}%` : '100%',
transform: `translate(-50%, -50%) rotate(${String(orientation)}deg)`,
};
}
function moveRectangle(
rectangle: Rectangle,
deltaX: number,
deltaY: number,
bounds: Dimensions
): Rectangle {
return {
...rectangle,
x: clamp(rectangle.x + deltaX, 0, bounds.width - rectangle.width),
y: clamp(rectangle.y + deltaY, 0, bounds.height - rectangle.height),
};
}
function resizeRectangle(
rectangle: Rectangle,
handle: ResizeHandle,
deltaX: number,
deltaY: number,
bounds: Dimensions,
minimum: number,
ratio?: number
): Rectangle {
if (ratio === undefined) {
const left = handle.includes('w')
? clamp(rectangle.x + deltaX, 0, rectangle.x + rectangle.width - minimum)
: rectangle.x;
const right = handle.includes('e')
? clamp(
rectangle.x + rectangle.width + deltaX,
rectangle.x + minimum,
bounds.width
)
: rectangle.x + rectangle.width;
const top = handle.includes('n')
? clamp(rectangle.y + deltaY, 0, rectangle.y + rectangle.height - minimum)
: rectangle.y;
const bottom = handle.includes('s')
? clamp(
rectangle.y + rectangle.height + deltaY,
rectangle.y + minimum,
bounds.height
)
: rectangle.y + rectangle.height;
return {
x: left,
y: top,
width: right - left,
height: bottom - top,
};
}
if (!Number.isFinite(ratio) || ratio <= 0) return rectangle;
const horizontal = handle.includes('w') ? -1 : handle.includes('e') ? 1 : 0;
const vertical = handle.includes('n') ? -1 : handle.includes('s') ? 1 : 0;
const minimumWidth = Math.max(minimum, minimum * ratio);
if (horizontal !== 0 && vertical !== 0) {
const anchorX =
horizontal < 0 ? rectangle.x + rectangle.width : rectangle.x;
const anchorY = vertical < 0 ? rectangle.y + rectangle.height : rectangle.y;
const movingX =
(horizontal < 0 ? rectangle.x : rectangle.x + rectangle.width) + deltaX;
const movingY =
(vertical < 0 ? rectangle.y : rectangle.y + rectangle.height) + deltaY;
const rawWidth = Math.abs(movingX - anchorX);
const rawHeight = Math.abs(movingY - anchorY);
const requestedWidth =
Math.abs(rawWidth - rectangle.width) >=
Math.abs(rawHeight * ratio - rectangle.width)
? rawWidth
: rawHeight * ratio;
const maxWidthX = horizontal < 0 ? anchorX : bounds.width - anchorX;
const maxHeightY = vertical < 0 ? anchorY : bounds.height - anchorY;
const width = clamp(
requestedWidth,
Math.min(minimumWidth, maxWidthX, maxHeightY * ratio),
Math.min(maxWidthX, maxHeightY * ratio)
);
const height = width / ratio;
return {
x: horizontal < 0 ? anchorX - width : anchorX,
y: vertical < 0 ? anchorY - height : anchorY,
width,
height,
};
}
if (horizontal !== 0) {
const anchorX =
horizontal < 0 ? rectangle.x + rectangle.width : rectangle.x;
const movingX =
(horizontal < 0 ? rectangle.x : rectangle.x + rectangle.width) + deltaX;
const centerY = rectangle.y + rectangle.height / 2;
const maxHeight = 2 * Math.min(centerY, bounds.height - centerY);
const maxWidth = Math.min(
horizontal < 0 ? anchorX : bounds.width - anchorX,
maxHeight * ratio
);
const width = clamp(
Math.abs(movingX - anchorX),
Math.min(minimumWidth, maxWidth),
maxWidth
);
const height = width / ratio;
return {
x: horizontal < 0 ? anchorX - width : anchorX,
y: centerY - height / 2,
width,
height,
};
}
const anchorY = vertical < 0 ? rectangle.y + rectangle.height : rectangle.y;
const movingY =
(vertical < 0 ? rectangle.y : rectangle.y + rectangle.height) + deltaY;
const centerX = rectangle.x + rectangle.width / 2;
const maxWidth = 2 * Math.min(centerX, bounds.width - centerX);
const maxHeight = Math.min(
vertical < 0 ? anchorY : bounds.height - anchorY,
maxWidth / ratio
);
const height = clamp(
Math.abs(movingY - anchorY),
Math.min(minimum, maxHeight),
maxHeight
);
const width = height * ratio;
return {
x: centerX - width / 2,
y: vertical < 0 ? anchorY - height : anchorY,
width,
height,
};
}
function fitRectangleToRatio(
rectangle: Rectangle,
ratio: number,
bounds: Dimensions
): Rectangle {
let width = rectangle.width;
let height = width / ratio;
if (height > rectangle.height) {
height = rectangle.height;
width = height * ratio;
}
if (width > bounds.width) {
width = bounds.width;
height = width / ratio;
}
if (height > bounds.height) {
height = bounds.height;
width = height * ratio;
}
return {
x: clamp(
rectangle.x + (rectangle.width - width) / 2,
0,
bounds.width - width
),
y: clamp(
rectangle.y + (rectangle.height - height) / 2,
0,
bounds.height - height
),
width,
height,
};
}
function arrowDelta(
key: string,
step: number
): { x: number; y: number } | undefined {
switch (key) {
case 'ArrowLeft':
return { x: -step, y: 0 };
case 'ArrowRight':
return { x: step, y: 0 };
case 'ArrowUp':
return { x: 0, y: -step };
case 'ArrowDown':
return { x: 0, y: step };
default:
return undefined;
}
}
function capitalize(value: string): string {
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
}
function clamp(value: number, minimum: number, maximum: number): number {
if (maximum < minimum) return maximum;
return Math.min(Math.max(value, minimum), maximum);
}
+81
View File
@@ -0,0 +1,81 @@
import { useRef, useState, type DragEvent } from 'react';
import { Icon } from './Icon';
export interface FileDropZoneProps {
onFiles: (files: File[]) => void;
compact?: boolean;
disabled?: boolean;
}
export function FileDropZone({
onFiles,
compact = false,
disabled = false,
}: FileDropZoneProps) {
const input = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const receive = (files: FileList | null) => {
const selected = files ? [...files].filter((file) => file.size > 0) : [];
if (selected.length > 0) onFiles(selected);
if (input.current) input.current.value = '';
};
const drop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setDragging(false);
if (!disabled) receive(event.dataTransfer.files);
};
return (
<div
className={[
'drop-zone',
compact ? 'drop-zone--compact' : '',
dragging ? 'drop-zone--active' : '',
]
.filter(Boolean)
.join(' ')}
onDragEnter={(event) => {
event.preventDefault();
if (!disabled) setDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setDragging(false);
}
}}
onDrop={drop}
>
<input
ref={input}
className="visually-hidden"
type="file"
accept="audio/*,video/*,.mkv,.mka,.flac,.wav,.ogg,.opus"
multiple
disabled={disabled}
onChange={(event) => receive(event.currentTarget.files)}
/>
<div className="drop-zone__icon">
<Icon name="add" />
</div>
<div>
<strong>{compact ? 'Add media' : 'Drop audio or video here'}</strong>
<span>
{compact
? 'Files stay on this device'
: 'Any FFmpeg-supported local file · nothing is uploaded'}
</span>
</div>
<button
className="button button--secondary"
type="button"
disabled={disabled}
onClick={() => input.current?.click()}
>
Choose files
</button>
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { useEffect, useRef } from 'react';
import applicationLicenseUrl from '../../LICENSE?url&no-inline';
import ffmpegWasmLicenseUrl from '../../LICENSES/ffmpeg.wasm-MIT.txt?url&no-inline';
import gplLicenseUrl from '../../LICENSES/GPL-2.0-or-later.txt?url&no-inline';
import licenseStatusUrl from '../../LICENSES/README.md?url&no-inline';
import sourceRecordUrl from '../../SOURCE.md?url&no-inline';
import thirdPartyNoticesUrl from '../../THIRD_PARTY_NOTICES.md?url&no-inline';
import {
APP_VERSION,
FFMPEG_CORE_VERSION,
FFMPEG_ENGINE_VERSION,
FFMPEG_UTIL_VERSION,
FFMPEG_WRAPPER_VERSION,
} from '../version';
import { Icon } from './Icon';
export interface HelpDialogProps {
open: boolean;
onClose: () => void;
}
export function HelpDialog({ open, onClose }: HelpDialogProps) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (open && !element.open) element.showModal();
if (!open && element.open) element.close();
}, [open]);
return (
<dialog
ref={dialog}
className="modal"
aria-labelledby="help-title"
onClose={onClose}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="modal__panel">
<header>
<div>
<p className="eyebrow">Local media studio</p>
<h2 id="help-title">Audio & Video Tools</h2>
</div>
<button
className="icon-action"
type="button"
aria-label="Close help"
onClick={onClose}
>
<Icon name="cancel" />
</button>
</header>
<div className="help-grid">
<section>
<span className="step-number">1</span>
<h3>Add media</h3>
<p>
Pick or drop local files. The browser gives FFmpeg direct,
read-only access when WORKERFS is available. If it must use the
bounded memory-copy fallback, the job log reports the exact copied
byte count.
</p>
</section>
<section>
<span className="step-number">2</span>
<h3>Choose an operation</h3>
<p>
Quick Convert produces one output. Edit arranges non-destructive
clips on a sequential timeline.
</p>
</section>
<section>
<span className="step-number">3</span>
<h3>Export locally</h3>
<p>
The FFmpeg WebAssembly core runs here. Source media is never
uploaded and the result is saved by your browser.
</p>
</section>
</div>
<section className="about-licenses" aria-labelledby="licenses-title">
<header>
<div>
<p className="eyebrow">Versions, source and legal context</p>
<h3 id="licenses-title">About &amp; licences</h3>
</div>
<span>av-tools {APP_VERSION}</span>
</header>
<dl className="about-version-grid">
<div>
<dt>FFmpeg engine</dt>
<dd>{FFMPEG_ENGINE_VERSION}</dd>
</div>
<div>
<dt>ffmpeg.wasm wrapper</dt>
<dd>{FFMPEG_WRAPPER_VERSION}</dd>
</div>
<div>
<dt>FFmpeg WASM cores</dt>
<dd>{FFMPEG_CORE_VERSION} · ST &amp; MT</dd>
</div>
<div>
<dt>ffmpeg.wasm utilities</dt>
<dd>{FFMPEG_UTIL_VERSION}</dd>
</div>
</dl>
<div className="about-license-grid">
<section>
<h4>Application licence</h4>
<p>
<strong>
Copyright © 2026 Albrecht Degering. GPL-3.0-or-later.
</strong>{' '}
This program comes with absolutely no warranty. You may
redistribute and modify it under GPL version 3 or, at your
option, a later version. Third-party components retain their own
notices and terms.
</p>
<div className="about-link-list">
<a
href={applicationLicenseUrl}
target="_blank"
rel="noreferrer"
>
Application GPL
</a>
<a href={licenseStatusUrl} target="_blank" rel="noreferrer">
Licence guide
</a>
<a href={sourceRecordUrl} target="_blank" rel="noreferrer">
Source &amp; reproducibility record
</a>
</div>
</section>
<section>
<h4>Bundled runtime notices</h4>
<ul>
<li>
ffmpeg.wasm wrapper and utilities MIT
<a
href={ffmpegWasmLicenseUrl}
target="_blank"
rel="noreferrer"
>
MIT text
</a>
</li>
<li>
FFmpeg single- and multithread cores package declares
GPL-2.0-or-later
<a href={gplLicenseUrl} target="_blank" rel="noreferrer">
GPL text
</a>
</li>
<li>
FFmpeg, linked codecs, Toolbox SDK, React and other runtime
packages retain their respective terms.
<a
href={thirdPartyNoticesUrl}
target="_blank"
rel="noreferrer"
>
Complete inventory
</a>
</li>
</ul>
</section>
</div>
</section>
<aside className="privacy-callout">
<Icon name="check" />
<div>
<strong>Private by architecture</strong>
<p>
No backend, analytics, third-party scripts, remote fonts, or
automatic remote media requests. Source files stay inside the
browser session and are not persisted by the app; only regenerable
derivatives may use local browser storage.
</p>
</div>
</aside>
<footer>
<a
href="https://git.add-ideas.de/zemion/av-tools"
target="_blank"
rel="noreferrer"
>
View source
</a>
<button className="button button--primary" onClick={onClose}>
Start working
</button>
</footer>
</div>
</dialog>
);
}
+115
View File
@@ -0,0 +1,115 @@
import type { SVGProps } from 'react';
export type IconName =
| 'add'
| 'audio'
| 'cancel'
| 'check'
| 'chevron'
| 'download'
| 'edit'
| 'file'
| 'info'
| 'play'
| 'queue'
| 'settings'
| 'sparkles'
| 'trash'
| 'video'
| 'warning';
const paths: Record<IconName, React.ReactNode> = {
add: <path d="M12 5v14M5 12h14" />,
audio: (
<>
<path d="M9 18V5l10-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="16" cy="16" r="3" />
</>
),
cancel: <path d="m6 6 12 12M18 6 6 18" />,
check: <path d="m5 12 4 4L19 6" />,
chevron: <path d="m9 18 6-6-6-6" />,
download: (
<>
<path d="M12 3v12m0 0 4-4m-4 4-4-4" />
<path d="M5 20h14" />
</>
),
edit: (
<>
<path d="M4 20h4l11-11-4-4L4 16v4Z" />
<path d="m13.5 6.5 4 4" />
</>
),
file: (
<>
<path d="M6 2h8l4 4v16H6z" />
<path d="M14 2v5h5" />
</>
),
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v6m0-10h.01" />
</>
),
play: <path d="m8 5 11 7-11 7z" />,
queue: (
<>
<path d="M4 6h12M4 12h12M4 18h8" />
<path d="m17 16 3 2-3 2z" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.6v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
</>
),
sparkles: (
<>
<path d="m12 3 1.2 3.8L17 8l-3.8 1.2L12 13l-1.2-3.8L7 8l3.8-1.2z" />
<path d="m18 14 .8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8z" />
<path d="m5 12 .7 1.8 1.8.7-1.8.7L5 17l-.7-1.8-1.8-.7 1.8-.7z" />
</>
),
trash: (
<>
<path d="M4 7h16M9 3h6l1 4H8zM7 7l1 14h8l1-14" />
<path d="M10 11v6m4-6v6" />
</>
),
video: (
<>
<rect x="3" y="5" width="14" height="14" rx="2" />
<path d="m17 10 4-2v8l-4-2z" />
</>
),
warning: (
<>
<path d="M12 3 2.7 20h18.6z" />
<path d="M12 9v5m0 3h.01" />
</>
),
};
export function Icon({
name,
...props
}: { name: IconName } & SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
{paths[name]}
</svg>
);
}
File diff suppressed because it is too large Load Diff
+495
View File
@@ -0,0 +1,495 @@
import { useEffect, useRef, useState } from 'react';
import { formatBytes, formatDuration } from '../app/application-state';
import type { ExportReport, ExportResult } from '../export';
import type { EngineState } from '../ffmpeg/ffmpeg.types';
import type { MediaJob } from '../jobs';
import {
assessGeneratedResultPreview,
formatTimecode,
type MediaProbe,
} from '../media';
import { Icon } from './Icon';
export interface ResultVerification {
readonly probe?: MediaProbe;
readonly warning?: string;
}
export interface JobPanelProps {
engineState: EngineState;
jobs: readonly MediaJob[];
results: readonly ExportResult[];
selectedResultId?: string;
verifications: Readonly<Record<string, ResultVerification>>;
report?: ExportReport;
logs: readonly string[];
error?: string;
queuedCount?: number;
onCancel: () => void;
onCancelJob: (job: MediaJob) => void;
onRetryJob: (job: MediaJob) => void;
onClearJobHistory: () => void;
onSave: (result: ExportResult) => void;
onSaveZip: (results: readonly ExportResult[]) => void;
onSaveReport: () => void;
onPreview: (result: ExportResult) => void;
onRemove: (result: ExportResult) => void;
onClearResults: () => void;
}
export function JobPanel({
engineState,
jobs,
results,
selectedResultId,
verifications,
report,
logs,
error,
queuedCount = 0,
onCancel,
onCancelJob,
onRetryJob,
onClearJobHistory,
onSave,
onSaveZip,
onSaveReport,
onPreview,
onRemove,
onClearResults,
}: JobPanelProps) {
const [selectedResultIds, setSelectedResultIds] = useState<
ReadonlySet<string>
>(() => new Set());
const knownResultIds = useRef<ReadonlySet<string>>(new Set());
useEffect(() => {
setSelectedResultIds((current) => {
const next = new Set(
results
.filter(
(result) =>
current.has(result.id) || !knownResultIds.current.has(result.id)
)
.map((result) => result.id)
);
knownResultIds.current = new Set(results.map((result) => result.id));
return next;
});
}, [results]);
const running = engineState.status === 'running';
if (
!running &&
queuedCount === 0 &&
jobs.length === 0 &&
results.length === 0 &&
!error &&
logs.length === 0
) {
return null;
}
return (
<section className="panel job-panel" aria-labelledby="jobs-title">
<header className="panel__header">
<div>
<p className="eyebrow">Local FFmpeg queue</p>
<h2 id="jobs-title">
{running
? 'Processing one operation'
: results.length === 1
? 'Current output'
: 'Current outputs'}
</h2>
</div>
{queuedCount > 0 ? (
<span className="job-panel__queue-count" role="status">
{queuedCount} waiting
</span>
) : null}
{running ? (
<button
className="button button--danger"
type="button"
onClick={onCancel}
>
<Icon name="cancel" />
Cancel & recover
</button>
) : null}
</header>
<div className="job-panel__body" aria-live="polite">
{queuedCount > 0 ? (
<p className="job-queue-note">
Additional operations are retained in memory and start one at a
time. Closing this page discards waiting source handles.
</p>
) : null}
{running ? (
<div className="job-progress">
<div>
<strong>{engineState.operation}</strong>
<span>
FFmpeg progress is an estimate; completion requires exit code
zero.
</span>
</div>
<span>
{engineState.progress === undefined
? 'Working…'
: `${Math.round(engineState.progress * 100)}%`}
{engineState.elapsedSeconds !== undefined
? ` · ${Math.round(engineState.elapsedSeconds)} s`
: ''}
{engineState.speed !== undefined
? ` · ${engineState.speed.toFixed(2)}×`
: ''}
</span>
<progress value={engineState.progress} max="1" />
</div>
) : null}
{jobs.length > 0 ? (
<section className="job-history" aria-labelledby="job-history-title">
<header>
<h3 id="job-history-title">Job history</h3>
{jobs.some((job) => isTerminalJob(job)) ? (
<button
className="button button--quiet"
type="button"
onClick={onClearJobHistory}
>
Clear finished
</button>
) : null}
</header>
<div className="job-history__list">
{jobs.map((job) => (
<article className="job-record" key={job.id}>
<div className="job-record__summary">
<div>
<strong>{job.operation}</strong>
<span>
{job.status.replace('-', ' ')}
{job.startedAt
? ` · ${formatJobTime(job.startedAt)}`
: ''}
</span>
</div>
<b>{Math.round(job.progress * 100)}%</b>
{job.status === 'queued' ? (
<button
className="button button--quiet"
type="button"
onClick={() => onCancelJob(job)}
>
Cancel waiting
</button>
) : null}
{job.status === 'failed' || job.status === 'cancelled' ? (
<button
className="button button--quiet"
type="button"
onClick={() => onRetryJob(job)}
>
Retry
</button>
) : null}
</div>
<progress value={job.progress} max="1" />
{job.error ? (
<small className="job-record__error">
{job.error.message}
</small>
) : null}
{job.steps.length > 1 ? (
<details>
<summary>{job.steps.length} local steps</summary>
<ol>
{job.steps.map((step) => (
<li key={step.id}>
<span>{step.name}</span>
<span>
{step.status} · {Math.round(step.progress * 100)}%
</span>
</li>
))}
</ol>
</details>
) : null}
</article>
))}
</div>
</section>
) : null}
{error ? (
<div className="notice notice--error" role="alert">
<Icon name="warning" />
<div>
<strong>Output failed</strong>
<span>{error}</span>
</div>
</div>
) : null}
{results.length > 0 ? (
<>
{results.length > 1 ? (
<label className="result-select-all">
<input
type="checkbox"
checked={selectedResultIds.size === results.length}
onChange={(event) =>
setSelectedResultIds(
event.currentTarget.checked
? new Set(results.map((result) => result.id))
: new Set()
)
}
/>
Select all generated results for ZIP
</label>
) : null}
<div className="result-list">
{results.map((result) => {
const verification = verifications[result.id];
const preview = generatedResultPreview(
result,
verification?.probe
);
return (
<article
className={`result-card${selectedResultId === result.id ? ' is-selected' : ''}`}
key={result.id}
>
{results.length > 1 ? (
<input
className="result-card__select"
type="checkbox"
aria-label={`Select ${result.fileName} for ZIP`}
checked={selectedResultIds.has(result.id)}
onChange={(event) => {
const checked = event.currentTarget.checked;
setSelectedResultIds((current) => {
const next = new Set(current);
if (checked) next.add(result.id);
else next.delete(result.id);
return next;
});
}}
/>
) : null}
<span className="result-card__icon">
<Icon name="check" />
</span>
<div>
<strong>{result.fileName}</strong>
<span>
{formatBytes(result.size)} ·{' '}
{verification?.warning
? 'created · verification warning'
: verification?.probe
? 'created · verified'
: 'completed locally'}
{verification?.probe?.durationSeconds !== undefined
? ` · ${formatDuration(verification.probe.durationSeconds)}`
: ''}
{verification?.probe
? ` · ${verification.probe.streams.length} verified stream${verification.probe.streams.length === 1 ? '' : 's'}`
: ''}
</span>
{result.timeRange ? (
<small className="result-card__range">
Segment range{' '}
{formatTimecode(result.timeRange.startSeconds)}
{formatTimecode(result.timeRange.endSeconds)} ·{' '}
{formatTimecode(
result.timeRange.endSeconds -
result.timeRange.startSeconds
)}{' '}
duration
</small>
) : null}
{verification?.warning ? (
<small className="result-card__warning">
{verification.warning}
</small>
) : null}
{!preview.previewable && preview.kind !== 'other' ? (
<small className="result-card__warning">
Download only: {preview.reason}
</small>
) : null}
{verification?.probe ? (
<details className="result-verification">
<summary>Round-trip probe details</summary>
<dl>
<div>
<dt>Container tags</dt>
<dd>
{formatTagSummary(verification.probe.tags)}
</dd>
</div>
<div>
<dt>Chapters</dt>
<dd>
{verification.probe.chapters.length === 0
? 'None'
: verification.probe.chapters
.map(
(chapter) =>
chapter.title ??
`Chapter ${chapter.id + 1}`
)
.join(', ')}
</dd>
</div>
{verification.probe.streams.map((stream) => (
<div key={stream.index}>
<dt>
{stream.type} #{stream.index}
</dt>
<dd>
{stream.codecName ?? 'unknown codec'}
{stream.language
? ` · ${stream.language}`
: ''}
{stream.title ? ` · ${stream.title}` : ''}
{` · ${formatTagSummary(stream.tags)}`}
{` · disposition: ${enabledDispositions(
stream.disposition
)}`}
</dd>
</div>
))}
</dl>
</details>
) : null}
</div>
{preview.previewable ? (
<button
className="button button--quiet"
type="button"
aria-pressed={selectedResultId === result.id}
onClick={() => onPreview(result)}
>
Preview
</button>
) : null}
<button
className="button button--primary"
type="button"
aria-label={
results.length === 1
? 'Save result'
: `Save ${result.fileName}`
}
onClick={() => onSave(result)}
>
<Icon name="download" />
{results.length === 1 ? 'Save result' : 'Save'}
</button>
<button
className="icon-action"
type="button"
aria-label={`Remove ${result.fileName}`}
onClick={() => onRemove(result)}
>
<Icon name="trash" />
</button>
</article>
);
})}
</div>
<div className="result-actions">
{results.length > 1 ? (
<button
className="button button--secondary"
type="button"
disabled={selectedResultIds.size === 0}
onClick={() =>
onSaveZip(
results.filter((result) =>
selectedResultIds.has(result.id)
)
)
}
>
<Icon name="download" />
Download selected as ZIP ({selectedResultIds.size})
</button>
) : null}
{report ? (
<button
className="button button--quiet"
type="button"
onClick={onSaveReport}
>
Export validation report
</button>
) : null}
<button
className="button button--quiet"
type="button"
onClick={onClearResults}
>
Clear generated results
</button>
</div>
</>
) : null}
{logs.length > 0 ? (
<details className="diagnostics">
<summary>FFmpeg diagnostics ({logs.length} recent lines)</summary>
<pre>{logs.join('\n')}</pre>
</details>
) : null}
</div>
</section>
);
}
function generatedResultPreview(
result: ExportResult,
probe: MediaProbe | undefined
) {
const element = document.createElement(
result.mimeType.startsWith('video/') ? 'video' : 'audio'
);
return assessGeneratedResultPreview(
{
fileName: result.fileName,
mimeType: result.mimeType,
...(probe ? { probe } : {}),
},
(mimeType) => element.canPlayType(mimeType)
);
}
function isTerminalJob(job: MediaJob): boolean {
return (
job.status === 'completed' ||
job.status === 'failed' ||
job.status === 'cancelled'
);
}
function formatJobTime(value: string): string {
const date = new Date(value);
return Number.isNaN(date.valueOf())
? value
: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatTagSummary(tags: Readonly<Record<string, string>>): string {
const entries = Object.entries(tags);
return entries.length === 0
? 'None reported'
: entries.map(([key, value]) => `${key}=${value}`).join(' · ');
}
function enabledDispositions(
dispositions: Readonly<Record<string, boolean>>
): string {
const enabled = Object.entries(dispositions)
.filter(([, value]) => value)
.map(([key]) => key);
return enabled.length > 0 ? enabled.join(', ') : 'none';
}
+83
View File
@@ -0,0 +1,83 @@
import type { ImportedMediaAsset } from '../app/application-state';
import { formatBytes, formatDuration } from '../app/application-state';
import { Icon } from './Icon';
export interface MediaBinProps {
assets: readonly ImportedMediaAsset[];
selectedId?: string;
onSelect: (id: string) => void;
onRemove: (id: string) => void;
}
export function MediaBin({
assets,
selectedId,
onSelect,
onRemove,
}: MediaBinProps) {
if (assets.length === 0) {
return (
<div className="media-bin__empty">
<Icon name="video" />
<p>Your source files will appear here.</p>
</div>
);
}
return (
<ul className="media-list" aria-label="Imported media">
{assets.map((asset) => {
const video = asset.probe?.streams.find(
(stream) => stream.type === 'video'
);
const audioCount =
asset.probe?.streams.filter((stream) => stream.type === 'audio')
.length ?? 0;
return (
<li key={asset.id}>
<button
type="button"
className={[
'media-card',
selectedId === asset.id ? 'media-card--selected' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => onSelect(asset.id)}
>
<span className="media-card__type">
<Icon name={video ? 'video' : 'audio'} />
</span>
<span className="media-card__content">
<strong title={asset.file.name}>{asset.file.name}</strong>
<small>
{formatBytes(asset.file.size)}
{asset.probe?.durationSeconds !== undefined
? ` · ${formatDuration(asset.probe.durationSeconds)}`
: ''}
</small>
<small className={`phase phase--${asset.phase}`}>
{asset.phase === 'probing'
? 'Inspecting locally…'
: asset.phase === 'ready'
? `${video ? `${video.width ?? '?'}×${video.height ?? '?'}` : 'Audio'} · ${audioCount} audio`
: asset.phase === 'error'
? asset.error
: 'Queued'}
</small>
</span>
</button>
<button
className="icon-action"
type="button"
aria-label={`Remove ${asset.file.name}`}
onClick={() => onRemove(asset.id)}
>
<Icon name="trash" />
</button>
</li>
);
})}
</ul>
);
}
+334
View File
@@ -0,0 +1,334 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ImportedMediaAsset } from '../app/application-state';
import {
assessBrowserPlayback,
assessGeneratedResultPreview,
type MediaProbe,
} from '../media';
import { renderWaveformToCanvas, type WaveformPeakData } from '../waveform';
import { Icon } from './Icon';
export interface PreviewExportResult {
readonly name: string;
readonly mimeType: string;
readonly objectUrl: string;
readonly probe?: MediaProbe;
}
export interface PreviewProxySettings {
readonly maxDurationSeconds: number;
readonly maxWidth: number;
}
export interface MediaPreviewProps {
asset?: ImportedMediaAsset;
result?: PreviewExportResult;
onCreateProxy?: () => void;
proxySettings?: PreviewProxySettings;
onProxySettingsChange?: (settings: PreviewProxySettings) => void;
proxyBusy?: boolean;
waveformPeaks?: WaveformPeakData;
currentTimeSeconds?: number;
onCurrentTimeChange?: (timeSeconds: number) => void;
}
export function MediaPreview({
asset,
result,
onCreateProxy,
proxySettings,
onProxySettingsChange,
proxyBusy = false,
waveformPeaks,
currentTimeSeconds,
onCurrentTimeChange,
}: MediaPreviewProps) {
const [failedSourceUrl, setFailedSourceUrl] = useState<string>();
const mediaElement = useRef<HTMLMediaElement>(null);
const sourceUrl = result?.objectUrl ?? asset?.objectUrl;
const playbackFailed = sourceUrl === failedSourceUrl;
const hasVideo = result
? result.mimeType.startsWith('video/')
: asset?.probe?.streams.some(
(stream) =>
stream.type === 'video' && stream.disposition.attached_pic !== true
) === true;
const hasAudio = result
? result.mimeType.startsWith('audio/')
: asset?.probe?.streams.some((stream) => stream.type === 'audio') === true;
const hasImage = result?.mimeType.startsWith('image/') === true;
const resultPlayback = useMemo(() => {
if (!result) return undefined;
const element = document.createElement(
result.mimeType.startsWith('video/') ? 'video' : 'audio'
);
return assessGeneratedResultPreview(
{
fileName: result.name,
mimeType: result.mimeType,
...(result.probe ? { probe: result.probe } : {}),
},
(mimeType) => element.canPlayType(mimeType)
);
}, [result]);
const playback = useMemo(() => {
if (!asset?.probe) return undefined;
const element = document.createElement(hasVideo ? 'video' : 'audio');
return assessBrowserPlayback(
asset.probe,
(mimeType) => element.canPlayType(mimeType),
{
fileName: asset.file.name,
declaredMimeType: asset.file.type,
}
);
}, [asset, hasVideo]);
useEffect(() => {
const element = mediaElement.current;
if (
element &&
currentTimeSeconds !== undefined &&
Number.isFinite(currentTimeSeconds) &&
Math.abs(element.currentTime - currentTimeSeconds) > 0.05
) {
element.currentTime = Math.max(0, currentTimeSeconds);
}
}, [currentTimeSeconds, sourceUrl]);
const reportCurrentTime = () => {
const value = mediaElement.current?.currentTime;
if (value !== undefined && Number.isFinite(value)) {
onCurrentTimeChange?.(value);
}
};
if (!sourceUrl) {
return (
<div className="preview-empty">
<div className="preview-empty__graphic" aria-hidden="true">
<span />
<Icon name="play" />
</div>
<p>Select a source to inspect and preview it.</p>
</div>
);
}
const needsProxy =
!result &&
(playbackFailed || playback?.support === 'unsupported') &&
onCreateProxy;
return (
<div className="preview">
<div className="preview__stage">
{result && resultPlayback?.previewable === false ? (
<div className="preview-unsupported" role="status">
<Icon name="download" />
<p>Download only: this browser cannot preview this result.</p>
<small>{resultPlayback.reason}</small>
</div>
) : hasImage && result ? (
<img src={sourceUrl} alt={`Generated result: ${result.name}`} />
) : hasVideo ? (
<video
ref={(element) => {
mediaElement.current = element;
}}
key={sourceUrl}
controls
playsInline
preload="metadata"
src={sourceUrl}
onError={() => setFailedSourceUrl(sourceUrl)}
onLoadedMetadata={reportCurrentTime}
onTimeUpdate={reportCurrentTime}
/>
) : hasAudio ? (
<div className="audio-preview">
{!result && waveformPeaks ? (
<AudioWaveformPreview
peaks={waveformPeaks}
currentTimeSeconds={currentTimeSeconds}
sourceName={asset?.file.name ?? 'audio source'}
/>
) : (
<div className="audio-preview__disc">
<Icon name="audio" />
</div>
)}
<audio
ref={(element) => {
mediaElement.current = element;
}}
key={sourceUrl}
controls
preload="metadata"
src={sourceUrl}
onError={() => setFailedSourceUrl(sourceUrl)}
onLoadedMetadata={reportCurrentTime}
onTimeUpdate={reportCurrentTime}
/>
</div>
) : (
<div className="preview-unsupported">
<Icon name="file" />
<p>This derivative has no browser preview.</p>
</div>
)}
</div>
<div className="preview__caption">
<span className="status-dot status-dot--local" />
<span>
{result
? resultPlayback?.previewable === false
? `Download only · ${result.name}`
: `Generated result · ${result.name}`
: (playback?.reason ?? 'Local source preview')}
</span>
{needsProxy && !proxySettings ? (
<button type="button" className="text-button" onClick={onCreateProxy}>
Create short preview proxy
</button>
) : null}
</div>
{needsProxy && proxySettings ? (
<div
className={`preview-proxy-controls${hasVideo ? '' : ' preview-proxy-controls--audio'}`}
>
<div>
<strong>Create a disposable preview proxy</strong>
<span>
{hasVideo
? 'H.264/AAC MP4, cached locally by these bounded settings.'
: 'AAC/M4A, cached locally by these bounded settings.'}
</span>
</div>
<label>
<span>Maximum seconds</span>
<input
type="number"
min="1"
max="300"
step="1"
value={proxySettings.maxDurationSeconds}
disabled={proxyBusy}
onChange={(event) => {
const value = Number(event.currentTarget.value);
if (Number.isFinite(value)) {
onProxySettingsChange?.({
...proxySettings,
maxDurationSeconds: Math.min(300, Math.max(1, value)),
});
}
}}
/>
</label>
{hasVideo ? (
<label>
<span>Maximum width</span>
<select
value={proxySettings.maxWidth}
disabled={proxyBusy}
onChange={(event) =>
onProxySettingsChange?.({
...proxySettings,
maxWidth: Number(event.currentTarget.value),
})
}
>
<option value="480">480 px</option>
<option value="720">720 px</option>
<option value="960">960 px</option>
<option value="1280">1280 px</option>
<option value="1920">1920 px</option>
</select>
</label>
) : null}
<button
type="button"
className="button button--secondary"
disabled={proxyBusy}
onClick={onCreateProxy}
>
<Icon name={proxyBusy ? 'queue' : 'play'} />
{proxyBusy ? 'Queued or running…' : 'Create proxy'}
</button>
</div>
) : null}
</div>
);
}
interface AudioWaveformPreviewProps {
readonly peaks: WaveformPeakData;
readonly currentTimeSeconds?: number;
readonly sourceName: string;
}
function AudioWaveformPreview({
peaks,
currentTimeSeconds,
sourceName,
}: AudioWaveformPreviewProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const [renderRevision, setRenderRevision] = useState(0);
useEffect(() => {
const stage = stageRef.current;
if (stage === null || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() =>
setRenderRevision((revision) => revision + 1)
);
observer.observe(stage);
return () => observer.disconnect();
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (canvas === null) return;
const style = getComputedStyle(canvas);
try {
renderWaveformToCanvas(canvas, peaks, {
width: Math.max(1, Math.round(canvas.clientWidth || 640)),
height: 112,
devicePixelRatio: Math.min(
4,
Math.max(1, window.devicePixelRatio || 1)
),
waveColor:
style.getPropertyValue('--audio-waveform-wave').trim() || '#6fd7ca',
backgroundColor:
style.getPropertyValue('--audio-waveform-background').trim() ||
'#101b22',
centerLineColor:
style.getPropertyValue('--audio-waveform-center').trim() ||
'rgba(255, 255, 255, 0.22)',
accessibleLabel: `Bounded waveform preview for ${sourceName}.`,
});
} catch {
// Audio controls remain usable if Canvas is unavailable.
}
}, [peaks, renderRevision, sourceName]);
const playheadFraction =
currentTimeSeconds === undefined || peaks.durationSeconds <= 0
? undefined
: Math.min(1, Math.max(0, currentTimeSeconds / peaks.durationSeconds));
return (
<div className="audio-preview__waveform" ref={stageRef}>
<canvas ref={canvasRef} />
{playheadFraction !== undefined ? (
<span
className="audio-preview__playhead"
style={{ left: `${String(playheadFraction * 100)}%` }}
aria-hidden="true"
/>
) : null}
</div>
);
}
+541
View File
@@ -0,0 +1,541 @@
import { useMemo, useState } from 'react';
import type { ImportedMediaAsset } from '../app/application-state';
import type { EngineState } from '../ffmpeg/ffmpeg.types';
import type { MediaStreamProbe } from '../media';
import type { StreamSelection } from '../commands/command-utils';
import {
validateRemuxCompatibility,
type RemuxStream,
} from '../commands/remux';
import {
requirementsForUserPreset,
type ExportPreset,
} from '../commands/convert';
import {
BUILT_IN_PRESETS,
presetAvailability,
} from '../presets/preset-registry';
import { formatBytes, formatDuration } from '../app/application-state';
import { Icon } from './Icon';
export type QuickOperation = 'convert' | 'remux';
export interface QuickExportConfiguration {
operation: QuickOperation;
preset?: ExportPreset;
remuxContainer?: 'mp4' | 'webm' | 'matroska' | 'ogg';
streamSelection: StreamSelection;
removeMetadata: boolean;
removeChapters: boolean;
}
export interface QuickConvertProps {
asset?: ImportedMediaAsset;
engineState: EngineState;
busy: boolean;
queueing?: boolean;
presets?: readonly ExportPreset[];
onExport: (configuration: QuickExportConfiguration) => void;
}
export function QuickConvert({
asset,
engineState,
busy,
queueing = false,
presets = BUILT_IN_PRESETS,
onExport,
}: QuickConvertProps) {
const [operation, setOperation] = useState<QuickOperation>('convert');
const [presetId, setPresetId] = useState('mp4-h264-balanced');
const [remuxContainer, setRemuxContainer] =
useState<QuickExportConfiguration['remuxContainer']>('matroska');
const [removeMetadata, setRemoveMetadata] = useState(false);
const [removeChapters, setRemoveChapters] = useState(false);
const [streamOverride, setStreamOverride] = useState<{
readonly assetId: string;
readonly indices: ReadonlySet<number>;
}>();
const preset = presets.find((entry) => entry.id === presetId);
const streams = useMemo(() => asset?.probe?.streams ?? [], [asset?.probe]);
const requestedStreams = useMemo(
() =>
streamOverride !== undefined &&
asset !== undefined &&
streamOverride.assetId === asset.id
? streamOverride.indices
: new Set(streams.map((stream) => stream.index)),
[asset, streamOverride, streams]
);
const streamUnavailableReasons = useMemo(
() =>
new Map(
streams.flatMap((stream) => {
const reason = quickStreamUnavailableReason(
stream,
operation,
preset
);
return reason ? ([[stream.index, reason] as const] as const) : [];
})
),
[operation, preset, streams]
);
const selectedStreams = useMemo(
() =>
new Set(
[...requestedStreams].filter(
(index) => !streamUnavailableReasons.has(index)
)
),
[requestedStreams, streamUnavailableReasons]
);
const effectiveSelection = useMemo(() => {
return {
video: streams
.filter(
(stream) =>
stream.type === 'video' && selectedStreams.has(stream.index)
)
.map((stream) => stream.index),
audio: streams
.filter(
(stream) =>
stream.type === 'audio' && selectedStreams.has(stream.index)
)
.map((stream) => stream.index),
subtitles: streams
.filter(
(stream) =>
stream.type === 'subtitle' && selectedStreams.has(stream.index)
)
.map((stream) => stream.index),
attachments: streams
.filter(
(stream) =>
stream.type === 'attachment' && selectedStreams.has(stream.index)
)
.map((stream) => stream.index),
data: streams
.filter(
(stream) =>
stream.type === 'data' && selectedStreams.has(stream.index)
)
.map((stream) => stream.index),
};
}, [selectedStreams, streams]);
const presetRequirements =
preset && 'builtIn' in preset
? preset.requirements
: preset
? requirementsForUserPreset(preset)
: undefined;
const availability =
presetRequirements && engineState.status === 'ready'
? presetAvailability(
{ requirements: presetRequirements },
engineState.capabilities
)
: undefined;
const opusNeedsRegressionTest = preset?.audio?.codec === 'libopus';
const presetNeedsMissingAudio =
preset?.kind === 'audio' && effectiveSelection.audio.length === 0;
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
stream.type === 'video' ||
stream.type === 'audio' ||
stream.type === 'subtitle' ||
stream.type === 'attachment' ||
stream.type === 'data'
? [
{
index: stream.index,
kind: stream.type,
codec: stream.codecName ?? 'unknown',
},
]
: []
);
const remuxDiagnostics = remuxContainer
? validateRemuxCompatibility(
{
streams: remuxStreams,
hasChapters: Boolean(asset?.probe?.chapters.length),
},
remuxContainer,
effectiveSelection
)
: [];
const remuxErrors = remuxDiagnostics.filter(
(diagnostic) => diagnostic.severity === 'error'
);
const remuxMuxerMissing =
remuxContainer !== undefined &&
engineState.status === 'ready' &&
!engineState.capabilities.muxers.has(remuxContainer);
const remuxUnavailableReason = remuxMuxerMissing
? `The loaded core is missing muxer ${remuxContainer}.`
: remuxErrors.map((diagnostic) => diagnostic.message).join(' ');
const hasSelectedStream = selectedStreams.size > 0;
const canExport =
asset?.phase === 'ready' &&
!busy &&
hasSelectedStream &&
(operation === 'remux'
? !remuxMuxerMissing && remuxErrors.length === 0
: preset !== undefined &&
availability?.status !== 'unavailable' &&
!opusNeedsRegressionTest &&
!presetNeedsMissingAudio);
const presetOption = (entry: ExportPreset) => {
const requirements =
'builtIn' in entry
? entry.requirements
: requirementsForUserPreset(entry);
const entryAvailability =
engineState.status === 'ready'
? presetAvailability({ requirements }, engineState.capabilities)
: undefined;
const reason =
entry.audio?.codec === 'libopus'
? 'Opus awaits a pinned-core regression test'
: entryAvailability?.status === 'unavailable'
? entryAvailability.reasons.join(', ')
: undefined;
return {
disabled: reason !== undefined,
label: reason ? `${entry.name} — unavailable: ${reason}` : entry.name,
};
};
return (
<section className="panel quick-panel" aria-labelledby="quick-title">
<header className="panel__header">
<div>
<p className="eyebrow">One source · one output</p>
<h2 id="quick-title">Quick Convert</h2>
</div>
<span className="privacy-badge">
<span />
Local processing
</span>
</header>
{asset ? (
<div className="source-summary">
<span className="source-summary__icon">
<Icon
name={
asset.probe?.streams.some((stream) => stream.type === 'video')
? 'video'
: 'audio'
}
/>
</span>
<div>
<strong>{asset.file.name}</strong>
<span>
{formatBytes(asset.file.size)} ·{' '}
{formatDuration(asset.probe?.durationSeconds)}
</span>
</div>
<span className={`phase phase--${asset.phase}`}>
{asset.phase === 'ready'
? 'Ready'
: asset.phase === 'error'
? 'Could not inspect'
: 'Preparing'}
</span>
</div>
) : (
<p className="panel-empty">
Add and select a media source to continue.
</p>
)}
<div className="segmented segmented--compact" aria-label="Output method">
<button
type="button"
className={operation === 'convert' ? 'is-active' : ''}
onClick={() => setOperation('convert')}
>
Re-encode
</button>
<button
type="button"
className={operation === 'remux' ? 'is-active' : ''}
onClick={() => setOperation('remux')}
>
Fast remux
</button>
</div>
<div className="form-grid">
{operation === 'convert' ? (
<label className="field field--wide">
<span>Export preset</span>
<select
value={presetId}
onChange={(event) => setPresetId(event.currentTarget.value)}
>
<optgroup label="Video">
{presets
.filter((entry) => entry.kind !== 'audio')
.map((entry) => {
const option = presetOption(entry);
return (
<option
key={entry.id}
value={entry.id}
disabled={option.disabled}
>
{option.label}
</option>
);
})}
</optgroup>
<optgroup label="Audio">
{presets
.filter((entry) => entry.kind === 'audio')
.map((entry) => {
const option = presetOption(entry);
return (
<option
key={entry.id}
value={entry.id}
disabled={option.disabled}
>
{option.label}
</option>
);
})}
</optgroup>
</select>
<small>
{preset?.description ??
'A validated user-created local export preset.'}
</small>
</label>
) : (
<label className="field field--wide">
<span>Target container</span>
<select
value={remuxContainer}
onChange={(event) =>
setRemuxContainer(
event.currentTarget
.value as QuickExportConfiguration['remuxContainer']
)
}
>
{(
[
['matroska', 'Matroska (.mkv)'],
['mp4', 'MP4 (.mp4)'],
['webm', 'WebM (.webm)'],
['ogg', 'Ogg (.ogg)'],
] as const
).map(([container, label]) => {
const diagnostics = validateRemuxCompatibility(
{
streams: remuxStreams,
hasChapters: Boolean(asset?.probe?.chapters.length),
},
container,
effectiveSelection
);
const missingMuxer =
engineState.status === 'ready' &&
!engineState.capabilities.muxers.has(container);
const reason = [
...(missingMuxer ? [`missing muxer ${container}.`] : []),
...diagnostics
.filter((entry) => entry.severity === 'error')
.map((entry) => entry.message),
].join(' ');
return (
<option
key={container}
value={container}
disabled={Boolean(reason)}
>
{reason ? `${label} — unavailable: ${reason}` : label}
</option>
);
})}
</select>
<small>Copies compatible streams without quality loss.</small>
</label>
)}
<fieldset className="stream-picker field--wide">
<legend>Streams</legend>
{streams.length > 0 ? (
streams.map((stream) => {
const checked = selectedStreams.has(stream.index);
const unavailableReason = streamUnavailableReasons.get(
stream.index
);
return (
<label key={stream.index}>
<input
type="checkbox"
checked={checked}
disabled={unavailableReason !== undefined}
onChange={() => {
const next = new Set(requestedStreams);
if (next.has(stream.index)) next.delete(stream.index);
else next.add(stream.index);
if (asset) {
setStreamOverride({
assetId: asset.id,
indices: next,
});
}
}}
/>
<span className="stream-kind">{stream.type}</span>
<span>
#{stream.index} · {stream.codecName ?? 'unknown codec'}
{stream.language ? ` · ${stream.language}` : ''}
{unavailableReason ? (
<small>Unavailable: {unavailableReason}</small>
) : null}
</span>
</label>
);
})
) : (
<p>Streams appear after local inspection.</p>
)}
</fieldset>
<label className="check-field">
<input
type="checkbox"
checked={removeMetadata}
onChange={(event) => setRemoveMetadata(event.currentTarget.checked)}
/>
Remove metadata
</label>
<label className="check-field">
<input
type="checkbox"
checked={removeChapters}
onChange={(event) => setRemoveChapters(event.currentTarget.checked)}
/>
Remove chapters
</label>
</div>
{availability?.status === 'unavailable' ? (
<p className="notice notice--error">
<Icon name="warning" />
This core cannot run the selected preset:{' '}
{availability.reasons.join(', ')}
</p>
) : null}
{operation === 'remux' && remuxUnavailableReason ? (
<p className="notice notice--error">
<Icon name="warning" />
{remuxUnavailableReason}
</p>
) : null}
{operation === 'convert' && presetNeedsMissingAudio ? (
<p className="notice notice--error">
<Icon name="warning" />
Select at least one audio stream for an audio-only preset.
</p>
) : null}
{!hasSelectedStream && streams.length > 0 ? (
<p className="notice notice--error">
<Icon name="warning" />
Select at least one output stream.
</p>
) : null}
{opusNeedsRegressionTest ? (
<p className="notice notice--warning">
<Icon name="warning" />
Opus export is held back until the pinned core passes its browser
regression test.
</p>
) : null}
<footer className="quick-panel__footer">
<div>
<strong>
{preset && 'settingsSummary' in preset
? preset.settingsSummary[0]
: preset
? 'Validated user preset'
: 'Stream copy'}
</strong>
<span>Resource estimates are checked before allocation.</span>
</div>
<button
className="button button--primary button--large"
type="button"
disabled={!canExport}
onClick={() =>
onExport({
operation,
preset,
remuxContainer,
streamSelection: effectiveSelection,
removeMetadata,
removeChapters,
})
}
>
<Icon name={busy || queueing ? 'queue' : 'sparkles'} />
{busy
? 'Preparing engine…'
: queueing
? 'Add to queue'
: operation === 'remux'
? 'Remux'
: 'Convert'}
</button>
</footer>
</section>
);
}
function quickStreamUnavailableReason(
stream: MediaStreamProbe,
operation: QuickOperation,
preset: ExportPreset | undefined
): string | undefined {
if (stream.type === 'unknown') {
return 'unknown stream kinds cannot be mapped safely.';
}
if (operation === 'remux') {
return undefined;
}
if (!preset) {
return 'choose an export preset first.';
}
if (stream.type === 'video' && !preset.video) {
return 'the selected preset creates audio only.';
}
if (stream.type === 'audio' && !preset.audio) {
return 'the selected preset creates video or images without audio.';
}
if (stream.type === 'subtitle') {
if (preset.subtitlePolicy === 'none') {
return 'the selected preset explicitly omits subtitles.';
}
if (preset.subtitlePolicy === 'burn-in') {
return 'burn-in requires an explicitly attached subtitle source.';
}
}
if (
(stream.type === 'attachment' || stream.type === 'data') &&
preset.container !== 'matroska'
) {
return `${stream.type} streams are only preserved by the reviewed Matroska conversion path.`;
}
return undefined;
}
+186
View File
@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react';
import { formatBytes } from '../app/application-state';
import { getDerivedCache } from '../app/derived-cache';
import {
readStorageQuota,
type DerivedCacheEntry,
type StorageQuotaSnapshot,
} from '../storage';
import { Icon } from './Icon';
export function StoragePanel({ projectId }: { readonly projectId?: string }) {
const [quota, setQuota] = useState<StorageQuotaSnapshot>();
const [entries, setEntries] = useState<readonly DerivedCacheEntry[]>([]);
const [mode, setMode] = useState<'opfs' | 'memory'>('memory');
const [message, setMessage] = useState<string>();
const refresh = async () => {
const [nextQuota, cache] = await Promise.all([
readStorageQuota(),
getDerivedCache(),
]);
setQuota(nextQuota);
setMode(cache.mode);
setEntries(await cache.list());
};
const runCacheAction = async (
action: () => Promise<void>,
success: string
) => {
try {
await action();
await refresh();
setMessage(success);
} catch (error) {
setMessage(
error instanceof Error
? `Cache operation failed: ${error.message}`
: 'Cache operation failed.'
);
}
};
useEffect(() => {
const timer = globalThis.setTimeout(() => {
void refresh().catch((error: unknown) => {
setMessage(
error instanceof Error
? error.message
: 'Storage status is unavailable.'
);
});
}, 0);
return () => globalThis.clearTimeout(timer);
}, []);
const cachedBytes = entries.reduce(
(total, entry) => total + entry.sizeBytes,
0
);
const projectEntries = projectId
? entries.filter((entry) => entry.projectId === projectId)
: [];
return (
<details
className="capability-panel storage-panel"
onToggle={(event) => {
if (!event.currentTarget.open) return;
void refresh().catch((error: unknown) => {
setMessage(
error instanceof Error
? error.message
: 'Storage status is unavailable.'
);
});
}}
>
<summary>
<span>
<Icon name="queue" />
Derived cache
</span>
<span className="engine-pill">
{formatBytes(cachedBytes)} · {mode.toUpperCase()}
</span>
</summary>
<div className="storage-panel__body">
<dl className="capability-grid">
<div>
<dt>Origin usage</dt>
<dd>
{quota?.usageBytes === undefined
? 'Not reported'
: formatBytes(quota.usageBytes)}
</dd>
</div>
<div>
<dt>Origin quota</dt>
<dd>
{quota?.quotaBytes === undefined
? 'Not reported'
: formatBytes(quota.quotaBytes)}
</dd>
</div>
<div>
<dt>Cached items</dt>
<dd>{entries.length}</dd>
</div>
</dl>
{entries.length > 0 ? (
<ul className="cache-list">
{entries.map((entry) => (
<li key={entry.key}>
<span className="stream-kind">{entry.kind}</span>
<span>{formatBytes(entry.sizeBytes)}</span>
<button
className="icon-action"
type="button"
aria-label={`Delete cached ${entry.kind}`}
onClick={() => {
void runCacheAction(async () => {
await (await getDerivedCache()).delete(entry.key);
}, `Deleted cached ${entry.kind}.`);
}}
>
<Icon name="trash" />
</button>
</li>
))}
</ul>
) : (
<p className="section-intro">
Preview proxies and generated frames may be cached here. Original
source media is never added automatically.
</p>
)}
<div className="storage-panel__actions">
<button
className="button button--secondary"
type="button"
disabled={entries.length === 0}
onClick={() => {
void runCacheAction(
async () => (await getDerivedCache()).clear(),
'Derived cache cleared.'
);
}}
>
<Icon name="trash" />
Clear cache
</button>
<button
className="button button--quiet"
type="button"
disabled={!projectId || projectEntries.length === 0}
onClick={() => {
if (!projectId) return;
void runCacheAction(
async () => {
await (await getDerivedCache()).clearProject(projectId);
},
`Cleared ${projectEntries.length} current-project cache item${projectEntries.length === 1 ? '' : 's'}.`
);
}}
>
Clear current project
</button>
<button
className="button button--quiet"
type="button"
onClick={() =>
void runCacheAction(
async () => undefined,
'Storage status refreshed.'
)
}
>
Refresh
</button>
{message ? <span role="status">{message}</span> : null}
</div>
</div>
</details>
);
}
+458
View File
@@ -0,0 +1,458 @@
.structural-operations {
min-width: 0;
overflow: hidden;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) + 0.25rem);
background: var(--toolbox-surface);
color: var(--toolbox-text);
box-shadow: 0 8px 25px rgb(24 34 68 / 5%);
}
.structural-operations *,
.structural-operations *::before,
.structural-operations *::after {
box-sizing: border-box;
}
.structural-operations h2,
.structural-operations h3,
.structural-operations h4,
.structural-operations p {
margin-block-start: 0;
}
.structural-operations__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
padding: 1.1rem 1.2rem;
border-bottom: 1px solid var(--toolbox-border);
}
.structural-operations__header h2 {
margin-block-end: 0.28rem;
font-size: 1.05rem;
}
.structural-operations__header p:not(.structural-operations__eyebrow) {
max-width: 50rem;
margin-block-end: 0;
color: var(--toolbox-muted);
font-size: 0.78rem;
line-height: 1.55;
}
.structural-operations__eyebrow {
margin-block-end: 0.25rem;
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.structural-operations__local {
flex: none;
padding: 0.45rem 0.65rem;
border: 1px solid
color-mix(
in srgb,
var(--toolbox-success, #2f8f5b) 34%,
var(--toolbox-border)
);
border-radius: 999px;
background: color-mix(
in srgb,
var(--toolbox-success, #2f8f5b) 8%,
var(--toolbox-surface)
);
color: var(--toolbox-success, #2f8f5b);
font-size: 0.68rem;
font-weight: 800;
}
.structural-operations__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 1rem;
padding: 1rem;
}
.structural-operation-card {
min-width: 0;
padding: 1rem;
border: 1px solid var(--toolbox-border);
border-radius: var(--toolbox-radius);
background: color-mix(
in srgb,
var(--toolbox-surface) 94%,
var(--toolbox-background)
);
}
.structural-operation-card > header {
display: flex;
align-items: flex-start;
gap: 0.7rem;
margin-block-end: 1rem;
}
.structural-operation-card > header h3 {
margin: 0 0 0.2rem;
font-size: 0.87rem;
}
.structural-operation-card > header p {
margin-block-end: 0;
color: var(--toolbox-muted);
font-size: 0.72rem;
line-height: 1.45;
}
.structural-operation-card__step {
width: 1.8rem;
height: 1.8rem;
display: inline-grid;
flex: none;
place-items: center;
border-radius: 0.55rem;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.72rem;
font-weight: 850;
}
.structural-range-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
margin: 0 0 0.85rem;
}
.structural-range-summary > div,
.structural-timeline-summary {
min-width: 0;
padding: 0.62rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface-soft);
}
.structural-range-summary dt {
margin-block-end: 0.22rem;
color: var(--toolbox-muted);
font-size: 0.61rem;
}
.structural-range-summary dd {
margin: 0;
overflow-wrap: anywhere;
font-size: 0.68rem;
font-weight: 760;
font-variant-numeric: tabular-nums;
}
.structural-timeline-summary {
margin-block-end: 0.85rem;
}
.structural-timeline-summary p {
margin-block-end: 0.18rem;
color: var(--toolbox-muted);
font-size: 0.67rem;
line-height: 1.45;
}
.structural-timeline-summary p:last-child {
margin-block-end: 0;
}
.structural-choice-cards,
.structural-policy {
display: grid;
gap: 0.55rem;
margin: 0 0 0.85rem;
padding: 0;
border: 0;
}
.structural-choice-cards > legend,
.structural-policy > legend {
margin-block-end: 0.35rem;
font-size: 0.7rem;
font-weight: 760;
}
.structural-choice-cards > label,
.structural-policy > div > label {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.72rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.12rem);
background: var(--toolbox-surface);
cursor: pointer;
}
.structural-choice-cards > label:has(input:checked),
.structural-policy > div > label:has(input:checked) {
border-color: color-mix(
in srgb,
var(--toolbox-accent) 48%,
var(--toolbox-border)
);
background: var(--toolbox-accent-soft);
}
.structural-choice-cards input,
.structural-policy input {
margin-block-start: 0.15rem;
accent-color: var(--toolbox-accent);
}
.structural-choice-cards span,
.structural-choice-cards strong,
.structural-choice-cards small,
.structural-policy span,
.structural-policy strong,
.structural-policy small {
display: block;
}
.structural-choice-cards strong,
.structural-policy strong {
margin-block-end: 0.18rem;
font-size: 0.74rem;
}
.structural-choice-cards small,
.structural-policy small {
color: var(--toolbox-muted);
font-size: 0.65rem;
line-height: 1.4;
}
.structural-field {
min-width: 0;
display: grid;
gap: 0.34rem;
margin-block-end: 0.85rem;
font-size: 0.7rem;
font-weight: 740;
}
.structural-field select {
width: 100%;
min-height: 2.5rem;
padding: 0.48rem 0.62rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface);
color: var(--toolbox-text);
font: inherit;
font-weight: 620;
}
.structural-field small {
color: var(--toolbox-muted);
font-size: 0.64rem;
font-weight: 580;
line-height: 1.4;
}
.structural-policy > p {
margin: -0.2rem 0 0;
color: var(--toolbox-muted);
font-size: 0.65rem;
line-height: 1.4;
}
.structural-policy > div {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.45rem;
}
.structural-hint,
.structural-empty {
margin-block-end: 0.85rem;
padding: 0.58rem 0.68rem;
border-radius: calc(var(--toolbox-radius) - 0.15rem);
background: var(--toolbox-surface-soft);
color: var(--toolbox-muted);
font-size: 0.67rem;
line-height: 1.45;
}
.structural-compatibility {
margin-block-end: 0.85rem;
padding: 0.68rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.15rem);
background: var(--toolbox-surface-soft);
}
.structural-compatibility h4 {
margin-block-end: 0.28rem;
font-size: 0.7rem;
}
.structural-compatibility p,
.structural-compatibility li {
margin-block-end: 0;
color: var(--toolbox-muted);
font-size: 0.65rem;
line-height: 1.45;
}
.structural-compatibility ul {
display: grid;
gap: 0.42rem;
margin: 0.5rem 0 0;
padding: 0;
list-style: none;
}
.structural-compatibility li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.45rem;
}
.structural-compatibility li > span:first-child {
align-self: start;
padding: 0.12rem 0.3rem;
border-radius: 999px;
background: var(--toolbox-surface);
font-size: 0.54rem;
font-weight: 800;
text-transform: uppercase;
}
.structural-compatibility li small {
display: block;
color: var(--toolbox-muted);
font-size: 0.58rem;
}
.structural-compatibility--ok {
border-color: color-mix(
in srgb,
var(--toolbox-success, #2f8f5b) 28%,
var(--toolbox-border)
);
}
.structural-compatibility--warning,
.structural-compatibility--error {
border-color: color-mix(
in srgb,
var(--toolbox-warning, #a96700) 30%,
var(--toolbox-border)
);
}
.structural-compatibility--error {
background: color-mix(
in srgb,
var(--toolbox-warning, #a96700) 8%,
var(--toolbox-surface)
);
}
.structural-action {
display: grid;
gap: 0.28rem;
}
.structural-action button {
width: 100%;
min-height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.58rem 0.82rem;
border: 1px solid var(--toolbox-accent);
border-radius: var(--toolbox-radius);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
cursor: pointer;
font: inherit;
font-size: 0.72rem;
font-weight: 780;
}
.structural-action button:hover:not(:disabled) {
border-color: var(--toolbox-accent-hover);
background: var(--toolbox-accent-hover);
}
.structural-action button:disabled {
cursor: not-allowed;
opacity: 0.52;
}
.structural-action small {
color: var(--toolbox-muted);
font-size: 0.61rem;
line-height: 1.35;
}
.structural-field select:focus-visible,
.structural-operations input:focus-visible,
.structural-operations button:focus-visible {
outline: 3px solid var(--toolbox-focus);
outline-offset: 2px;
}
.structural-operations__status {
min-height: 1.25rem;
margin: 0;
padding: 0 1rem 0.7rem;
color: var(--toolbox-muted);
font-size: 0.68rem;
}
@media (max-width: 64rem) {
.structural-operations__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 40rem) {
.structural-operations__header {
display: grid;
gap: 0.75rem;
}
.structural-operations__local {
justify-self: start;
}
.structural-operations__grid {
padding: 0.7rem;
}
.structural-operation-card {
padding: 0.8rem;
}
.structural-range-summary,
.structural-policy > div {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.structural-operations *,
.structural-operations *::before,
.structural-operations *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+990
View File
@@ -0,0 +1,990 @@
import { useId, useState } from 'react';
import './StructuralOperations.css';
export type StructuralCapabilityKey =
'trim-fast' | 'trim-accurate' | 'concat-fast' | 'concat-normalized';
export interface StructuralCapabilityStatus {
readonly available: boolean;
readonly reason?: string;
}
export interface StructuralExportPresetOption {
readonly id: string;
readonly name: string;
readonly fileExtension: string;
readonly supportsAudio: boolean;
readonly supportsVideo: boolean;
readonly disabledReason?: string;
}
export interface StructuralSelectedClip {
readonly id: string;
readonly label: string;
readonly sourceInSeconds: number;
readonly sourceOutSeconds: number;
readonly sourceDurationSeconds?: number;
readonly sourceExtension: string;
readonly hasAudio: boolean;
readonly hasVideo: boolean;
}
export interface StructuralTimelineClip {
readonly id: string;
readonly label: string;
readonly hasAudio: boolean;
readonly hasVideo: boolean;
readonly hasSubtitles: boolean;
}
export interface StructuralCompatibilityDiagnostic {
readonly code: string;
readonly severity: 'info' | 'warning' | 'error';
readonly message: string;
readonly path?: string;
}
export interface StructuralTimeline {
/** Clips in their exact export order. */
readonly clips: readonly StructuralTimelineClip[];
readonly fastTargetExtension: string;
readonly fastTargetMuxer: string;
/**
* `undefined` means the command layer has not checked the inputs. An empty
* array means it checked them and found no compatibility diagnostics.
*/
readonly fastCompatibilityDiagnostics?: readonly StructuralCompatibilityDiagnostic[];
}
export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject';
export type NormalizedSubtitlePolicy = 'drop-all' | 'reject';
export type StructuralTrimRequest =
| {
readonly operation: 'trim';
readonly mode: 'fast';
readonly clipId: string;
readonly startSeconds: number;
readonly endSeconds: number;
readonly targetExtension: string;
}
| {
readonly operation: 'trim';
readonly mode: 'accurate';
readonly clipId: string;
readonly startSeconds: number;
readonly endSeconds: number;
readonly presetId: string;
};
export type StructuralConcatRequest =
| {
readonly operation: 'concat';
readonly mode: 'fast';
readonly clipIds: readonly string[];
readonly targetExtension: string;
readonly targetMuxer: string;
}
| {
readonly operation: 'concat';
readonly mode: 'normalized';
readonly clipIds: readonly string[];
readonly presetId: string;
readonly missingAudioPolicy: MissingAudioPolicy;
readonly subtitlePolicy: NormalizedSubtitlePolicy;
};
type RequestHandler<T> = (request: T) => void | Promise<void>;
export interface StructuralOperationsProps {
readonly selectedClip?: StructuralSelectedClip;
readonly timeline?: StructuralTimeline;
readonly exportPresets?: readonly StructuralExportPresetOption[];
/**
* Missing entries fail closed. The integration layer must opt in only after
* checking the loaded FFmpeg core and the current browser environment.
*/
readonly availability?: Partial<
Readonly<Record<StructuralCapabilityKey, StructuralCapabilityStatus>>
>;
readonly busy?: boolean;
readonly onTrim?: RequestHandler<StructuralTrimRequest>;
readonly onConcat?: RequestHandler<StructuralConcatRequest>;
}
type TrimMode = StructuralTrimRequest['mode'];
type ConcatMode = StructuralConcatRequest['mode'];
export function StructuralOperations({
selectedClip,
timeline,
exportPresets = [],
availability = {},
busy = false,
onTrim,
onConcat,
}: StructuralOperationsProps) {
const componentId = useId();
const [trimMode, setTrimMode] = useState<TrimMode>('fast');
const [concatMode, setConcatMode] = useState<ConcatMode>('fast');
const [trimPresetId, setTrimPresetId] = useState('');
const [concatPresetId, setConcatPresetId] = useState('');
const [missingAudioPolicy, setMissingAudioPolicy] = useState<
MissingAudioPolicy | ''
>('');
const [subtitlePolicy, setSubtitlePolicy] = useState<
NormalizedSubtitlePolicy | ''
>('');
const [pending, setPending] = useState(false);
const [status, setStatus] = useState<string>();
const trimPreset = exportPresets.find((preset) => preset.id === trimPresetId);
const concatPreset = exportPresets.find(
(preset) => preset.id === concatPresetId
);
const trimReason = reasonForTrim({
mode: trimMode,
selectedClip,
preset: trimPreset,
availability,
connected: Boolean(onTrim),
busy: busy || pending,
});
const concatReason = reasonForConcat({
mode: concatMode,
timeline,
preset: concatPreset,
missingAudioPolicy,
subtitlePolicy,
availability,
connected: Boolean(onConcat),
busy: busy || pending,
});
const run = <T,>(
handler: RequestHandler<T> | undefined,
request: T,
successMessage: string
) => {
if (!handler) {
return;
}
setPending(true);
setStatus(undefined);
void Promise.resolve()
.then(() => handler(request))
.then(() => setStatus(successMessage))
.catch((error: unknown) => {
setStatus(
error instanceof Error
? `The operation could not be queued: ${error.message}`
: 'The operation could not be queued.'
);
})
.finally(() => setPending(false));
};
return (
<section
className="structural-operations"
aria-labelledby={`${componentId}-title`}
>
<header className="structural-operations__header">
<div>
<p className="structural-operations__eyebrow">Structural exports</p>
<h2 id={`${componentId}-title`}>Trim or join without guesswork</h2>
<p>
Choose stream-copy speed or a preset-backed re-encode. Every action
runs locally in this browser.
</p>
</div>
<span className="structural-operations__local">Local only</span>
</header>
<div className="structural-operations__grid">
<section className="structural-operation-card">
<header>
<span className="structural-operation-card__step">1</span>
<div>
<h3>Trim the selected clip</h3>
<p>
The current timeline in/out points are used as-is; this panel
does not change the edit.
</p>
</div>
</header>
{selectedClip ? (
<dl className="structural-range-summary">
<div>
<dt>Clip</dt>
<dd>{selectedClip.label}</dd>
</div>
<div>
<dt>Current range</dt>
<dd>
{formatTime(selectedClip.sourceInSeconds)}
{formatTime(selectedClip.sourceOutSeconds)}
</dd>
</div>
<div>
<dt>Export duration</dt>
<dd>
{formatDuration(
selectedClip.sourceOutSeconds - selectedClip.sourceInSeconds
)}
</dd>
</div>
</dl>
) : (
<p className="structural-empty">
Select one timeline clip to export its current range.
</p>
)}
<fieldset
className="structural-choice-cards"
disabled={busy || pending}
>
<legend>Trim method</legend>
<label>
<input
type="radio"
name={`${componentId}-trim-mode`}
value="fast"
checked={trimMode === 'fast'}
onChange={() => setTrimMode('fast')}
/>
<span>
<strong>Fast stream copy</strong>
<small>
No generation loss and usually much faster. The first frame
may move to a nearby keyframe, so the boundary is not
frame-accurate.
</small>
</span>
</label>
<label>
<input
type="radio"
name={`${componentId}-trim-mode`}
value="accurate"
checked={trimMode === 'accurate'}
onChange={() => setTrimMode('accurate')}
/>
<span>
<strong>Accurate re-encode</strong>
<small>
Decodes around the requested in/out points for practical frame
accuracy. It is slower and applies the selected preset&apos;s
quality settings.
</small>
</span>
</label>
</fieldset>
{trimMode === 'accurate' ? (
<PresetField
id={`${componentId}-trim-preset`}
label="Accurate trim preset"
value={trimPresetId}
presets={exportPresets}
suitable={(preset) =>
selectedClip?.hasVideo
? preset.supportsVideo
: Boolean(selectedClip?.hasAudio && preset.supportsAudio)
}
disabled={busy || pending}
onChange={setTrimPresetId}
/>
) : (
<p className="structural-hint">
The fast export keeps the source streams and writes{' '}
<strong>
.
{normalizeExtension(selectedClip?.sourceExtension) ||
'source format'}
</strong>
.
</p>
)}
<StructuralAction
label={
trimMode === 'fast' ? 'Export fast trim' : 'Export exact trim'
}
reason={trimReason}
onClick={() => {
if (!selectedClip) {
return;
}
if (trimMode === 'fast') {
run(
onTrim,
{
operation: 'trim',
mode: 'fast',
clipId: selectedClip.id,
startSeconds: selectedClip.sourceInSeconds,
endSeconds: selectedClip.sourceOutSeconds,
targetExtension: normalizeExtension(
selectedClip.sourceExtension
),
},
'Fast trim queued.'
);
return;
}
if (!trimPreset) {
return;
}
run(
onTrim,
{
operation: 'trim',
mode: 'accurate',
clipId: selectedClip.id,
startSeconds: selectedClip.sourceInSeconds,
endSeconds: selectedClip.sourceOutSeconds,
presetId: trimPreset.id,
},
'Accurate trim queued.'
);
}}
/>
</section>
<section className="structural-operation-card">
<header>
<span className="structural-operation-card__step">2</span>
<div>
<h3>Join the sequential timeline</h3>
<p>
Clips are submitted in their visible timeline order. Fast
concatenation is enabled only after an explicit stream check.
</p>
</div>
</header>
{timeline ? (
<TimelineSummary clips={timeline.clips} />
) : (
<p className="structural-empty">
Add at least two clips to the sequential timeline.
</p>
)}
<fieldset
className="structural-choice-cards"
disabled={busy || pending}
>
<legend>Join method</legend>
<label>
<input
type="radio"
name={`${componentId}-concat-mode`}
value="fast"
checked={concatMode === 'fast'}
onChange={() => setConcatMode('fast')}
/>
<span>
<strong>Fast compatible concat</strong>
<small>
Copies streams without quality loss. Every clip must have the
same stream order, codecs, dimensions, time bases, and audio
layout.
</small>
</span>
</label>
<label>
<input
type="radio"
name={`${componentId}-concat-mode`}
value="normalized"
checked={concatMode === 'normalized'}
onChange={() => setConcatMode('normalized')}
/>
<span>
<strong>Normalized re-encode</strong>
<small>
Scales and pads video, normalizes frame rate and audio layout,
then encodes the complete sequence with one typed preset.
</small>
</span>
</label>
</fieldset>
{concatMode === 'fast' ? (
<CompatibilityReport
diagnostics={timeline?.fastCompatibilityDiagnostics}
clipCount={timeline?.clips.length ?? 0}
/>
) : (
<>
<PresetField
id={`${componentId}-concat-preset`}
label="Normalized concat preset"
value={concatPresetId}
presets={exportPresets}
suitable={(preset) => preset.supportsVideo}
disabled={busy || pending}
onChange={setConcatPresetId}
/>
<MissingAudioPolicyField
componentId={componentId}
value={missingAudioPolicy}
disabled={busy || pending}
onChange={setMissingAudioPolicy}
/>
<SubtitlePolicyField
componentId={componentId}
value={subtitlePolicy}
disabled={busy || pending}
hasSubtitles={Boolean(
timeline?.clips.some((clip) => clip.hasSubtitles)
)}
onChange={setSubtitlePolicy}
/>
</>
)}
<StructuralAction
label={
concatMode === 'fast'
? 'Export fast concat'
: 'Export normalized concat'
}
reason={concatReason}
onClick={() => {
if (!timeline) {
return;
}
const clipIds = timeline.clips.map((clip) => clip.id);
if (concatMode === 'fast') {
run(
onConcat,
{
operation: 'concat',
mode: 'fast',
clipIds,
targetExtension: normalizeExtension(
timeline.fastTargetExtension
),
targetMuxer: timeline.fastTargetMuxer.trim(),
},
'Fast concat queued.'
);
return;
}
if (!concatPreset || !missingAudioPolicy || !subtitlePolicy) {
return;
}
run(
onConcat,
{
operation: 'concat',
mode: 'normalized',
clipIds,
presetId: concatPreset.id,
missingAudioPolicy,
subtitlePolicy,
},
'Normalized concat queued.'
);
}}
/>
</section>
</div>
<p
className="structural-operations__status"
role="status"
aria-live="polite"
>
{pending ? 'Queueing local operation…' : status}
</p>
</section>
);
}
function TimelineSummary({
clips,
}: {
readonly clips: readonly StructuralTimelineClip[];
}) {
const audioCount = clips.filter((clip) => clip.hasAudio).length;
const subtitleCount = clips.filter((clip) => clip.hasSubtitles).length;
const missingAudio = clips.filter((clip) => !clip.hasAudio);
return (
<div className="structural-timeline-summary">
<p>
<strong>{clips.length}</strong> clips in sequence ·{' '}
<strong>{audioCount}</strong> with audio ·{' '}
<strong>{subtitleCount}</strong> with subtitles
</p>
{missingAudio.length > 0 ? (
<p>No audio: {missingAudio.map((clip) => clip.label).join(', ')}</p>
) : (
<p>Every clip has an audio stream.</p>
)}
</div>
);
}
function CompatibilityReport({
diagnostics,
clipCount,
}: {
readonly diagnostics:
readonly StructuralCompatibilityDiagnostic[] | undefined;
readonly clipCount: number;
}) {
if (diagnostics === undefined) {
return (
<div className="structural-compatibility structural-compatibility--error">
<h4>Fast compatibility not checked</h4>
<p>
Inspect every input stream before enabling stream-copy concatenation.
</p>
</div>
);
}
if (diagnostics.length === 0) {
return (
<div className="structural-compatibility structural-compatibility--ok">
<h4>Fast compatibility passed</h4>
<p>
All {clipCount} clips have matching stream properties for stream copy.
</p>
</div>
);
}
const hasErrors = diagnostics.some(
(diagnostic) => diagnostic.severity === 'error'
);
return (
<div
className={`structural-compatibility ${
hasErrors
? 'structural-compatibility--error'
: 'structural-compatibility--warning'
}`}
>
<h4>
{hasErrors ? 'Fast compatibility failed' : 'Fast compatibility notes'}
</h4>
<ul>
{diagnostics.map((diagnostic, index) => (
<li key={`${diagnostic.code}-${diagnostic.path ?? index}`}>
<span>{diagnostic.severity}</span>
<span>
{diagnostic.message}
{diagnostic.path ? (
<small>Checked field: {diagnostic.path}</small>
) : null}
</span>
</li>
))}
</ul>
</div>
);
}
function PresetField({
id,
label,
value,
presets,
suitable,
disabled,
onChange,
}: {
readonly id: string;
readonly label: string;
readonly value: string;
readonly presets: readonly StructuralExportPresetOption[];
readonly suitable: (preset: StructuralExportPresetOption) => boolean;
readonly disabled: boolean;
readonly onChange: (value: string) => void;
}) {
const selectableCount = presets.filter(
(preset) => suitable(preset) && !preset.disabledReason
).length;
return (
<label className="structural-field" htmlFor={id}>
<span>{label}</span>
<select
id={id}
aria-label={label}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value)}
>
<option value="">Choose an available preset</option>
{presets.map((preset) => {
const unsuitable = !suitable(preset);
const reason =
preset.disabledReason ??
(unsuitable ? 'wrong media type for this operation' : undefined);
return (
<option
key={preset.id}
value={preset.id}
disabled={Boolean(reason)}
>
{preset.name} · .{normalizeExtension(preset.fileExtension)}
{reason ? ` — unavailable: ${reason}` : ''}
</option>
);
})}
</select>
<small>
{selectableCount > 0
? `${selectableCount} verified preset${
selectableCount === 1 ? '' : 's'
} available.`
: 'No suitable preset has passed the current FFmpeg capability check.'}
</small>
</label>
);
}
function MissingAudioPolicyField({
componentId,
value,
disabled,
onChange,
}: {
readonly componentId: string;
readonly value: MissingAudioPolicy | '';
readonly disabled: boolean;
readonly onChange: (value: MissingAudioPolicy) => void;
}) {
const options: readonly {
readonly value: MissingAudioPolicy;
readonly title: string;
readonly detail: string;
}[] = [
{
value: 'insert-silence',
title: 'Insert silence',
detail: 'Keep an audio track and fill each silent clip to its duration.',
},
{
value: 'drop-all',
title: 'Drop all audio',
detail: 'Export one video-only sequence, even from clips with audio.',
},
{
value: 'reject',
title: 'Reject mixed audio',
detail: 'Stop if some clips have audio and others do not.',
},
];
return (
<fieldset
className="structural-policy"
disabled={disabled}
aria-describedby={`${componentId}-audio-policy-hint`}
>
<legend>Missing-audio policy</legend>
<p id={`${componentId}-audio-policy-hint`}>
Required: choose how a normalized sequence handles clips without audio.
</p>
<div>
{options.map((option) => (
<label key={option.value}>
<input
type="radio"
name={`${componentId}-audio-policy`}
value={option.value}
checked={value === option.value}
onChange={() => onChange(option.value)}
/>
<span>
<strong>{option.title}</strong>
<small>{option.detail}</small>
</span>
</label>
))}
</div>
</fieldset>
);
}
function SubtitlePolicyField({
componentId,
value,
disabled,
hasSubtitles,
onChange,
}: {
readonly componentId: string;
readonly value: NormalizedSubtitlePolicy | '';
readonly disabled: boolean;
readonly hasSubtitles: boolean;
readonly onChange: (value: NormalizedSubtitlePolicy) => void;
}) {
return (
<fieldset
className="structural-policy"
disabled={disabled}
aria-describedby={`${componentId}-subtitle-policy-hint`}
>
<legend>Subtitle policy</legend>
<p id={`${componentId}-subtitle-policy-hint`}>
Required: normalized concat has no reviewed, container-safe subtitle
retiming path. The selected preset and loaded FFmpeg capabilities still
gate the final export.
</p>
<div>
<label>
<input
type="radio"
name={`${componentId}-subtitle-policy`}
value="reject"
checked={value === 'reject'}
onChange={() => onChange('reject')}
/>
<span>
<strong>Reject subtitle input</strong>
<small>
{hasSubtitles
? 'Keep the operation blocked because at least one clip contains subtitles.'
: 'Proceed only while every clip remains subtitle-free.'}
</small>
</span>
</label>
<label>
<input
type="radio"
name={`${componentId}-subtitle-policy`}
value="drop-all"
checked={value === 'drop-all'}
onChange={() => onChange('drop-all')}
/>
<span>
<strong>Drop all subtitles</strong>
<small>
Explicitly omit subtitle streams from every normalized input. This
cannot be undone in the output.
</small>
</span>
</label>
</div>
</fieldset>
);
}
function StructuralAction({
label,
reason,
onClick,
}: {
readonly label: string;
readonly reason?: string;
readonly onClick: () => void;
}) {
const reasonId = useId();
return (
<div className="structural-action">
<button
type="button"
disabled={Boolean(reason)}
aria-describedby={reason ? reasonId : undefined}
onClick={onClick}
>
{label}
</button>
{reason ? <small id={reasonId}>{reason}</small> : null}
</div>
);
}
function reasonForTrim({
mode,
selectedClip,
preset,
availability,
connected,
busy,
}: {
readonly mode: TrimMode;
readonly selectedClip: StructuralSelectedClip | undefined;
readonly preset: StructuralExportPresetOption | undefined;
readonly availability: StructuralOperationsProps['availability'];
readonly connected: boolean;
readonly busy: boolean;
}): string | undefined {
if (busy) {
return 'Another local operation is currently running.';
}
if (!selectedClip) {
return 'Select a timeline clip first.';
}
if (!validRange(selectedClip)) {
return 'The selected clip must have a finite in/out range inside its source.';
}
if (mode === 'fast' && !normalizeExtension(selectedClip.sourceExtension)) {
return 'The selected source has no safe output extension.';
}
if (mode === 'accurate') {
if (!preset) {
return 'Choose an available typed preset for the accurate trim.';
}
if (preset.disabledReason) {
return preset.disabledReason;
}
if (
(selectedClip.hasVideo && !preset.supportsVideo) ||
(!selectedClip.hasVideo && selectedClip.hasAudio && !preset.supportsAudio)
) {
return 'The selected preset does not encode this clip type.';
}
}
if (!connected) {
return 'This action has not been connected to the job runner.';
}
return capabilityReason(
availability?.[mode === 'fast' ? 'trim-fast' : 'trim-accurate']
);
}
function reasonForConcat({
mode,
timeline,
preset,
missingAudioPolicy,
subtitlePolicy,
availability,
connected,
busy,
}: {
readonly mode: ConcatMode;
readonly timeline: StructuralTimeline | undefined;
readonly preset: StructuralExportPresetOption | undefined;
readonly missingAudioPolicy: MissingAudioPolicy | '';
readonly subtitlePolicy: NormalizedSubtitlePolicy | '';
readonly availability: StructuralOperationsProps['availability'];
readonly connected: boolean;
readonly busy: boolean;
}): string | undefined {
if (busy) {
return 'Another local operation is currently running.';
}
if (!timeline || timeline.clips.length < 2) {
return 'Add at least two timeline clips in the order to be joined.';
}
const clipIds = timeline.clips.map((clip) => clip.id);
if (
clipIds.some((id) => id.trim() === '') ||
new Set(clipIds).size !== clipIds.length
) {
return 'Every timeline clip must have a unique nonempty ID.';
}
if (mode === 'fast') {
if (!normalizeExtension(timeline.fastTargetExtension)) {
return 'The sequence has no safe fast-concat output extension.';
}
if (timeline.fastCompatibilityDiagnostics === undefined) {
return 'Fast concat remains disabled until every input stream is checked.';
}
if (
timeline.fastCompatibilityDiagnostics.some(
(diagnostic) => diagnostic.severity === 'error'
)
) {
return 'Resolve the reported stream incompatibilities or use normalized concat.';
}
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/u.test(timeline.fastTargetMuxer.trim())) {
return 'The fast-concat output muxer is unknown.';
}
} else {
if (timeline.clips.some((clip) => !clip.hasVideo)) {
return 'Normalized concat currently requires video in every clip.';
}
if (!preset) {
return 'Choose an available typed video preset for normalized concat.';
}
if (preset.disabledReason) {
return preset.disabledReason;
}
if (!preset.supportsVideo) {
return 'Normalized concat requires a video preset.';
}
if (!missingAudioPolicy) {
return 'Choose an explicit missing-audio policy.';
}
if (!subtitlePolicy) {
return 'Choose an explicit subtitle policy.';
}
if (
subtitlePolicy === 'reject' &&
timeline.clips.some((clip) => clip.hasSubtitles)
) {
return 'This sequence contains subtitles. Explicitly drop all subtitles, or remove them before normalized concat.';
}
}
if (!connected) {
return 'This action has not been connected to the job runner.';
}
return capabilityReason(
availability?.[mode === 'fast' ? 'concat-fast' : 'concat-normalized']
);
}
function capabilityReason(
status: StructuralCapabilityStatus | undefined
): string | undefined {
if (!status?.available) {
return (
status?.reason ??
'The required browser and FFmpeg capabilities have not been verified.'
);
}
return undefined;
}
function validRange(clip: StructuralSelectedClip): boolean {
if (
!Number.isFinite(clip.sourceInSeconds) ||
!Number.isFinite(clip.sourceOutSeconds) ||
clip.sourceInSeconds < 0 ||
clip.sourceOutSeconds <= clip.sourceInSeconds
) {
return false;
}
return (
clip.sourceDurationSeconds === undefined ||
(Number.isFinite(clip.sourceDurationSeconds) &&
clip.sourceDurationSeconds > 0 &&
clip.sourceOutSeconds <= clip.sourceDurationSeconds)
);
}
function normalizeExtension(extension: string | undefined): string {
const normalized = (extension ?? '')
.trim()
.replace(/^\.+/u, '')
.toLowerCase();
return /^[a-z0-9]{1,10}$/u.test(normalized) ? normalized : '';
}
function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return 'Invalid range';
}
return `${seconds.toFixed(3)} s`;
}
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return '—';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainder = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}:${remainder.toFixed(3).padStart(6, '0')}`;
}
+253
View File
@@ -0,0 +1,253 @@
import { useState, type DragEvent } from 'react';
import type {
AvProjectV1,
MediaAssetReference,
TimelineClip,
} from '../project';
import type { WaveformPeakData } from '../waveform';
import { formatDuration } from '../app/application-state';
import { Icon } from './Icon';
import { WaveformEditor, type WaveformSelection } from './WaveformEditor';
export interface TimelineProps {
project: AvProjectV1;
selectedClipId?: string;
onSelect: (clipId: string) => void;
onMove: (clipId: string, index: number) => void;
onUpdate: (clipId: string, changes: Partial<TimelineClip>) => void;
onRemove: (clipId: string) => void;
waveformPeaks?: WaveformPeakData;
currentTimeSeconds?: number;
splitMarkers?: readonly number[];
onSelectionChange?: (selection: WaveformSelection) => void;
onSeek?: (timeSeconds: number) => void;
waveformDisabled?: boolean;
}
export function Timeline({
project,
selectedClipId,
onSelect,
onMove,
onUpdate,
onRemove,
waveformPeaks,
currentTimeSeconds,
splitMarkers = [],
onSelectionChange,
onSeek,
waveformDisabled = false,
}: TimelineProps) {
const [draggedId, setDraggedId] = useState<string>();
const assetMap = new Map(project.assets.map((asset) => [asset.id, asset]));
const selectedClip = project.timeline.find(
(clip) => clip.id === selectedClipId
);
const dropAt = (event: DragEvent, index: number) => {
event.preventDefault();
if (draggedId) onMove(draggedId, index);
setDraggedId(undefined);
};
return (
<section className="timeline-panel" aria-labelledby="timeline-title">
<header>
<div>
<p className="eyebrow">Sequential, non-destructive</p>
<h2 id="timeline-title">Timeline</h2>
</div>
<span>
{project.timeline.length} clip
{project.timeline.length === 1 ? '' : 's'}
</span>
</header>
{project.timeline.length === 0 ? (
<div className="timeline-empty">
Imported sources with a known duration are added here.
</div>
) : (
<div className="timeline-track">
{project.timeline.map((clip, index) => (
<TimelineClipCard
key={clip.id}
clip={clip}
asset={assetMap.get(clip.assetId)}
selected={clip.id === selectedClipId}
first={index === 0}
last={index === project.timeline.length - 1}
onSelect={() => onSelect(clip.id)}
onDragStart={() => setDraggedId(clip.id)}
onDragEnd={() => setDraggedId(undefined)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => dropAt(event, index)}
onMove={(offset) => onMove(clip.id, index + offset)}
onUpdate={(changes) => onUpdate(clip.id, changes)}
onRemove={() => onRemove(clip.id)}
/>
))}
</div>
)}
{waveformPeaks && selectedClip ? (
<div className="timeline-waveform subpanel">
<WaveformEditor
peaks={waveformPeaks}
inSeconds={selectedClip.sourceInSeconds}
outSeconds={selectedClip.sourceOutSeconds}
markers={splitMarkers}
currentTimeSeconds={currentTimeSeconds}
onSeek={onSeek}
disabled={waveformDisabled}
ariaLabel={`Waveform editor for ${
assetMap.get(selectedClip.assetId)?.originalName ??
'selected clip'
}`}
onChange={(selection) => {
onUpdate(selectedClip.id, {
sourceInSeconds: selection.inSeconds,
sourceOutSeconds: selection.outSeconds,
});
onSelectionChange?.(selection);
}}
/>
</div>
) : null}
</section>
);
}
interface TimelineClipCardProps {
clip: TimelineClip;
asset?: MediaAssetReference;
selected: boolean;
first: boolean;
last: boolean;
onSelect: () => void;
onDragStart: () => void;
onDragEnd: () => void;
onDragOver: (event: DragEvent) => void;
onDrop: (event: DragEvent) => void;
onMove: (offset: number) => void;
onUpdate: (changes: Partial<TimelineClip>) => void;
onRemove: () => void;
}
function TimelineClipCard({
clip,
asset,
selected,
first,
last,
onSelect,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
onMove,
onUpdate,
onRemove,
}: TimelineClipCardProps) {
const sourceDurationSeconds = asset?.probe?.durationSeconds;
const canResetTrim =
sourceDurationSeconds !== undefined &&
Number.isFinite(sourceDurationSeconds) &&
sourceDurationSeconds >= 0.001;
const trimIsFullSource =
canResetTrim &&
clip.sourceInSeconds === 0 &&
clip.sourceOutSeconds === sourceDurationSeconds;
return (
<article
className={`timeline-clip${selected ? ' is-selected' : ''}`}
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDrop={onDrop}
>
<button className="timeline-clip__main" type="button" onClick={onSelect}>
<span className="timeline-clip__thumb">
<Icon name="video" />
</span>
<span>
<strong>{asset?.originalName ?? 'Missing source'}</strong>
<small>
{formatDuration(clip.sourceOutSeconds - clip.sourceInSeconds)}
</small>
</span>
</button>
{selected ? (
<div className="timeline-clip__controls">
<label>
In
<input
type="number"
min="0"
step="0.01"
value={clip.sourceInSeconds}
onChange={(event) =>
onUpdate({
sourceInSeconds: Math.max(
0,
Number(event.currentTarget.value)
),
})
}
/>
</label>
<label>
Out
<input
type="number"
min={clip.sourceInSeconds + 0.001}
step="0.01"
value={clip.sourceOutSeconds}
onChange={(event) =>
onUpdate({
sourceOutSeconds: Number(event.currentTarget.value),
})
}
/>
</label>
<div className="timeline-clip__actions">
<button
className="timeline-clip__reset"
type="button"
disabled={!canResetTrim || trimIsFullSource}
aria-label="Reset trim to full source"
onClick={() => {
if (!canResetTrim) return;
onUpdate({
sourceInSeconds: 0,
sourceOutSeconds: sourceDurationSeconds,
});
}}
>
Reset trim
</button>
<button
type="button"
disabled={first}
aria-label="Move clip earlier"
onClick={() => onMove(-1)}
>
</button>
<button
type="button"
disabled={last}
aria-label="Move clip later"
onClick={() => onMove(1)}
>
</button>
<button type="button" aria-label="Remove clip" onClick={onRemove}>
<Icon name="trash" />
</button>
</div>
</div>
) : null}
</article>
);
}
+788
View File
@@ -0,0 +1,788 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
type PointerEvent,
} from 'react';
import { formatTimecode } from '../media';
import { MAX_SPLIT_MARKERS } from '../limits';
import {
renderWaveformToCanvas,
validateWaveformPeakData,
type WaveformPeakData,
} from '../waveform';
import './visual-editor.css';
type DragTarget =
{ kind: 'in' } | { kind: 'out' } | { kind: 'marker'; index: number };
interface ActiveDrag {
pointerId: number;
target: DragTarget;
}
export interface WaveformSelection {
readonly inSeconds: number;
readonly outSeconds: number;
readonly markers: readonly number[];
}
export interface WaveformEditorProps {
peaks: WaveformPeakData;
inSeconds: number;
outSeconds: number;
markers: readonly number[];
onChange: (selection: WaveformSelection) => void;
currentTimeSeconds?: number;
onSeek?: (timeSeconds: number) => void;
disabled?: boolean;
minimumClipDurationSeconds?: number;
keyboardStepSeconds?: number;
maximumZoom?: number;
height?: number;
ariaLabel?: string;
}
const MINIMUM_MARKER_GAP_SECONDS = 0.001;
/**
* A single-track waveform editor. It renders bounded peak data to Canvas and
* keeps trim points and split markers as accessible DOM controls above it.
*/
export function WaveformEditor({
peaks,
inSeconds,
outSeconds,
markers,
onChange,
currentTimeSeconds,
onSeek,
disabled = false,
minimumClipDurationSeconds = 0.001,
keyboardStepSeconds = 0.1,
maximumZoom = 32,
height = 168,
ariaLabel = 'Waveform trim and split editor',
}: WaveformEditorProps) {
const peakData = useMemo(() => validateWaveformPeakData(peaks), [peaks]);
validateEditorOptions(
minimumClipDurationSeconds,
keyboardStepSeconds,
maximumZoom,
height
);
const instanceId = useId();
const descriptionId = `${instanceId}-waveform-description`;
const canvasRef = useRef<HTMLCanvasElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const activeDragRef = useRef<ActiveDrag | undefined>(undefined);
const [zoom, setZoom] = useState(1);
const [viewportStartSeconds, setViewportStartSeconds] = useState(0);
const [renderRevision, setRenderRevision] = useState(0);
const duration = peakData.durationSeconds;
const effectiveMinimumClipDuration = Math.min(
minimumClipDurationSeconds,
duration
);
const effectiveZoom = clamp(zoom, 1, maximumZoom);
const selection = useMemo(
() =>
normalizeSelection(
{ inSeconds, outSeconds, markers },
duration,
minimumClipDurationSeconds
),
[duration, inSeconds, markers, minimumClipDurationSeconds, outSeconds]
);
const visibleDuration = duration === 0 ? 0 : duration / effectiveZoom;
const maximumViewportStart = Math.max(0, duration - visibleDuration);
const effectiveViewportStart = clamp(
viewportStartSeconds,
0,
maximumViewportStart
);
const viewportEnd = effectiveViewportStart + visibleDuration;
const currentTime =
currentTimeSeconds === undefined
? undefined
: clampFinite(currentTimeSeconds, 0, duration);
useEffect(() => {
const stage = stageRef.current;
if (stage === null || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
setRenderRevision((revision) => revision + 1);
});
observer.observe(stage);
return () => observer.disconnect();
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (canvas === null || duration === 0) return;
const style = getComputedStyle(canvas);
const width = clamp(Math.round(canvas.clientWidth || 640), 1, 8_192);
const startBucket = clamp(
Math.floor((effectiveViewportStart / duration) * peakData.bucketCount),
0,
Math.max(0, peakData.bucketCount - 1)
);
const endBucket = clamp(
Math.ceil((viewportEnd / duration) * peakData.bucketCount),
startBucket + 1,
peakData.bucketCount
);
renderWaveformToCanvas(canvas, peakData, {
width,
height,
devicePixelRatio: clamp(window.devicePixelRatio || 1, 1, 4),
startBucket,
endBucket,
waveColor: style.getPropertyValue('--waveform-wave').trim() || '#6fd7ca',
backgroundColor:
style.getPropertyValue('--waveform-background').trim() || '#101b22',
centerLineColor:
style.getPropertyValue('--waveform-center').trim() ||
'rgba(255, 255, 255, 0.22)',
selection: {
startFraction: timeToViewportFraction(
selection.inSeconds,
effectiveViewportStart,
visibleDuration
),
endFraction: timeToViewportFraction(
selection.outSeconds,
effectiveViewportStart,
visibleDuration
),
color:
style.getPropertyValue('--waveform-selection').trim() ||
'rgba(88, 201, 184, 0.18)',
},
accessibleLabel: `${ariaLabel}. ${describeSelection(selection)} Visible range ${formatRange(
effectiveViewportStart,
viewportEnd
)}.`,
});
}, [
ariaLabel,
duration,
effectiveViewportStart,
height,
peakData,
renderRevision,
selection,
viewportEnd,
visibleDuration,
]);
const emit = (next: WaveformSelection) => {
onChange(normalizeSelection(next, duration, minimumClipDurationSeconds));
};
const setTrimPoint = (kind: 'in' | 'out', timeSeconds: number) => {
if (disabled || duration === 0) return;
const next =
kind === 'in'
? {
...selection,
inSeconds: clamp(
timeSeconds,
0,
selection.outSeconds - effectiveMinimumClipDuration
),
}
: {
...selection,
outSeconds: clamp(
timeSeconds,
selection.inSeconds + effectiveMinimumClipDuration,
duration
),
};
emit(next);
};
const setMarker = (index: number, timeSeconds: number) => {
if (disabled) return;
const nextMarkers = [...selection.markers];
nextMarkers[index] = clamp(
timeSeconds,
selection.inSeconds + MINIMUM_MARKER_GAP_SECONDS,
selection.outSeconds - MINIMUM_MARKER_GAP_SECONDS
);
emit({ ...selection, markers: nextMarkers });
};
const addMarker = (timeSeconds: number) => {
if (
disabled ||
selection.markers.length >= MAX_SPLIT_MARKERS ||
timeSeconds <= selection.inSeconds + MINIMUM_MARKER_GAP_SECONDS / 2 ||
timeSeconds >= selection.outSeconds - MINIMUM_MARKER_GAP_SECONDS / 2
) {
return;
}
emit({ ...selection, markers: [...selection.markers, timeSeconds] });
};
const removeMarker = (index: number) => {
if (disabled) return;
emit({
...selection,
markers: selection.markers.filter(
(_, markerIndex) => markerIndex !== index
),
});
};
const timeFromPointer = (event: PointerEvent<HTMLElement>): number => {
const bounds = stageRef.current?.getBoundingClientRect();
if (bounds === undefined || bounds.width <= 0) {
return effectiveViewportStart;
}
return clamp(
effectiveViewportStart +
((event.clientX - bounds.left) / bounds.width) * visibleDuration,
effectiveViewportStart,
viewportEnd
);
};
const beginDrag = (
event: PointerEvent<HTMLButtonElement>,
target: DragTarget
) => {
if (disabled) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
activeDragRef.current = { pointerId: event.pointerId, target };
};
const continueDrag = (event: PointerEvent<HTMLButtonElement>) => {
const active = activeDragRef.current;
if (
active === undefined ||
active.pointerId !== event.pointerId ||
disabled
) {
return;
}
event.preventDefault();
const time = timeFromPointer(event);
if (active.target.kind === 'marker') {
setMarker(active.target.index, time);
} else {
setTrimPoint(active.target.kind, time);
}
};
const endDrag = (event: PointerEvent<HTMLButtonElement>) => {
if (activeDragRef.current?.pointerId === event.pointerId) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
activeDragRef.current = undefined;
}
};
const handleTrimKey = (
event: KeyboardEvent<HTMLButtonElement>,
kind: 'in' | 'out'
) => {
if (disabled) return;
const current = kind === 'in' ? selection.inSeconds : selection.outSeconds;
const next = timeFromKey(
event,
current,
kind === 'in' ? 0 : selection.inSeconds + effectiveMinimumClipDuration,
kind === 'in'
? selection.outSeconds - effectiveMinimumClipDuration
: duration,
keyboardStepSeconds
);
if (next === undefined) return;
event.preventDefault();
setTrimPoint(kind, next);
};
const handleMarkerKey = (
event: KeyboardEvent<HTMLButtonElement>,
index: number
) => {
if (disabled) return;
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault();
removeMarker(index);
return;
}
const marker = selection.markers[index];
if (marker === undefined) return;
const next = timeFromKey(
event,
marker,
selection.inSeconds + MINIMUM_MARKER_GAP_SECONDS,
selection.outSeconds - MINIMUM_MARKER_GAP_SECONDS,
keyboardStepSeconds
);
if (next === undefined) return;
event.preventDefault();
setMarker(index, next);
};
const seekFromStage = (event: PointerEvent<HTMLDivElement>) => {
if (disabled || onSeek === undefined || event.button !== 0) return;
onSeek(roundSeconds(timeFromPointer(event)));
};
const setZoomLevel = (event: ChangeEvent<HTMLInputElement>) => {
const nextZoom = clamp(Number(event.currentTarget.value), 1, maximumZoom);
const center = effectiveViewportStart + visibleDuration / 2;
const nextVisibleDuration = duration / nextZoom;
setZoom(nextZoom);
setViewportStartSeconds(
clamp(
center - nextVisibleDuration / 2,
0,
Math.max(0, duration - nextVisibleDuration)
)
);
};
if (duration === 0 || peakData.bucketCount === 0) {
return (
<section
className="waveform-editor waveform-editor--empty"
aria-label={ariaLabel}
>
<p>No waveform samples are available for this source.</p>
</section>
);
}
return (
<section className="waveform-editor" aria-label={ariaLabel}>
<header className="waveform-editor__header">
<div>
<strong>Single sequential audio track</strong>
<span id={descriptionId}>
Drag a handle, or focus it and use the arrow keys. Hold Shift for
larger keyboard steps.
</span>
</div>
<output aria-live="polite">{describeSelection(selection)}</output>
</header>
<div
ref={stageRef}
className="waveform-editor__stage"
style={{ height }}
aria-describedby={descriptionId}
onPointerDown={seekFromStage}
>
<canvas ref={canvasRef} className="waveform-editor__canvas" />
<div className="waveform-editor__overlay">
{currentTime !== undefined &&
isTimeVisible(currentTime, effectiveViewportStart, viewportEnd) ? (
<span
className="waveform-editor__playhead"
style={{
left: `${String(
timeToViewportFraction(
currentTime,
effectiveViewportStart,
visibleDuration
) * 100
)}%`,
}}
aria-hidden="true"
/>
) : null}
<WaveformHandle
kind="in"
time={selection.inSeconds}
viewportStart={effectiveViewportStart}
viewportEnd={viewportEnd}
visibleDuration={visibleDuration}
minimum={0}
maximum={selection.outSeconds - effectiveMinimumClipDuration}
disabled={disabled}
onPointerDown={(event) => beginDrag(event, { kind: 'in' })}
onPointerMove={continueDrag}
onPointerEnd={endDrag}
onKeyDown={(event) => handleTrimKey(event, 'in')}
/>
<WaveformHandle
kind="out"
time={selection.outSeconds}
viewportStart={effectiveViewportStart}
viewportEnd={viewportEnd}
visibleDuration={visibleDuration}
minimum={selection.inSeconds + effectiveMinimumClipDuration}
maximum={duration}
disabled={disabled}
onPointerDown={(event) => beginDrag(event, { kind: 'out' })}
onPointerMove={continueDrag}
onPointerEnd={endDrag}
onKeyDown={(event) => handleTrimKey(event, 'out')}
/>
{selection.markers.map((marker, index) =>
isTimeVisible(marker, effectiveViewportStart, viewportEnd) ? (
<button
key={`${String(marker)}-${String(index)}`}
type="button"
className="waveform-editor__marker"
style={{
left: `${String(
timeToViewportFraction(
marker,
effectiveViewportStart,
visibleDuration
) * 100
)}%`,
}}
role="slider"
aria-label={`Split marker ${String(index + 1)}`}
aria-valuemin={selection.inSeconds}
aria-valuemax={selection.outSeconds}
aria-valuenow={marker}
aria-valuetext={formatTimecode(marker)}
aria-keyshortcuts="ArrowLeft ArrowRight Delete"
disabled={disabled}
onPointerDown={(event) =>
beginDrag(event, { kind: 'marker', index })
}
onPointerMove={continueDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={(event) => handleMarkerKey(event, index)}
>
<span aria-hidden="true" />
</button>
) : null
)}
</div>
</div>
<div className="waveform-editor__navigation">
<label>
<span>Zoom</span>
<input
type="range"
aria-label="Zoom waveform"
min="1"
max={maximumZoom}
step="0.25"
value={effectiveZoom}
disabled={disabled}
aria-valuetext={`${effectiveZoom.toFixed(2)}×`}
onChange={setZoomLevel}
/>
<output>{effectiveZoom.toFixed(2)}×</output>
</label>
<label>
<span>Scroll</span>
<input
type="range"
aria-label="Scroll waveform"
min="0"
max={maximumViewportStart}
step={Math.max(0.001, visibleDuration / 200)}
value={effectiveViewportStart}
disabled={disabled || effectiveZoom === 1}
aria-valuetext={formatTimecode(effectiveViewportStart)}
onChange={(event) =>
setViewportStartSeconds(Number(event.currentTarget.value))
}
/>
<output>{formatRange(effectiveViewportStart, viewportEnd)}</output>
</label>
</div>
<fieldset className="waveform-editor__trim" disabled={disabled}>
<legend>Trim selection</legend>
<label>
<span>In</span>
<input
type="number"
min="0"
max={selection.outSeconds - effectiveMinimumClipDuration}
step={keyboardStepSeconds}
value={selection.inSeconds}
onChange={(event) =>
setTrimPoint('in', Number(event.currentTarget.value))
}
/>
</label>
<label>
<span>Out</span>
<input
type="number"
min={selection.inSeconds + effectiveMinimumClipDuration}
max={duration}
step={keyboardStepSeconds}
value={selection.outSeconds}
onChange={(event) =>
setTrimPoint('out', Number(event.currentTarget.value))
}
/>
</label>
<button
type="button"
disabled={currentTime === undefined}
onClick={() => {
if (currentTime !== undefined) setTrimPoint('in', currentTime);
}}
>
Set in to current time
</button>
<button
type="button"
disabled={currentTime === undefined}
onClick={() => {
if (currentTime !== undefined) setTrimPoint('out', currentTime);
}}
>
Set out to current time
</button>
<button
type="button"
disabled={
currentTime === undefined ||
selection.markers.length >= MAX_SPLIT_MARKERS
}
onClick={() => {
if (currentTime !== undefined) addMarker(currentTime);
}}
>
Add split at current time
</button>
</fieldset>
<div className="waveform-editor__markers">
<div>
<strong>Split markers</strong>
<span>
{selection.markers.length}/{MAX_SPLIT_MARKERS}
</span>
</div>
{selection.markers.length === 0 ? (
<p>No split markers.</p>
) : (
<ol>
{selection.markers.map((marker, index) => (
<li key={`${String(marker)}-${String(index)}`}>
<label>
<span>Marker {index + 1}</span>
<input
type="number"
min={selection.inSeconds + MINIMUM_MARKER_GAP_SECONDS}
max={selection.outSeconds - MINIMUM_MARKER_GAP_SECONDS}
step={keyboardStepSeconds}
value={marker}
disabled={disabled}
onChange={(event) =>
setMarker(index, Number(event.currentTarget.value))
}
/>
</label>
<button
type="button"
disabled={disabled}
aria-label={`Remove split marker ${String(index + 1)}`}
onClick={() => removeMarker(index)}
>
Remove
</button>
</li>
))}
</ol>
)}
</div>
</section>
);
}
interface WaveformHandleProps {
kind: 'in' | 'out';
time: number;
viewportStart: number;
viewportEnd: number;
visibleDuration: number;
minimum: number;
maximum: number;
disabled: boolean;
onPointerDown: (event: PointerEvent<HTMLButtonElement>) => void;
onPointerMove: (event: PointerEvent<HTMLButtonElement>) => void;
onPointerEnd: (event: PointerEvent<HTMLButtonElement>) => void;
onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;
}
function WaveformHandle({
kind,
time,
viewportStart,
viewportEnd,
visibleDuration,
minimum,
maximum,
disabled,
onPointerDown,
onPointerMove,
onPointerEnd,
onKeyDown,
}: WaveformHandleProps) {
if (!isTimeVisible(time, viewportStart, viewportEnd)) return null;
return (
<button
type="button"
className={`waveform-editor__trim-handle waveform-editor__trim-handle--${kind}`}
style={{
left: `${String(
timeToViewportFraction(time, viewportStart, visibleDuration) * 100
)}%`,
}}
role="slider"
aria-label={`${kind === 'in' ? 'In' : 'Out'} trim handle`}
aria-valuemin={minimum}
aria-valuemax={maximum}
aria-valuenow={time}
aria-valuetext={formatTimecode(time)}
aria-keyshortcuts="ArrowLeft ArrowRight Home End"
disabled={disabled}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
onKeyDown={onKeyDown}
>
<span aria-hidden="true">{kind === 'in' ? 'IN' : 'OUT'}</span>
</button>
);
}
function validateEditorOptions(
minimumDuration: number,
keyboardStep: number,
maximumZoom: number,
height: number
): void {
if (!Number.isFinite(minimumDuration) || minimumDuration <= 0) {
throw new RangeError('Minimum clip duration must be positive.');
}
if (!Number.isFinite(keyboardStep) || keyboardStep <= 0) {
throw new RangeError('Waveform keyboard step must be positive.');
}
if (!Number.isFinite(maximumZoom) || maximumZoom < 1 || maximumZoom > 128) {
throw new RangeError('Waveform maximum zoom must be between 1 and 128.');
}
if (!Number.isFinite(height) || height < 80 || height > 1_024) {
throw new RangeError('Waveform height must be between 80 and 1024 pixels.');
}
}
function normalizeSelection(
selection: WaveformSelection,
duration: number,
requestedMinimumDuration: number
): WaveformSelection {
if (duration <= 0) {
return Object.freeze({ inSeconds: 0, outSeconds: 0, markers: [] });
}
const minimumDuration = Math.min(requestedMinimumDuration, duration);
const inPoint = roundSeconds(
clampFinite(selection.inSeconds, 0, duration - minimumDuration)
);
const outPoint = roundSeconds(
clampFinite(selection.outSeconds, inPoint + minimumDuration, duration)
);
const normalizedMarkers: number[] = [];
for (const marker of selection.markers) {
if (normalizedMarkers.length >= MAX_SPLIT_MARKERS) break;
if (!Number.isFinite(marker)) continue;
const normalized = roundSeconds(marker);
if (
normalized <= inPoint + MINIMUM_MARKER_GAP_SECONDS / 2 ||
normalized >= outPoint - MINIMUM_MARKER_GAP_SECONDS / 2 ||
normalizedMarkers.some(
(existing) =>
Math.abs(existing - normalized) < MINIMUM_MARKER_GAP_SECONDS
)
) {
continue;
}
normalizedMarkers.push(normalized);
}
normalizedMarkers.sort((left, right) => left - right);
return Object.freeze({
inSeconds: inPoint,
outSeconds: outPoint,
markers: Object.freeze(normalizedMarkers),
});
}
function timeFromKey(
event: KeyboardEvent,
value: number,
minimum: number,
maximum: number,
baseStep: number
): number | undefined {
const step = event.shiftKey ? Math.max(1, baseStep * 10) : baseStep;
switch (event.key) {
case 'ArrowLeft':
case 'ArrowDown':
return clamp(value - step, minimum, maximum);
case 'ArrowRight':
case 'ArrowUp':
return clamp(value + step, minimum, maximum);
case 'Home':
return minimum;
case 'End':
return maximum;
default:
return undefined;
}
}
function timeToViewportFraction(
time: number,
viewportStart: number,
visibleDuration: number
): number {
if (visibleDuration <= 0) return 0;
return clamp((time - viewportStart) / visibleDuration, 0, 1);
}
function isTimeVisible(time: number, start: number, end: number): boolean {
return time >= start && time <= end;
}
function describeSelection(selection: WaveformSelection): string {
return `Selected ${formatRange(
selection.inSeconds,
selection.outSeconds
)}; ${String(selection.markers.length)} split marker${
selection.markers.length === 1 ? '' : 's'
}.`;
}
function formatRange(start: number, end: number): string {
return `${formatTimecode(start)}${formatTimecode(end)}`;
}
function roundSeconds(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
function clampFinite(value: number, minimum: number, maximum: number): number {
return Number.isFinite(value) ? clamp(value, minimum, maximum) : minimum;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}
+577
View File
@@ -0,0 +1,577 @@
.crop-editor,
.waveform-editor {
--visual-editor-border: var(--toolbox-border, #ced7df);
--visual-editor-surface: var(--toolbox-surface, #fff);
--visual-editor-soft: var(--toolbox-surface-soft, #eef3f5);
--visual-editor-text: var(--toolbox-text, #17232b);
--visual-editor-muted: var(--toolbox-muted, #61717c);
--visual-editor-accent: var(--toolbox-accent, #137c72);
--visual-editor-focus: var(--toolbox-focus, #3ba8ff);
min-width: 0;
color: var(--visual-editor-text);
}
.crop-editor button,
.crop-editor input,
.crop-editor select,
.waveform-editor button,
.waveform-editor input {
font: inherit;
}
.crop-editor__stage {
position: relative;
width: 100%;
max-height: min(66vh, 46rem);
overflow: hidden;
border: 1px solid var(--visual-editor-border);
border-radius: calc(var(--toolbox-radius, 0.6rem) + 0.2rem);
background:
linear-gradient(45deg, rgb(255 255 255 / 4%) 25%, transparent 25%),
linear-gradient(-45deg, rgb(255 255 255 / 4%) 25%, transparent 25%), #10161a;
background-position:
0 0,
8px 8px;
background-size: 16px 16px;
isolation: isolate;
touch-action: none;
}
.crop-editor__media {
position: absolute;
z-index: 0;
top: 50%;
left: 50%;
transform-origin: center;
pointer-events: none;
}
.crop-editor__media > img,
.crop-editor__media > video,
.crop-editor__media > canvas,
.crop-editor__media > picture,
.crop-editor__media > picture > img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.crop-editor__shade {
position: absolute;
z-index: 1;
inset: 0;
pointer-events: none;
}
.crop-editor__rectangle {
position: absolute;
z-index: 2;
min-width: 1px;
min-height: 1px;
border: 2px solid #fff;
box-shadow:
0 0 0 1px rgb(0 0 0 / 70%),
0 0 0 9999px rgb(4 10 14 / 62%);
}
.crop-editor__rectangle::before,
.crop-editor__rectangle::after {
position: absolute;
content: '';
pointer-events: none;
}
.crop-editor__rectangle::before {
inset: 33.333% 0;
border-block: 1px solid rgb(255 255 255 / 45%);
}
.crop-editor__rectangle::after {
inset: 0 33.333%;
border-inline: 1px solid rgb(255 255 255 / 45%);
}
.crop-editor__move {
position: absolute;
z-index: 1;
inset: 1px;
border: 0;
background: transparent;
color: #fff;
cursor: move;
touch-action: none;
}
.crop-editor__move > span {
position: absolute;
top: 50%;
left: 50%;
padding: 0.22rem 0.4rem;
border-radius: 999px;
background: rgb(0 0 0 / 62%);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.04em;
opacity: 0;
transform: translate(-50%, -50%);
transition: opacity 120ms ease;
}
.crop-editor__rectangle:hover .crop-editor__move > span,
.crop-editor__move:focus-visible > span {
opacity: 1;
}
.crop-editor__handle {
position: absolute;
z-index: 3;
width: 1.6rem;
height: 1.6rem;
padding: 0;
border: 0;
background: transparent;
touch-action: none;
}
.crop-editor__handle::after {
position: absolute;
width: 0.62rem;
height: 0.62rem;
border: 2px solid #102128;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 4px rgb(0 0 0 / 55%);
content: '';
inset: 50% auto auto 50%;
transform: translate(-50%, -50%);
}
.crop-editor__handle--n {
top: 0;
left: 50%;
cursor: ns-resize;
transform: translate(-50%, -50%);
}
.crop-editor__handle--ne {
top: 0;
right: 0;
cursor: nesw-resize;
transform: translate(50%, -50%);
}
.crop-editor__handle--e {
top: 50%;
right: 0;
cursor: ew-resize;
transform: translate(50%, -50%);
}
.crop-editor__handle--se {
right: 0;
bottom: 0;
cursor: nwse-resize;
transform: translate(50%, 50%);
}
.crop-editor__handle--s {
bottom: 0;
left: 50%;
cursor: ns-resize;
transform: translate(-50%, 50%);
}
.crop-editor__handle--sw {
bottom: 0;
left: 0;
cursor: nesw-resize;
transform: translate(-50%, 50%);
}
.crop-editor__handle--w {
top: 50%;
left: 0;
cursor: ew-resize;
transform: translate(-50%, -50%);
}
.crop-editor__handle--nw {
top: 0;
left: 0;
cursor: nwse-resize;
transform: translate(-50%, -50%);
}
.crop-editor__move:focus-visible,
.crop-editor__handle:focus-visible,
.waveform-editor__trim-handle:focus-visible,
.waveform-editor__marker:focus-visible {
outline: 3px solid var(--visual-editor-focus);
outline-offset: 2px;
}
.crop-editor__toolbar,
.crop-editor__numeric,
.waveform-editor__navigation,
.waveform-editor__trim {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
gap: 0.7rem;
margin-top: 0.75rem;
}
.crop-editor__toolbar {
align-items: end;
}
.crop-editor__toolbar > label,
.crop-editor__numeric > label,
.waveform-editor__navigation > label,
.waveform-editor__trim > label {
min-width: 0;
display: grid;
gap: 0.3rem;
color: var(--visual-editor-muted);
font-size: 0.76rem;
font-weight: 720;
}
.crop-editor__toolbar > label > select,
.crop-editor__numeric input,
.waveform-editor__trim input,
.waveform-editor__markers input {
width: 100%;
min-width: 0;
min-height: 2.35rem;
padding: 0.45rem 0.55rem;
border: 1px solid var(--visual-editor-border);
border-radius: var(--toolbox-radius, 0.6rem);
background: var(--visual-editor-surface);
color: var(--visual-editor-text);
}
.crop-editor__toolbar > button,
.waveform-editor__trim > button,
.waveform-editor__markers button {
min-height: 2.35rem;
padding: 0.48rem 0.68rem;
border: 1px solid var(--visual-editor-border);
border-radius: var(--toolbox-radius, 0.6rem);
background: var(--visual-editor-surface);
color: var(--visual-editor-text);
cursor: pointer;
}
.crop-editor__lock {
min-height: 2.35rem;
display: flex !important;
flex-direction: row;
align-items: center;
}
.crop-editor__lock input {
width: 1.1rem;
height: 1.1rem;
margin: 0;
accent-color: var(--visual-editor-accent);
}
.crop-editor__numeric,
.waveform-editor__trim {
padding: 0.7rem;
border: 1px solid var(--visual-editor-border);
border-radius: calc(var(--toolbox-radius, 0.6rem) + 0.12rem);
}
.crop-editor__numeric legend,
.waveform-editor__trim legend {
padding-inline: 0.3rem;
color: var(--visual-editor-muted);
font-size: 0.72rem;
font-weight: 800;
}
.crop-editor__hint,
.crop-editor__errors {
margin: 0.55rem 0 0;
color: var(--visual-editor-muted);
font-size: 0.72rem;
line-height: 1.45;
}
.crop-editor__errors {
color: var(--toolbox-danger, #b22f3e);
}
.waveform-editor {
--waveform-background: color-mix(
in srgb,
var(--toolbox-text, #17232b) 96%,
#000
);
--waveform-wave: color-mix(in srgb, var(--visual-editor-accent) 76%, #fff);
--waveform-center: rgb(255 255 255 / 22%);
--waveform-selection: color-mix(
in srgb,
var(--visual-editor-accent) 22%,
transparent
);
display: grid;
gap: 0.75rem;
}
.waveform-editor__header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
}
.waveform-editor__header > div {
min-width: 0;
display: grid;
gap: 0.2rem;
}
.waveform-editor__header strong {
font-size: 0.84rem;
}
.waveform-editor__header span,
.waveform-editor__header output,
.waveform-editor__markers p,
.waveform-editor__markers > div > span {
color: var(--visual-editor-muted);
font-size: 0.72rem;
line-height: 1.4;
}
.waveform-editor__header output {
flex: none;
text-align: right;
}
.waveform-editor__stage {
position: relative;
min-height: 5rem;
overflow: hidden;
border: 1px solid var(--visual-editor-border);
border-radius: calc(var(--toolbox-radius, 0.6rem) + 0.18rem);
background: var(--waveform-background);
cursor: crosshair;
isolation: isolate;
touch-action: none;
}
.waveform-editor__canvas,
.waveform-editor__overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.waveform-editor__canvas {
display: block;
color: var(--waveform-wave);
}
.waveform-editor__overlay {
pointer-events: none;
}
.waveform-editor__playhead {
position: absolute;
z-index: 2;
top: 0;
bottom: 0;
width: 2px;
background: #ffcd57;
box-shadow: 0 0 0 1px rgb(0 0 0 / 35%);
transform: translateX(-1px);
}
.waveform-editor__trim-handle,
.waveform-editor__marker {
position: absolute;
z-index: 4;
top: 0;
bottom: 0;
width: 1.5rem;
padding: 0;
border: 0;
background: transparent;
pointer-events: auto;
touch-action: none;
transform: translateX(-50%);
}
.waveform-editor__trim-handle {
color: #fff;
cursor: ew-resize;
}
.waveform-editor__trim-handle::before,
.waveform-editor__marker::before {
position: absolute;
top: 0;
bottom: 0;
left: calc(50% - 1px);
width: 2px;
background: currentColor;
content: '';
}
.waveform-editor__trim-handle > span {
position: absolute;
top: 0.3rem;
left: 50%;
min-width: 1.7rem;
padding: 0.16rem 0.25rem;
border-radius: 0.28rem;
background: var(--visual-editor-accent);
color: var(--toolbox-accent-contrast, #fff);
font-size: 0.56rem;
font-weight: 900;
line-height: 1;
transform: translateX(-50%);
}
.waveform-editor__trim-handle--in {
color: #89ebdd;
}
.waveform-editor__trim-handle--out {
color: #ffb49d;
}
.waveform-editor__marker {
z-index: 3;
color: #ffd36f;
cursor: col-resize;
}
.waveform-editor__marker > span {
position: absolute;
top: 0.25rem;
left: 50%;
width: 0.72rem;
height: 0.72rem;
border: 2px solid #342a00;
background: currentColor;
clip-path: polygon(50% 100%, 0 0, 100% 0);
transform: translateX(-50%);
}
.waveform-editor__navigation {
margin-top: 0;
}
.waveform-editor__navigation > label {
grid-template-columns: auto minmax(5rem, 1fr) auto;
align-items: center;
}
.waveform-editor__navigation input[type='range'] {
width: 100%;
accent-color: var(--visual-editor-accent);
}
.waveform-editor__navigation output {
min-width: 5rem;
color: var(--visual-editor-muted);
font-size: 0.7rem;
text-align: right;
}
.waveform-editor__trim {
align-items: end;
margin-top: 0;
}
.waveform-editor__markers {
min-width: 0;
}
.waveform-editor__markers > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.waveform-editor__markers p {
margin: 0.5rem 0 0;
}
.waveform-editor__markers ol {
display: grid;
gap: 0.45rem;
margin: 0.5rem 0 0;
padding: 0;
list-style: none;
}
.waveform-editor__markers li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.55rem;
}
.waveform-editor__markers label {
min-width: 0;
display: grid;
grid-template-columns: 5rem minmax(0, 1fr);
align-items: center;
gap: 0.5rem;
color: var(--visual-editor-muted);
font-size: 0.72rem;
font-weight: 720;
}
.waveform-editor--empty {
min-height: 7rem;
place-items: center;
padding: 1rem;
border: 1px dashed var(--visual-editor-border);
border-radius: var(--toolbox-radius, 0.6rem);
background: var(--visual-editor-soft);
color: var(--visual-editor-muted);
text-align: center;
}
.waveform-editor--empty p {
margin: 0;
}
@media (max-width: 43rem) {
.waveform-editor__header {
align-items: start;
flex-direction: column;
}
.waveform-editor__header output {
text-align: left;
}
.waveform-editor__navigation > label {
grid-template-columns: 3rem minmax(4rem, 1fr);
}
.waveform-editor__navigation output {
grid-column: 1 / -1;
text-align: left;
}
.waveform-editor__markers label {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.crop-editor__move > span {
transition: none;
}
}
+145
View File
@@ -0,0 +1,145 @@
import {
exportReportBlob,
exportReportFileName,
type ExportReport,
} from './export-report';
import { saveBlob } from './save-result';
import { safeExportFileName } from './output-name';
import { type ExportResult, ExportResultCollection } from './result-collection';
import {
createResultZip,
resultEntries,
type CreateResultZipOptions,
type ResultZip,
} from './zip-results';
export type SaveOutcome = 'saved' | 'downloaded' | 'cancelled';
export type BlobSaver = (
blob: Blob,
fileName: string,
mimeType?: string
) => Promise<SaveOutcome>;
export interface SaveExportResultOptions {
readonly saver?: BlobSaver;
readonly fileName?: string;
}
export interface SaveCollectionResultOptions extends SaveExportResultOptions {
/**
* Defaults to true so large result Blobs and their preview URLs are released
* after a successful save or download.
*/
readonly releaseAfterSave?: boolean;
}
export interface SaveResultsZipOptions extends Omit<
CreateResultZipOptions,
'fileName'
> {
readonly fileName?: string;
readonly report?: ExportReport;
readonly saver?: BlobSaver;
}
export interface SavedResultsZip {
readonly outcome: SaveOutcome;
readonly archive: ResultZip;
}
export async function saveExportResult(
result: ExportResult,
options: SaveExportResultOptions = {}
): Promise<SaveOutcome> {
const fileName = safeExportFileName(options.fileName ?? result.fileName, {
fallback: 'output',
});
return (options.saver ?? saveBlob)(result.blob, fileName, result.mimeType);
}
export async function saveCollectionResult(
collection: ExportResultCollection,
resultId: string,
options: SaveCollectionResultOptions = {}
): Promise<SaveOutcome> {
const result = collection.get(resultId);
if (!result) {
throw new RangeError(`Unknown result ID "${resultId}".`);
}
const outcome = await saveExportResult(result, options);
if (outcome !== 'cancelled' && (options.releaseAfterSave ?? true)) {
collection.remove(resultId);
}
return outcome;
}
export async function saveResultsZip(
results: readonly ExportResult[],
options: SaveResultsZipOptions = {}
): Promise<SavedResultsZip> {
const entries = [...resultEntries(results)];
if (options.report) {
entries.push({
fileName: exportReportFileName(options.report.plan.operation),
blob: exportReportBlob(options.report),
});
}
const { saver, fileName } = options;
const archive = await createResultZip(entries, {
...(options.limits ? { limits: options.limits } : {}),
...(options.signal ? { signal: options.signal } : {}),
...(options.compressionLevel === undefined
? {}
: { compressionLevel: options.compressionLevel }),
fileName,
});
const outcome = await (saver ?? saveBlob)(
archive.blob,
archive.fileName,
archive.blob.type
);
return Object.freeze({ outcome, archive });
}
export async function saveCollectionZip(
collection: ExportResultCollection,
options: SaveResultsZipOptions & {
readonly releaseAfterSave?: boolean;
} = {}
): Promise<SavedResultsZip> {
const results = collection.list();
const saved = await saveResultsZip(results, options);
if (saved.outcome !== 'cancelled' && (options.releaseAfterSave ?? true)) {
collection.clear();
}
return saved;
}
export async function saveExportReport(
report: ExportReport,
options: {
readonly fileName?: string;
readonly saver?: BlobSaver;
} = {}
): Promise<SaveOutcome> {
const blob = exportReportBlob(report);
return (options.saver ?? saveBlob)(
blob,
safeExportFileName(
options.fileName ?? exportReportFileName(report.plan.operation),
{ extension: 'json', fallback: 'export-report' }
),
blob.type
);
}
export function supportsSaveFilePicker(): boolean {
return (
typeof (
globalThis as typeof globalThis & {
showSaveFilePicker?: unknown;
}
).showSaveFilePicker === 'function'
);
}
+258
View File
@@ -0,0 +1,258 @@
import type {
CommandDiagnostic,
FFmpegCommandPlan,
} from '../commands/command-plan';
import { JSON_MIME_TYPE } from './mime';
import { safeExportFileName } from './output-name';
import type { ExportResult } from './result-collection';
const REPORT_SCHEMA_VERSION = 1;
const MAX_DIAGNOSTICS = 100;
const MAX_TEXT_LENGTH = 2_000;
export type ExportReportStatus =
'completed' | 'failed' | 'cancelled' | 'partial';
export interface ExportExecutionSummary {
readonly status?: ExportReportStatus;
readonly engineMode?: 'single-thread' | 'multi-thread';
readonly ffmpegVersion?: string;
readonly coreVersion?: string;
readonly elapsedSeconds?: number;
readonly conversionMilliseconds?: number;
readonly outputReadMilliseconds?: number;
readonly cleanupMilliseconds?: number;
readonly exitCode?: number;
}
export interface ExportReportInput {
readonly plan: Pick<FFmpegCommandPlan, 'id' | 'operation' | 'diagnostics'>;
readonly results: readonly Pick<
ExportResult,
'id' | 'outputId' | 'fileName' | 'mimeType' | 'role' | 'size'
>[];
readonly execution?: ExportExecutionSummary;
readonly diagnostics?: readonly CommandDiagnostic[];
readonly generatedAt?: string;
}
export interface ExportReport {
readonly schemaVersion: 1;
readonly generatedAt: string;
readonly plan: {
readonly id: string;
readonly operation: string;
};
readonly execution?: ExportExecutionSummary;
readonly outputs: readonly {
readonly id: string;
readonly outputId: string;
readonly fileName: string;
readonly mimeType: string;
readonly role: ExportResult['role'];
readonly sizeBytes: number;
}[];
readonly diagnostics: readonly CommandDiagnostic[];
readonly privacy: {
readonly includesSourceMedia: false;
readonly includesCommandArguments: false;
readonly privatePathsRedacted: true;
};
}
/**
* Builds an intentionally narrow report. Source records, command arguments,
* Blobs, object URLs and browser storage paths are not copied into the output.
*/
export function createExportReport(input: ExportReportInput): ExportReport {
const generatedAt = normalizeTimestamp(
input.generatedAt ?? new Date().toISOString()
);
const diagnostics = [...input.plan.diagnostics, ...(input.diagnostics ?? [])]
.slice(0, MAX_DIAGNOSTICS)
.map(sanitizeDiagnostic);
const outputs = [...input.results]
.map((result) =>
Object.freeze({
id: sanitizeText(result.id, 256),
outputId: sanitizeText(result.outputId, 256),
fileName: safeExportFileName(result.fileName, {
fallback: 'output',
}),
mimeType: sanitizeText(result.mimeType, 128),
role: result.role,
sizeBytes: safeByteSize(result.size),
})
)
.sort((left, right) => compareText(left.id, right.id));
const execution = sanitizeExecution(input.execution);
return Object.freeze({
schemaVersion: REPORT_SCHEMA_VERSION,
generatedAt,
plan: Object.freeze({
id: sanitizeText(input.plan.id, 256),
operation: sanitizeText(input.plan.operation, 128),
}),
...(execution ? { execution } : {}),
outputs: Object.freeze(outputs),
diagnostics: Object.freeze(diagnostics),
privacy: Object.freeze({
includesSourceMedia: false,
includesCommandArguments: false,
privatePathsRedacted: true,
}),
});
}
export function serializeExportReport(report: ExportReport): string {
return `${JSON.stringify(report, undefined, 2)}\n`;
}
export function exportReportBlob(report: ExportReport): Blob {
return new Blob([serializeExportReport(report)], {
type: JSON_MIME_TYPE,
});
}
export function exportReportFileName(operation: string): string {
return safeExportFileName(`${operation}-export-report`, {
extension: 'json',
fallback: 'export-report',
});
}
export function redactPrivatePaths(value: string): string {
return value
.replace(/\bblob:(?:https?:\/\/)?[^\s"'<>]+/giu, '[object URL]')
.replace(/\bfile:\/\/\/?[^\s"'<>]+/giu, '[local path]')
.replace(
/(["'])\b[a-z]:[\\/](?:users|documents and settings|fakepath|temp)[\\/][^\r\n"'<>]*\1/giu,
'[local path]'
)
.replace(
/\b[a-z]:[\\/](?:users|documents and settings|fakepath|temp)[\\/][^\s"'<>]*/giu,
'[local path]'
)
.replace(
/(["'])\/(?:home|users|mnt|media|volumes|tmp|var\/folders|private\/var)\/[^\r\n"'<>]*\1/giu,
'[local path]'
)
.replace(
/\/(?:home|users|mnt|media|volumes|tmp|var\/folders|private\/var)\/[^\s"'<>]+/giu,
'[local path]'
);
}
function sanitizeDiagnostic(diagnostic: CommandDiagnostic): CommandDiagnostic {
return Object.freeze({
code: sanitizeText(diagnostic.code, 128),
severity: diagnostic.severity,
message: sanitizeText(diagnostic.message, MAX_TEXT_LENGTH),
...(diagnostic.field ? { field: sanitizeText(diagnostic.field, 256) } : {}),
});
}
function sanitizeExecution(
execution: ExportExecutionSummary | undefined
): ExportExecutionSummary | undefined {
if (!execution) {
return undefined;
}
const elapsedSeconds =
execution.elapsedSeconds === undefined
? undefined
: safeNonNegativeNumber(execution.elapsedSeconds, 'Elapsed seconds');
const exitCode =
execution.exitCode === undefined
? undefined
: safeInteger(execution.exitCode, 'Exit code');
const conversionMilliseconds = optionalNonNegativeNumber(
execution.conversionMilliseconds,
'Conversion milliseconds'
);
const outputReadMilliseconds = optionalNonNegativeNumber(
execution.outputReadMilliseconds,
'Output-read milliseconds'
);
const cleanupMilliseconds = optionalNonNegativeNumber(
execution.cleanupMilliseconds,
'Cleanup milliseconds'
);
return Object.freeze({
...(execution.status ? { status: execution.status } : {}),
...(execution.engineMode ? { engineMode: execution.engineMode } : {}),
...(execution.ffmpegVersion
? { ffmpegVersion: sanitizeText(execution.ffmpegVersion, 128) }
: {}),
...(execution.coreVersion
? { coreVersion: sanitizeText(execution.coreVersion, 128) }
: {}),
...(elapsedSeconds === undefined ? {} : { elapsedSeconds }),
...(conversionMilliseconds === undefined ? {} : { conversionMilliseconds }),
...(outputReadMilliseconds === undefined ? {} : { outputReadMilliseconds }),
...(cleanupMilliseconds === undefined ? {} : { cleanupMilliseconds }),
...(exitCode === undefined ? {} : { exitCode }),
});
}
function optionalNonNegativeNumber(
value: number | undefined,
label: string
): number | undefined {
return value === undefined ? undefined : safeNonNegativeNumber(value, label);
}
function sanitizeText(value: string, maximumLength: number): string {
return removeUnsafeControlCharacters(redactPrivatePaths(value)).slice(
0,
maximumLength
);
}
function removeUnsafeControlCharacters(value: string): string {
return Array.from(value)
.filter((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return (
codePoint === 9 ||
codePoint === 10 ||
codePoint === 13 ||
(codePoint >= 32 && codePoint !== 127)
);
})
.join('');
}
function normalizeTimestamp(value: string): string {
const timestamp = new Date(value);
if (!Number.isFinite(timestamp.valueOf())) {
throw new RangeError('Report generation time must be a valid timestamp.');
}
return timestamp.toISOString();
}
function safeByteSize(value: number): number {
if (!Number.isSafeInteger(value) || value < 0) {
throw new RangeError('Output size must be a non-negative safe integer.');
}
return value;
}
function safeNonNegativeNumber(value: number, label: string): number {
if (!Number.isFinite(value) || value < 0) {
throw new RangeError(`${label} must be a non-negative finite number.`);
}
return value;
}
function safeInteger(value: number, label: string): number {
if (!Number.isSafeInteger(value)) {
throw new RangeError(`${label} must be a safe integer.`);
}
return value;
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
+69
View File
@@ -0,0 +1,69 @@
export {
saveCollectionResult,
saveCollectionZip,
saveExportReport,
saveExportResult,
saveResultsZip,
supportsSaveFilePicker,
type BlobSaver,
type SaveCollectionResultOptions,
type SavedResultsZip,
type SaveExportResultOptions,
type SaveOutcome,
type SaveResultsZipOptions,
} from './browser-save';
export {
createExportReport,
exportReportBlob,
exportReportFileName,
redactPrivatePaths,
serializeExportReport,
type ExportExecutionSummary,
type ExportReport,
type ExportReportInput,
type ExportReportStatus,
} from './export-report';
export {
BINARY_MIME_TYPE,
JSON_MIME_TYPE,
ZIP_MIME_TYPE,
blobWithOutputMimeType,
isBrowserPreviewMimeType,
mimeTypeFromOutputName,
normalizeOutputMimeType,
preferredExtensionForMimeType,
} from './mime';
export {
deriveOutputFileName,
isSafeArchiveLeafName,
safeExportFileName,
uniqueExportFileNames,
type DerivedOutputNameOptions,
type OutputNameOptions,
} from './output-name';
export {
collectCommandOutputs,
DEFAULT_RESULT_COLLECTION_LIMITS,
ExportResultCollection,
ExportResultLimitError,
MissingCommandOutputError,
type CommandOutputData,
type ExportResult,
type ExportResultInput,
type ExportResultLimit,
type ObjectUrlApi,
type ResultCollectionLimits,
type ResultCollectionOptions,
} from './result-collection';
export { downloadBlob, saveBlob } from './save-result';
export {
createResultZip,
DEFAULT_RESULT_ZIP_LIMITS,
resultEntries,
ResultZipLimitError,
type CreateResultZipOptions,
type ResultZip,
type ResultZipEntry,
type ResultZipLimit,
type ResultZipLimits,
} from './zip-results';
+128
View File
@@ -0,0 +1,128 @@
import { splitFileName } from '../media/safe-file-name';
const MIME_BY_EXTENSION: Readonly<Record<string, string>> = Object.freeze({
'.3gp': 'video/3gpp',
'.aac': 'audio/aac',
'.ass': 'text/x-ssa',
'.avi': 'video/x-msvideo',
'.avif': 'image/avif',
'.bmp': 'image/bmp',
'.flac': 'audio/flac',
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.json': 'application/json',
'.m2ts': 'video/mp2t',
'.m4a': 'audio/mp4',
'.m4v': 'video/mp4',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.mpeg': 'video/mpeg',
'.mpg': 'video/mpeg',
'.oga': 'audio/ogg',
'.ogg': 'audio/ogg',
'.ogv': 'video/ogg',
'.opus': 'audio/ogg',
'.png': 'image/png',
'.srt': 'application/x-subrip',
'.tif': 'image/tiff',
'.tiff': 'image/tiff',
'.ts': 'video/mp2t',
'.txt': 'text/plain',
'.vtt': 'text/vtt',
'.wav': 'audio/wav',
'.webm': 'video/webm',
'.webp': 'image/webp',
'.zip': 'application/zip',
});
const EXTENSION_BY_MIME: Readonly<Record<string, string>> = Object.freeze({
'application/json': '.json',
'application/zip': '.zip',
'application/x-subrip': '.srt',
'audio/aac': '.aac',
'audio/flac': '.flac',
'audio/mp4': '.m4a',
'audio/mpeg': '.mp3',
'audio/ogg': '.ogg',
'audio/wav': '.wav',
'image/avif': '.avif',
'image/bmp': '.bmp',
'image/gif': '.gif',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/tiff': '.tiff',
'image/webp': '.webp',
'text/plain': '.txt',
'text/vtt': '.vtt',
'text/x-ssa': '.ass',
'video/3gpp': '.3gp',
'video/mp2t': '.ts',
'video/mp4': '.mp4',
'video/mpeg': '.mpeg',
'video/ogg': '.ogv',
'video/quicktime': '.mov',
'video/webm': '.webm',
'video/x-matroska': '.mkv',
'video/x-msvideo': '.avi',
});
const MIME_TYPE_PATTERN = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/iu;
export const BINARY_MIME_TYPE = 'application/octet-stream';
export const ZIP_MIME_TYPE = 'application/zip';
export const JSON_MIME_TYPE = 'application/json';
export function mimeTypeFromOutputName(fileName: string): string | undefined {
const extension = splitFileName(fileName).extension.toLowerCase();
return MIME_BY_EXTENSION[extension];
}
/**
* Returns a picker- and Blob-safe base MIME type. Parameters such as codecs
* are deliberately removed because the File System Access API accept map
* expects media types, not Content-Type header values.
*/
export function normalizeOutputMimeType(
candidate: string | undefined,
fileName?: string
): string {
const baseType = candidate?.split(';', 1)[0]?.trim().toLowerCase();
if (baseType && MIME_TYPE_PATTERN.test(baseType)) {
return baseType;
}
return (
(fileName ? mimeTypeFromOutputName(fileName) : undefined) ??
BINARY_MIME_TYPE
);
}
export function preferredExtensionForMimeType(
mimeType: string
): string | undefined {
return EXTENSION_BY_MIME[normalizeOutputMimeType(mimeType)];
}
export function isBrowserPreviewMimeType(mimeType: string): boolean {
const normalized = normalizeOutputMimeType(mimeType);
return (
normalized.startsWith('audio/') ||
normalized.startsWith('video/') ||
normalized.startsWith('image/')
);
}
export function blobWithOutputMimeType(
data: Blob | Uint8Array,
mimeType: string,
fileName?: string
): Blob {
const normalized = normalizeOutputMimeType(mimeType, fileName);
if (data instanceof Blob && data.type === normalized) {
return data;
}
const part: BlobPart = data instanceof Blob ? data : Uint8Array.from(data);
return new Blob([part], { type: normalized });
}
+168
View File
@@ -0,0 +1,168 @@
import {
safeFileName,
safeOutputFileName,
splitFileName,
} from '../media/safe-file-name';
const DEFAULT_OUTPUT_NAME = 'output';
const MAX_OUTPUT_NAME_LENGTH = 120;
export interface OutputNameOptions {
/**
* When provided, this extension replaces an extension in `requestedName`.
* Both `mp4` and `.mp4` are accepted.
*/
readonly extension?: string;
readonly fallback?: string;
}
export interface DerivedOutputNameOptions extends OutputNameOptions {
readonly sourceName: string;
readonly operation: string;
readonly partIndex?: number;
readonly partCount?: number;
}
/**
* Converts an untrusted display name into a portable leaf filename. Directory
* components, control characters, reserved device names and unsafe punctuation
* are removed by `safeFileName`.
*/
export function safeExportFileName(
requestedName: string,
options: OutputNameOptions = {}
): string {
const leafName = requestedName.split(/[\\/]/u).at(-1) ?? '';
const fallback = safeFileName(options.fallback ?? DEFAULT_OUTPUT_NAME, {
fallback: DEFAULT_OUTPUT_NAME,
maxLength: 48,
preserveExtension: false,
});
if (options.extension !== undefined) {
const { stem } = splitFileName(leafName);
return safeOutputFileName(stem, options.extension, fallback);
}
return safeFileName(leafName, {
fallback,
maxLength: MAX_OUTPUT_NAME_LENGTH,
});
}
/**
* Derives a predictable name from a source and operation. A numbered suffix is
* included only for multi-output operations.
*/
export function deriveOutputFileName(
options: DerivedOutputNameOptions
): string {
validatePartOptions(options.partIndex, options.partCount);
const sourceLeaf = safeExportFileName(options.sourceName, {
fallback: 'source',
});
const sourceStem = splitFileName(sourceLeaf).stem;
const operation = safeFileName(options.operation, {
fallback: 'export',
maxLength: 40,
preserveExtension: false,
});
const partSuffix =
options.partIndex === undefined
? ''
: `-${String(options.partIndex + 1).padStart(
Math.max(3, String(options.partCount ?? 1).length),
'0'
)}`;
return safeExportFileName(`${sourceStem}-${operation}${partSuffix}`, {
extension:
options.extension ?? splitFileName(sourceLeaf).extension.slice(1),
fallback: options.fallback,
});
}
/**
* Produces case-insensitively unique leaf filenames while retaining input
* order. This avoids silent overwrites when an archive is extracted on a
* case-insensitive filesystem.
*/
export function uniqueExportFileNames(
requestedNames: readonly string[],
fallback = DEFAULT_OUTPUT_NAME
): readonly string[] {
const used = new Set<string>();
const nextSuffix = new Map<string, number>();
const names: string[] = [];
for (const requestedName of requestedNames) {
const safeName = safeExportFileName(requestedName, { fallback });
const key = comparisonKey(safeName);
if (!used.has(key)) {
used.add(key);
nextSuffix.set(key, 2);
names.push(safeName);
continue;
}
const { stem, extension } = splitFileName(safeName);
let suffix = nextSuffix.get(key) ?? 2;
let candidate: string;
do {
candidate = safeExportFileName(`${stem}-${suffix}${extension}`, {
fallback,
});
suffix += 1;
} while (used.has(comparisonKey(candidate)));
nextSuffix.set(key, suffix);
used.add(comparisonKey(candidate));
names.push(candidate);
}
return Object.freeze(names);
}
export function isSafeArchiveLeafName(fileName: string): boolean {
return (
fileName.length > 0 &&
fileName.length <= MAX_OUTPUT_NAME_LENGTH &&
fileName !== '.' &&
fileName !== '..' &&
!fileName.includes('/') &&
!fileName.includes('\\') &&
!hasControlCharacters(fileName) &&
safeExportFileName(fileName) === fileName
);
}
function comparisonKey(fileName: string): string {
return fileName.normalize('NFKC').toLocaleLowerCase('en-US');
}
function hasControlCharacters(value: string): boolean {
return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint < 32 || codePoint === 127;
});
}
function validatePartOptions(
partIndex: number | undefined,
partCount: number | undefined
): void {
if (
partCount !== undefined &&
(!Number.isSafeInteger(partCount) || partCount < 1)
) {
throw new RangeError('Part count must be a positive safe integer.');
}
if (
partIndex !== undefined &&
(!Number.isSafeInteger(partIndex) ||
partIndex < 0 ||
(partCount !== undefined && partIndex >= partCount))
) {
throw new RangeError('Part index must be within the output part count.');
}
}
+458
View File
@@ -0,0 +1,458 @@
import type {
FFmpegCommandPlan,
PlannedOutput,
} from '../commands/command-plan';
import { MAX_GENERATED_OUTPUT_FILES } from '../limits';
import { blobWithOutputMimeType, normalizeOutputMimeType } from './mime';
import { safeExportFileName, uniqueExportFileNames } from './output-name';
export const DEFAULT_RESULT_COLLECTION_LIMITS = Object.freeze({
maxResults: MAX_GENERATED_OUTPUT_FILES,
maxSingleResultBytes: 1024 * 1024 * 1024,
maxTotalBytes: 1024 * 1024 * 1024,
});
export interface ResultCollectionLimits {
readonly maxResults: number;
readonly maxSingleResultBytes: number;
readonly maxTotalBytes: number;
}
export interface ExportResultInput {
readonly id: string;
readonly planId: string;
readonly outputId: string;
readonly fileName: string;
readonly blob: Blob;
readonly mimeType?: string;
readonly role?: PlannedOutput['role'];
readonly timeRange?: PlannedOutput['timeRange'];
readonly createdAt?: string;
}
export interface ExportResult {
readonly id: string;
readonly planId: string;
readonly outputId: string;
readonly fileName: string;
readonly blob: Blob;
readonly mimeType: string;
readonly role: PlannedOutput['role'];
readonly timeRange?: PlannedOutput['timeRange'];
readonly size: number;
readonly createdAt: string;
}
export interface ObjectUrlApi {
createObjectURL(blob: Blob): string;
revokeObjectURL(url: string): void;
}
export interface ResultCollectionOptions {
readonly limits?: Partial<ResultCollectionLimits>;
readonly objectUrlApi?: ObjectUrlApi;
readonly now?: () => Date;
}
export type CommandOutputData = Blob | Uint8Array;
/**
* Owns browser-memory output Blobs and every object URL created for them.
* Removing, replacing, or clearing a result always revokes its preview URL.
*/
export class ExportResultCollection {
readonly #limits: ResultCollectionLimits;
readonly #urlApi: ObjectUrlApi;
readonly #now: () => Date;
readonly #results = new Map<string, ExportResult>();
readonly #objectUrls = new Map<string, string>();
#totalBytes = 0;
#disposed = false;
constructor(options: ResultCollectionOptions = {}) {
this.#limits = resolveLimits(options.limits);
this.#urlApi = options.objectUrlApi ?? defaultObjectUrlApi();
this.#now = options.now ?? (() => new Date());
}
get size(): number {
return this.#results.size;
}
get totalBytes(): number {
return this.#totalBytes;
}
get limits(): ResultCollectionLimits {
return this.#limits;
}
list(): readonly ExportResult[] {
this.#assertActive();
return Object.freeze([...this.#results.values()]);
}
get(id: string): ExportResult | undefined {
this.#assertActive();
return this.#results.get(id);
}
add(input: ExportResultInput): ExportResult {
const result = this.addMany([input])[0];
if (!result) {
throw new Error('The result could not be added.');
}
return result;
}
/**
* Adds a batch atomically: every ID, timestamp and limit is validated before
* the collection retains the first Blob.
*/
addMany(inputs: readonly ExportResultInput[]): readonly ExportResult[] {
this.#assertActive();
if (inputs.length === 0) {
return Object.freeze([]);
}
const inputIds = new Set<string>();
let addedBytes = 0;
for (const input of inputs) {
validateIdentifier(input.id, 'Result ID');
validateTimeRange(input.timeRange);
if (this.#results.has(input.id) || inputIds.has(input.id)) {
throw new RangeError(`A result with ID "${input.id}" already exists.`);
}
inputIds.add(input.id);
this.#assertSingleResultSize(input.blob.size);
addedBytes += input.blob.size;
if (!Number.isSafeInteger(addedBytes)) {
throw new RangeError(
'Combined result size exceeds safe integer range.'
);
}
}
this.#assertCountAndTotalCapacity(inputs.length, addedBytes, 0);
const existingNames = [...this.#results.values()].map(
(result) => result.fileName
);
const uniqueNames = uniqueExportFileNames([
...existingNames,
...inputs.map((input) =>
safeExportFileName(input.fileName, { fallback: 'output' })
),
]).slice(existingNames.length);
const results = inputs.map((input, index) =>
this.#createResult(input, undefined, uniqueNames[index])
);
for (const result of results) {
this.#results.set(result.id, result);
}
this.#totalBytes += addedBytes;
return Object.freeze(results);
}
replace(input: ExportResultInput): ExportResult {
this.#assertActive();
validateIdentifier(input.id, 'Result ID');
validateTimeRange(input.timeRange);
const existing = this.#results.get(input.id);
this.#assertSingleResultSize(input.blob.size);
this.#assertCountAndTotalCapacity(
existing ? 0 : 1,
input.blob.size,
existing?.size ?? 0
);
const result = this.#createResult(input, input.id);
this.#revokeObjectUrl(input.id);
this.#results.set(input.id, result);
this.#totalBytes = this.#totalBytes - (existing?.size ?? 0) + result.size;
return result;
}
objectUrl(id: string): string {
this.#assertActive();
const result = this.#results.get(id);
if (!result) {
throw new RangeError(`Unknown result ID "${id}".`);
}
const existing = this.#objectUrls.get(id);
if (existing) {
return existing;
}
const url = this.#urlApi.createObjectURL(result.blob);
this.#objectUrls.set(id, url);
return url;
}
revokeObjectUrl(id: string): boolean {
this.#assertActive();
return this.#revokeObjectUrl(id);
}
remove(id: string): boolean {
this.#assertActive();
const result = this.#results.get(id);
if (!result) {
return false;
}
this.#revokeObjectUrl(id);
this.#results.delete(id);
this.#totalBytes -= result.size;
return true;
}
clear(): void {
this.#assertActive();
this.#clear();
}
/**
* Permanently releases the collection. Calling read or mutation methods
* afterwards throws, making use-after-cleanup visible during development.
*/
dispose(): void {
if (this.#disposed) {
return;
}
this.#clear();
this.#disposed = true;
}
#createResult(
input: ExportResultInput,
replacedId?: string,
assignedFileName?: string
): ExportResult {
validateIdentifier(input.planId, 'Plan ID');
validateIdentifier(input.outputId, 'Output ID');
const existingNames = [...this.#results.values()]
.filter((result) => result.id !== replacedId)
.map((result) => result.fileName);
const [generatedFileName] = uniqueExportFileNames([
...existingNames,
safeExportFileName(input.fileName, { fallback: 'output' }),
]).slice(-1);
const fileName = assignedFileName ?? generatedFileName;
const createdAt = normalizeTimestamp(
input.createdAt ?? this.#now().toISOString()
);
const mimeType = normalizeOutputMimeType(
input.mimeType ?? input.blob.type,
fileName
);
return Object.freeze({
id: input.id,
planId: input.planId,
outputId: input.outputId,
fileName: fileName ?? 'output',
blob:
input.blob.type === mimeType
? input.blob
: blobWithOutputMimeType(input.blob, mimeType, fileName),
mimeType,
role: input.role ?? 'media',
...(input.timeRange
? { timeRange: Object.freeze({ ...input.timeRange }) }
: {}),
size: input.blob.size,
createdAt,
});
}
#assertSingleResultSize(addedBytes: number): void {
if (!Number.isSafeInteger(addedBytes) || addedBytes < 0) {
throw new RangeError('Result size must be a non-negative safe integer.');
}
if (addedBytes > this.#limits.maxSingleResultBytes) {
throw new ExportResultLimitError(
'single-result-bytes',
addedBytes,
this.#limits.maxSingleResultBytes
);
}
}
#assertCountAndTotalCapacity(
addedCount: number,
addedBytes: number,
replacedBytes: number
): void {
if (this.#results.size + addedCount > this.#limits.maxResults) {
throw new ExportResultLimitError(
'result-count',
this.#results.size + addedCount,
this.#limits.maxResults
);
}
const nextTotal = this.#totalBytes - replacedBytes + addedBytes;
if (nextTotal > this.#limits.maxTotalBytes) {
throw new ExportResultLimitError(
'total-result-bytes',
nextTotal,
this.#limits.maxTotalBytes
);
}
}
#revokeObjectUrl(id: string): boolean {
const url = this.#objectUrls.get(id);
if (!url) {
return false;
}
this.#urlApi.revokeObjectURL(url);
this.#objectUrls.delete(id);
return true;
}
#clear(): void {
for (const url of this.#objectUrls.values()) {
this.#urlApi.revokeObjectURL(url);
}
this.#objectUrls.clear();
this.#results.clear();
this.#totalBytes = 0;
}
#assertActive(): void {
if (this.#disposed) {
throw new Error('This export result collection has been disposed.');
}
}
}
export type ExportResultLimit =
'result-count' | 'single-result-bytes' | 'total-result-bytes';
export class ExportResultLimitError extends RangeError {
readonly limit: ExportResultLimit;
readonly actual: number;
readonly maximum: number;
constructor(limit: ExportResultLimit, actual: number, maximum: number) {
super(
`Export result limit exceeded: ${limit} is ${actual}, max ${maximum}.`
);
this.name = 'ExportResultLimitError';
this.limit = limit;
this.actual = actual;
this.maximum = maximum;
}
}
/**
* Converts the output map returned by the job queue into managed browser
* results. Keys may be the planned output ID or its virtual output path.
*/
export function collectCommandOutputs(
plan: Pick<FFmpegCommandPlan, 'id' | 'outputs'>,
outputs: ReadonlyMap<string, CommandOutputData>,
collection: ExportResultCollection
): readonly ExportResult[] {
const inputs: ExportResultInput[] = [];
for (const planned of plan.outputs) {
const data = outputs.get(planned.id) ?? outputs.get(planned.path);
if (!data) {
throw new MissingCommandOutputError(planned.id, planned.path);
}
const blob = blobWithOutputMimeType(
data instanceof Blob ? data : Uint8Array.from(data),
planned.mediaType,
planned.fileName
);
inputs.push({
id: `${plan.id}:${planned.id}`,
planId: plan.id,
outputId: planned.id,
fileName: planned.fileName,
blob,
mimeType: planned.mediaType,
role: planned.role,
...(planned.timeRange ? { timeRange: planned.timeRange } : {}),
});
}
return collection.addMany(inputs);
}
export class MissingCommandOutputError extends Error {
readonly outputId: string;
readonly virtualPath: string;
constructor(outputId: string, virtualPath: string) {
super(`Expected output "${outputId}" was not returned by the media job.`);
this.name = 'MissingCommandOutputError';
this.outputId = outputId;
this.virtualPath = virtualPath;
}
}
function resolveLimits(
overrides: Partial<ResultCollectionLimits> | undefined
): ResultCollectionLimits {
const limits = {
...DEFAULT_RESULT_COLLECTION_LIMITS,
...overrides,
};
for (const [name, value] of Object.entries(limits)) {
if (!Number.isSafeInteger(value) || value < 1) {
throw new RangeError(`${name} must be a positive safe integer.`);
}
}
if (limits.maxSingleResultBytes > limits.maxTotalBytes) {
throw new RangeError('maxSingleResultBytes cannot exceed maxTotalBytes.');
}
return Object.freeze(limits);
}
function validateTimeRange(range: PlannedOutput['timeRange']): void {
if (
range &&
(!Number.isFinite(range.startSeconds) ||
range.startSeconds < 0 ||
!Number.isFinite(range.endSeconds) ||
range.endSeconds <= range.startSeconds)
) {
throw new RangeError(
'Result time range must have a finite start before end.'
);
}
}
function defaultObjectUrlApi(): ObjectUrlApi {
if (
typeof URL.createObjectURL !== 'function' ||
typeof URL.revokeObjectURL !== 'function'
) {
return {
createObjectURL: () => {
throw new Error('Object URLs are not supported in this environment.');
},
revokeObjectURL: () => undefined,
};
}
return URL;
}
function validateIdentifier(value: string, label: string): void {
if (value.length < 1 || value.length > 256 || hasControlCharacters(value)) {
throw new RangeError(
`${label} must contain 1 to 256 printable characters.`
);
}
}
function hasControlCharacters(value: string): boolean {
return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint < 32 || codePoint === 127;
});
}
function normalizeTimestamp(value: string): string {
const timestamp = new Date(value);
if (!Number.isFinite(timestamp.valueOf())) {
throw new RangeError('Result creation time must be a valid timestamp.');
}
return timestamp.toISOString();
}
+73
View File
@@ -0,0 +1,73 @@
interface WritableFileStream {
write(data: Blob): Promise<void>;
close(): Promise<void>;
}
interface SaveFileHandle {
createWritable(): Promise<WritableFileStream>;
}
interface SavePickerOptions {
suggestedName?: string;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
}
type SaveFilePicker = (options?: SavePickerOptions) => Promise<SaveFileHandle>;
function getSaveFilePicker(): SaveFilePicker | undefined {
const value = (
globalThis as typeof globalThis & {
showSaveFilePicker?: SaveFilePicker;
}
).showSaveFilePicker;
return typeof value === 'function' ? value.bind(globalThis) : undefined;
}
export async function saveBlob(
blob: Blob,
fileName: string,
mimeType = blob.type || 'application/octet-stream'
): Promise<'saved' | 'downloaded' | 'cancelled'> {
const picker = getSaveFilePicker();
if (picker) {
try {
const extension = fileName.includes('.')
? `.${fileName.split('.').at(-1)}`
: '';
const handle = await picker({
suggestedName: fileName,
types: [
{
description: 'Media output',
accept: { [mimeType]: extension ? [extension] : [] },
},
],
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return 'saved';
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return 'cancelled';
}
throw error;
}
}
downloadBlob(blob, fileName);
return 'downloaded';
}
export function downloadBlob(blob: Blob, fileName: string): void {
const temporaryUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = temporaryUrl;
link.download = fileName;
link.rel = 'noopener';
link.click();
setTimeout(() => URL.revokeObjectURL(temporaryUrl), 1_000);
}
+267
View File
@@ -0,0 +1,267 @@
import {
zip,
type AsyncTerminable,
type AsyncZippable,
type AsyncZipOptions,
} from 'fflate';
import { ZIP_MIME_TYPE } from './mime';
import {
isSafeArchiveLeafName,
safeExportFileName,
uniqueExportFileNames,
} from './output-name';
import type { ExportResult } from './result-collection';
import { MAX_RESULT_ZIP_ENTRIES } from '../limits';
const MEBIBYTE = 1024 * 1024;
export const DEFAULT_RESULT_ZIP_LIMITS = Object.freeze({
maxEntries: MAX_RESULT_ZIP_ENTRIES,
maxEntryBytes: 512 * MEBIBYTE,
maxTotalInputBytes: 512 * MEBIBYTE,
maxArchiveBytes: 550 * MEBIBYTE,
});
export interface ResultZipLimits {
readonly maxEntries: number;
readonly maxEntryBytes: number;
readonly maxTotalInputBytes: number;
readonly maxArchiveBytes: number;
}
export interface ResultZipEntry {
readonly fileName: string;
readonly blob: Blob;
}
export interface CreateResultZipOptions {
readonly fileName?: string;
readonly limits?: Partial<ResultZipLimits>;
readonly signal?: AbortSignal;
readonly compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
}
export interface ResultZip {
readonly blob: Blob;
readonly fileName: string;
readonly entryNames: readonly string[];
readonly totalInputBytes: number;
readonly archiveBytes: number;
}
export type ResultZipLimit =
'entry-count' | 'entry-bytes' | 'total-input-bytes' | 'archive-bytes';
export class ResultZipLimitError extends RangeError {
readonly limit: ResultZipLimit;
readonly actual: number;
readonly maximum: number;
constructor(limit: ResultZipLimit, actual: number, maximum: number) {
super(`ZIP limit exceeded: ${limit} is ${actual}, max ${maximum}.`);
this.name = 'ResultZipLimitError';
this.limit = limit;
this.actual = actual;
this.maximum = maximum;
}
}
/**
* Creates an archive with sorted, safe paths, fixed local DOS timestamps,
* normalized attributes and a fixed compression level. fflate's asynchronous
* API moves compression away from the browser rendering path.
*/
export async function createResultZip(
entries: readonly ResultZipEntry[],
options: CreateResultZipOptions = {}
): Promise<ResultZip> {
const limits = resolveZipLimits(options.limits);
throwIfAborted(options.signal);
if (entries.length < 1) {
throw new RangeError('At least one result is required to create a ZIP.');
}
if (entries.length > limits.maxEntries) {
throw new ResultZipLimitError(
'entry-count',
entries.length,
limits.maxEntries
);
}
const names = uniqueExportFileNames(
entries.map((entry) => entry.fileName),
'result'
);
const normalized = entries
.map((entry, index) => ({
blob: entry.blob,
fileName: names[index] ?? `result-${index + 1}`,
}))
.sort((left, right) => comparePaths(left.fileName, right.fileName));
let totalInputBytes = 0;
for (const entry of normalized) {
if (!isSafeArchiveLeafName(entry.fileName)) {
throw new RangeError(`Unsafe ZIP entry name: "${entry.fileName}".`);
}
if (entry.blob.size > limits.maxEntryBytes) {
throw new ResultZipLimitError(
'entry-bytes',
entry.blob.size,
limits.maxEntryBytes
);
}
totalInputBytes += entry.blob.size;
if (
!Number.isSafeInteger(totalInputBytes) ||
totalInputBytes > limits.maxTotalInputBytes
) {
throw new ResultZipLimitError(
'total-input-bytes',
totalInputBytes,
limits.maxTotalInputBytes
);
}
}
const zippable: AsyncZippable = {};
for (const entry of normalized) {
throwIfAborted(options.signal);
const bytes = new Uint8Array(await entry.blob.arrayBuffer());
zippable[entry.fileName] = [
bytes,
{
attrs: 0o644 << 16,
mtime: fixedZipTimestamp(),
os: 3,
},
];
}
const bytes = await compressZip(zippable, {
attrs: 0o644 << 16,
level: options.compressionLevel ?? 6,
mtime: fixedZipTimestamp(),
os: 3,
signal: options.signal,
});
if (bytes.byteLength > limits.maxArchiveBytes) {
throw new ResultZipLimitError(
'archive-bytes',
bytes.byteLength,
limits.maxArchiveBytes
);
}
const blob = new Blob([Uint8Array.from(bytes)], {
type: ZIP_MIME_TYPE,
});
return Object.freeze({
blob,
fileName: safeExportFileName(options.fileName ?? 'av-tools-results.zip', {
extension: 'zip',
fallback: 'av-tools-results',
}),
entryNames: Object.freeze(normalized.map((entry) => entry.fileName)),
totalInputBytes,
archiveBytes: blob.size,
});
}
export function resultEntries(
results: readonly ExportResult[]
): readonly ResultZipEntry[] {
return Object.freeze(
results.map((result) =>
Object.freeze({
fileName: result.fileName,
blob: result.blob,
})
)
);
}
interface CompressionOptions extends AsyncZipOptions {
readonly signal?: AbortSignal;
}
function compressZip(
zippable: AsyncZippable,
options: CompressionOptions
): Promise<Uint8Array> {
throwIfAborted(options.signal);
return new Promise((resolve, reject) => {
let settled = false;
let terminate: AsyncTerminable = () => undefined;
const onAbort = () => {
if (settled) {
return;
}
settled = true;
terminate();
reject(abortError());
};
options.signal?.addEventListener('abort', onAbort, { once: true });
const zipOptions: AsyncZipOptions = {
...(options.attrs === undefined ? {} : { attrs: options.attrs }),
...(options.level === undefined ? {} : { level: options.level }),
...(options.mtime === undefined ? {} : { mtime: options.mtime }),
...(options.os === undefined ? {} : { os: options.os }),
};
terminate = zip(zippable, zipOptions, (error, bytes) => {
if (settled) {
return;
}
settled = true;
options.signal?.removeEventListener('abort', onAbort);
if (error) {
reject(error);
return;
}
resolve(bytes);
});
if (options.signal?.aborted) {
onAbort();
}
});
}
function resolveZipLimits(
overrides: Partial<ResultZipLimits> | undefined
): ResultZipLimits {
const limits = {
...DEFAULT_RESULT_ZIP_LIMITS,
...overrides,
};
for (const [name, value] of Object.entries(limits)) {
if (!Number.isSafeInteger(value) || value < 1) {
throw new RangeError(`${name} must be a positive safe integer.`);
}
}
if (limits.maxEntryBytes > limits.maxTotalInputBytes) {
throw new RangeError('maxEntryBytes cannot exceed maxTotalInputBytes.');
}
return Object.freeze(limits);
}
function fixedZipTimestamp(): Date {
// fflate writes DOS local-time fields. Constructing the Date in local time
// makes those encoded fields identical across time zones.
return new Date(1980, 0, 1, 0, 0, 0, 0);
}
function comparePaths(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw abortError();
}
}
function abortError(): DOMException {
return new DOMException('ZIP creation was cancelled.', 'AbortError');
}
+863
View File
@@ -0,0 +1,863 @@
import type { LogEvent, ProgressEvent } from '@ffmpeg/ffmpeg';
import { queryCapabilities } from './capabilities';
import {
detectEngineEnvironment,
selectEngineMode,
supportsMultithread,
} from './engine-mode';
import { EngineError, toEngineError } from './ffmpeg-error';
import type {
EngineJobRequest,
EngineJobResult,
EngineJobContext,
EngineLogListener,
EngineMode,
EnginePreference,
EngineProgressListener,
EngineState,
EngineStateListener,
FFmpegAdapter,
FFmpegCapabilities,
ProbeResult,
} from './ffmpeg.types';
import { createLoadConfig } from './load-config';
import { LogBuffer } from './log-buffer';
import { normalizeProgress } from './progress-parser';
import { describeMountedInputAccess, mountJobFiles } from './virtual-fs';
import {
parseFFmpegProgress,
progressUpdateFromFFmpeg,
} from '../jobs/progress';
type FFmpegFactory = () => Promise<FFmpegAdapter>;
export const DEFAULT_JOB_TIMEOUT_MILLISECONDS = 4 * 60 * 60 * 1_000;
export const MULTITHREAD_CODEC_THREAD_LIMIT = 2;
/**
* The pinned Emscripten core can retain native heap state across heterogeneous
* FFmpeg invocations even after every virtual file has been removed. Recycling
* the idle worker before this many media jobs bounds that accumulation without
* interrupting an active command or discarding application-level results.
*/
export const MAX_EXECUTIONS_PER_ENGINE = 16;
/** The pinned tile filter aborts on a fourth consecutive contact-sheet job. */
export const MAX_CONTACT_SHEETS_PER_ENGINE = 3;
export function shouldRecycleExecutionCore(
completedExecutions: number,
nextOperation?: string,
completedContactSheets = 0
): boolean {
return (
completedExecutions >= MAX_EXECUTIONS_PER_ENGINE ||
(nextOperation === 'contact-sheet' &&
completedContactSheets >= MAX_CONTACT_SHEETS_PER_ENGINE)
);
}
async function defaultFFmpegFactory(): Promise<FFmpegAdapter> {
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
return new FFmpeg();
}
function textFromFileData(data: Uint8Array | string): string {
return typeof data === 'string' ? data : new TextDecoder().decode(data);
}
function bytesFromFileData(data: Uint8Array | string): Uint8Array {
return typeof data === 'string' ? new TextEncoder().encode(data) : data;
}
export class EngineManager {
readonly #factory: FFmpegFactory;
readonly #stateListeners = new Set<EngineStateListener>();
readonly #logListeners = new Set<EngineLogListener>();
readonly #progressListeners = new Set<EngineProgressListener>();
readonly #logs = new LogBuffer();
#engine: FFmpegAdapter | null = null;
#state: EngineState = { status: 'idle' };
#mode: EngineMode | null = null;
#preference: EnginePreference = 'automatic';
#capabilities: FFmpegCapabilities | null = null;
#warning: Extract<EngineState, { status: 'ready' }>['warning'];
#loadPromise: Promise<void> | null = null;
#activeJobId: string | null = null;
#activeExpectedDurationSeconds: number | undefined;
#activeStartedAtMilliseconds = 0;
#machineProgressLines: string[] = [];
#machineProgressSeen = false;
#cancelledJobIds = new Set<string>();
#executionsSinceLoad = 0;
#contactSheetsSinceLoad = 0;
#generation = 0;
readonly #handleLog = (event: LogEvent): void => {
this.#logs.append(event);
if (this.#state.status === 'running') {
for (const line of event.message.split(/\r?\n/gu)) {
if (/^(?:frame|fps|out_time_us|out_time|speed|progress)=/u.test(line)) {
this.#machineProgressLines.push(line);
}
if (/^progress=(?:continue|end)$/u.test(line)) {
const parsed = parseFFmpegProgress(
this.#machineProgressLines.join('\n')
);
const update = progressUpdateFromFFmpeg(
parsed,
this.#activeExpectedDurationSeconds
);
this.#machineProgressLines = [];
this.#machineProgressSeen = true;
this.#setState({
...this.#state,
...(update.progress !== undefined
? { progress: update.progress }
: {}),
...(update.speed !== undefined ? { speed: update.speed } : {}),
...(update.frame !== undefined ? { frame: update.frame } : {}),
elapsedSeconds: Math.max(
0,
(performance.now() - this.#activeStartedAtMilliseconds) / 1_000
),
});
}
}
}
for (const listener of this.#logListeners) listener(event);
};
readonly #handleProgress = (event: ProgressEvent): void => {
const normalized = normalizeProgress(event);
if (
this.#state.status === 'running' &&
!this.#machineProgressSeen &&
normalized.progress !== this.#state.progress
) {
this.#setState({
...this.#state,
progress: Math.min(0.99, normalized.progress),
elapsedSeconds: Math.max(
0,
(performance.now() - this.#activeStartedAtMilliseconds) / 1_000
),
});
}
for (const listener of this.#progressListeners) listener(normalized);
};
constructor(factory: FFmpegFactory = defaultFFmpegFactory) {
this.#factory = factory;
}
get state(): EngineState {
return this.#state;
}
get preference(): EnginePreference {
return this.#preference;
}
setPreference(preference: EnginePreference): void {
if (this.#state.status === 'running') {
throw new EngineError(
'busy',
'Engine mode cannot be changed while a job is running.'
);
}
if (preference !== this.#preference) {
this.#preference = preference;
this.dispose();
}
}
subscribe(listener: EngineStateListener): () => void {
this.#stateListeners.add(listener);
listener(this.#state);
return () => this.#stateListeners.delete(listener);
}
onLog(listener: EngineLogListener): () => void {
this.#logListeners.add(listener);
return () => this.#logListeners.delete(listener);
}
onProgress(listener: EngineProgressListener): () => void {
this.#progressListeners.add(listener);
return () => this.#progressListeners.delete(listener);
}
async initialize(): Promise<void> {
if (this.#activeJobId || this.#state.status === 'running') {
throw new EngineError(
'busy',
'The FFmpeg engine cannot be initialized while another operation is active.'
);
}
if (this.#state.status === 'ready') return;
if (this.#loadPromise) return this.#loadPromise;
this.#loadPromise = this.#initializeInternal().finally(() => {
this.#loadPromise = null;
});
return this.#loadPromise;
}
async #initializeInternal(): Promise<void> {
const environment = detectEngineEnvironment();
if (!environment.secureContext || typeof Worker === 'undefined') {
const error = new EngineError(
'unsupported-environment',
'FFmpeg requires a secure browser context with Web Worker support.'
);
this.#setState({ status: 'error', error });
throw error;
}
const requestedMode = selectEngineMode(environment, this.#preference);
const unsupportedMultithread =
this.#preference === 'prefer-multithread' &&
!supportsMultithread(environment);
const initialWarning = unsupportedMultithread
? {
code: 'multithread-unavailable' as const,
message:
'Multithreading is unavailable in this browser context; using the single-thread compatibility core.',
}
: undefined;
try {
await this.#loadMode(requestedMode, initialWarning);
} catch (firstError) {
if (requestedMode !== 'multithread') {
const details = describeInitializationFailure(firstError);
this.#terminateEngine();
const error = new EngineError(
'load-failed',
'The FFmpeg compatibility core could not be loaded.',
{ cause: firstError, details }
);
this.#setState({ status: 'error', error });
throw error;
}
this.#terminateEngine();
const warning = {
code: 'multithread-load-failed' as const,
message:
'The multithread core failed to load. The app recovered with the single-thread compatibility core.',
};
this.#setState({
status: 'recovering',
previousMode: 'multithread',
warning,
});
try {
await this.#loadMode('single-thread', warning);
} catch (fallbackError) {
const details = [
`Multithread core: ${describeInitializationFailure(firstError)}`,
`Single-thread core: ${describeInitializationFailure(fallbackError)}`,
].join('\n');
this.#terminateEngine();
const error = new EngineError(
'load-failed',
'Neither FFmpeg execution core could be loaded.',
{
cause: fallbackError,
details,
}
);
this.#setState({ status: 'error', error });
throw error;
}
}
}
async #loadMode(
mode: EngineMode,
warning?: Extract<EngineState, { status: 'ready' }>['warning']
): Promise<void> {
const generation = ++this.#generation;
this.#setState({
status: 'loading',
mode,
progress: 0.05,
stage: 'Loading FFmpeg wrapper',
});
const engine = await this.#factory();
if (generation !== this.#generation) return;
this.#engine = engine;
this.#mode = mode;
engine.on('log', this.#handleLog);
engine.on('progress', this.#handleProgress);
this.#setState({
status: 'loading',
mode,
progress: 0.2,
stage: 'Loading local WebAssembly core',
});
await engine.load(createLoadConfig(mode));
if (generation !== this.#generation) return;
this.#setState({
status: 'loading',
mode,
progress: 0.75,
stage: 'Inspecting codec capabilities',
});
const capabilities = await queryCapabilities(engine);
if (generation !== this.#generation) return;
this.#capabilities = capabilities;
this.#executionsSinceLoad = 0;
this.#contactSheetsSinceLoad = 0;
this.#warning = warning;
this.#setState({ status: 'ready', mode, capabilities, warning });
}
async runJob(request: EngineJobRequest): Promise<EngineJobResult> {
if (this.#activeJobId) {
throw new EngineError(
'busy',
'Another FFmpeg operation is already running.'
);
}
await this.initialize();
const coreWorkload = request.coreWorkload ?? request.operation;
await this.#recycleExecutionCoreIfNeeded(coreWorkload);
const engine = this.#engine;
const mode = this.#mode;
if (!engine || !mode || this.#state.status !== 'ready') {
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
}
const mounted = await mountJobFiles(
engine,
request.id,
request.inputs,
request.outputs.map(({ name }) => name)
);
const generation = this.#generation;
const temporaryPaths = (request.temporaryFiles ?? []).map(
(temporary, index) => {
const extensionMatch = /\.([a-zA-Z0-9]{1,10})$/.exec(temporary.name);
const extension = extensionMatch?.[1]?.toLowerCase() ?? 'tmp';
return `${mounted.workDirectory}/temporary-${index}.${extension}`;
}
);
this.#activeJobId = request.id;
this.#activeExpectedDurationSeconds = request.expectedDurationSeconds;
this.#activeStartedAtMilliseconds = performance.now();
this.#machineProgressLines = [];
this.#machineProgressSeen = false;
this.#logs.clear();
this.#setState({
status: 'running',
mode,
jobId: request.id,
operation: request.operation,
inputMode: mounted.inputMode,
inputBytes: mounted.inputBytes,
copiedInputBytes: mounted.copiedInputBytes,
progress: 0,
});
this.#appendInternalLog({
type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr',
message: describeMountedInputAccess(mounted),
});
let completedResult: Omit<EngineJobResult, 'timings'> | undefined;
let conversionMilliseconds: number;
let outputReadMilliseconds: number;
let cleanupMilliseconds: number;
let coreAborted = false;
try {
const context: EngineJobContext = {
inputPaths: mounted.inputPaths,
temporaryPaths,
outputPaths: mounted.outputPaths,
workDirectory: mounted.workDirectory,
};
for (const [index, temporary] of (
request.temporaryFiles ?? []
).entries()) {
const path = temporaryPaths[index];
if (path) {
const content =
typeof temporary.content === 'function'
? temporary.content(context)
: temporary.content;
await engine.writeFile(
path,
typeof content === 'string' ? content : new Uint8Array(content)
);
}
}
const args = prepareEngineArguments(
request.buildArguments(context),
mode,
mounted.inputPaths,
mounted.outputPaths
);
const conversionStartedAt = performance.now();
const exitCode = await engine.exec(
args,
engineJobTimeoutMilliseconds(request)
);
if (generation === this.#generation && this.#engine === engine) {
this.#executionsSinceLoad += 1;
if (coreWorkload === 'contact-sheet') {
this.#contactSheetsSinceLoad += 1;
}
}
conversionMilliseconds = performance.now() - conversionStartedAt;
if (this.#cancelledJobIds.delete(request.id)) {
throw new EngineError('cancelled', 'The FFmpeg job was cancelled.');
}
if (exitCode !== 0) {
coreAborted = /(?:^|\n)Aborted\(\)(?:\n|$)/u.test(this.#logs.text());
throw new EngineError(
'execution-failed',
`FFmpeg exited with code ${exitCode}.`,
{
exitCode,
details: this.#logs.text(),
}
);
}
const outputReadStartedAt = performance.now();
const outputs = await Promise.all(
mounted.outputPaths.map(async (path, index) => {
const bytes = bytesFromFileData(await engine.readFile(path));
if (bytes.byteLength === 0) {
throw new EngineError(
'execution-failed',
`FFmpeg produced an empty expected output: ${request.outputs[index]?.name ?? `output-${index}`}.`,
{ details: this.#logs.text() }
);
}
return {
name: request.outputs[index]?.name ?? `output-${index}`,
mimeType:
request.outputs[index]?.mimeType ?? 'application/octet-stream',
bytes,
};
})
);
outputReadMilliseconds = performance.now() - outputReadStartedAt;
completedResult = {
id: request.id,
exitCode: 0,
inputMode: mounted.inputMode,
inputBytes: mounted.inputBytes,
copiedInputBytes: mounted.copiedInputBytes,
outputs,
logs: this.#logs.snapshot(),
};
} catch (error) {
if (this.#cancelledJobIds.delete(request.id)) {
throw new EngineError('cancelled', 'The FFmpeg job was cancelled.', {
cause: error,
});
}
throw toEngineError(error);
} finally {
const cleanupStartedAt = performance.now();
this.#activeJobId = null;
this.#activeExpectedDurationSeconds = undefined;
this.#machineProgressLines = [];
this.#machineProgressSeen = false;
if (generation === this.#generation && this.#engine === engine) {
for (const path of temporaryPaths) {
try {
await engine.deleteFile(path);
} catch {
// Cleanup below will recover or surface a later filesystem fault.
}
}
const cleanupSucceeded = await mounted.cleanup();
if (
(!cleanupSucceeded || coreAborted) &&
generation === this.#generation &&
this.#engine === engine
) {
this.#logs.append({
type: 'stderr',
message: coreAborted
? 'The FFmpeg WebAssembly core aborted; recreating it before the next job.'
: 'Filesystem cleanup could not be verified; recreating the FFmpeg engine.',
});
this.#terminateEngine();
this.#setState({ status: 'recovering', previousMode: mode });
try {
await this.initialize();
} catch {
// initialize() publishes the controlled error state.
}
} else if (this.#isRunning()) {
const capabilities = this.#capabilities;
if (capabilities) {
this.#setState({
status: 'ready',
mode,
capabilities,
warning: this.#warning,
});
}
}
}
cleanupMilliseconds = performance.now() - cleanupStartedAt;
}
if (!completedResult) {
throw new EngineError(
'execution-failed',
'FFmpeg completed without returning an execution result.'
);
}
return {
...completedResult,
timings: Object.freeze({
conversionMilliseconds,
outputReadMilliseconds,
cleanupMilliseconds,
}),
};
}
async probe(file: File, timeoutMilliseconds = 30_000): Promise<ProbeResult> {
await this.initialize();
const engine = this.#engine;
const mode = this.#mode;
if (!engine || !mode || this.#state.status !== 'ready') {
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
}
if (this.#activeJobId) {
throw new EngineError(
'busy',
'Another FFmpeg operation is already running.'
);
}
const id = crypto.randomUUID();
const mounted = await mountJobFiles(engine, id, [file], ['probe.json']);
const probeOutput = mounted.outputPaths[0];
const inputPath = mounted.inputPaths[0];
if (!probeOutput || !inputPath) {
throw new EngineError(
'filesystem-failed',
'Could not allocate probe paths.'
);
}
const activeProbeId = `probe-${id}`;
this.#activeJobId = activeProbeId;
this.#activeExpectedDurationSeconds = undefined;
this.#activeStartedAtMilliseconds = performance.now();
this.#machineProgressLines = [];
this.#machineProgressSeen = false;
this.#logs.clear();
this.#setState({
status: 'running',
mode,
jobId: this.#activeJobId,
operation: 'Inspect media',
inputMode: mounted.inputMode,
inputBytes: mounted.inputBytes,
copiedInputBytes: mounted.copiedInputBytes,
});
this.#appendInternalLog({
type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr',
message: describeMountedInputAccess(mounted),
});
const watchdog = setTimeout(() => this.cancelActive(), timeoutMilliseconds);
try {
const exitCode = await engine.ffprobe([
'-v',
'error',
'-show_format',
'-show_streams',
'-show_chapters',
'-of',
'json',
inputPath,
'-o',
probeOutput,
]);
let parsed: unknown;
try {
parsed = JSON.parse(
textFromFileData(await engine.readFile(probeOutput))
) as unknown;
} catch (readError) {
throw new EngineError(
'probe-failed',
exitCode === 0
? 'ffprobe did not produce valid JSON.'
: `ffprobe exited with code ${exitCode} without a valid report.`,
{
cause: readError,
exitCode,
details: this.#logs.text(),
}
);
}
if (!isProbeReportShape(parsed)) {
throw new EngineError(
'probe-failed',
'ffprobe produced JSON that does not have a media report shape.',
{ exitCode, details: this.#logs.text() }
);
}
if (!probeReportHasStreams(parsed)) {
throw new EngineError(
'probe-failed',
'ffprobe did not find any media streams.',
{ exitCode, details: this.#logs.text() }
);
}
if (exitCode !== 0) {
// The pinned 0.12.10 browser core is observed to return -1 after
// successfully writing a complete report. The file is the source of
// truth only in this narrow, validated case; absent/malformed output
// above still fails.
this.#logs.append({
type: 'stderr',
message: `Compatibility note: ffprobe returned ${exitCode}, but its complete JSON report was validated and accepted.`,
});
}
return {
json: parsed,
inputMode: mounted.inputMode,
inputBytes: mounted.inputBytes,
copiedInputBytes: mounted.copiedInputBytes,
logs: this.#logs.snapshot(),
};
} catch (error) {
if (this.#cancelledJobIds.delete(activeProbeId)) {
throw new EngineError('cancelled', 'The media probe was cancelled.', {
cause: error,
});
}
throw error instanceof EngineError
? error
: new EngineError('probe-failed', 'The media could not be inspected.', {
cause: error,
details: this.#logs.text(),
});
} finally {
clearTimeout(watchdog);
this.#activeJobId = null;
this.#machineProgressLines = [];
this.#machineProgressSeen = false;
if (this.#engine === engine) {
const cleanupSucceeded = await mounted.cleanup();
if (!cleanupSucceeded && this.#engine === engine) {
this.#logs.append({
type: 'stderr',
message:
'Probe filesystem cleanup could not be verified; recreating the FFmpeg engine.',
});
this.#terminateEngine();
this.#setState({ status: 'recovering', previousMode: mode });
try {
await this.initialize();
} catch {
// initialize() publishes the controlled error state.
}
} else if (this.#isRunning()) {
const capabilities = this.#capabilities;
if (capabilities) {
this.#setState({
status: 'ready',
mode,
capabilities,
warning: this.#warning,
});
}
}
}
}
}
async cancelActive(): Promise<void> {
const activeJobId = this.#activeJobId;
const previousMode = this.#mode;
if (!activeJobId || !previousMode) return;
this.#cancelledJobIds.add(activeJobId);
this.#terminateEngine();
this.#activeJobId = null;
this.#activeExpectedDurationSeconds = undefined;
this.#machineProgressLines = [];
this.#machineProgressSeen = false;
this.#setState({ status: 'recovering', previousMode });
try {
await this.initialize();
} catch {
// initialize() already publishes the controlled error state.
}
}
dispose(): void {
this.#terminateEngine();
this.#activeJobId = null;
this.#setState({ status: 'idle' });
}
#terminateEngine(): void {
this.#generation += 1;
if (this.#engine) {
this.#engine.off('log', this.#handleLog);
this.#engine.off('progress', this.#handleProgress);
this.#engine.terminate();
}
this.#engine = null;
this.#mode = null;
this.#capabilities = null;
this.#warning = undefined;
this.#executionsSinceLoad = 0;
this.#contactSheetsSinceLoad = 0;
}
async #recycleExecutionCoreIfNeeded(nextOperation: string): Promise<void> {
if (
!shouldRecycleExecutionCore(
this.#executionsSinceLoad,
nextOperation,
this.#contactSheetsSinceLoad
)
) {
return;
}
const previousMode = this.#mode;
if (
!previousMode ||
this.#activeJobId ||
this.#state.status === 'running'
) {
return;
}
const contactSheetGuard =
nextOperation === 'contact-sheet' &&
this.#contactSheetsSinceLoad >= MAX_CONTACT_SHEETS_PER_ENGINE;
this.#appendInternalLog({
type: 'stderr',
message: contactSheetGuard
? `Refreshing the idle FFmpeg WebAssembly core before contact sheet ${this.#contactSheetsSinceLoad + 1} to release retained tile-filter state.`
: `Refreshing the idle FFmpeg WebAssembly core after ${this.#executionsSinceLoad} media jobs to release retained native heap state.`,
});
this.#terminateEngine();
this.#setState({ status: 'recovering', previousMode });
await this.initialize();
}
#setState(state: EngineState): void {
this.#state = state;
for (const listener of this.#stateListeners) listener(state);
}
#appendInternalLog(event: LogEvent): void {
this.#logs.append(event);
for (const listener of this.#logListeners) listener(event);
}
#isRunning(): boolean {
return this.#state.status === 'running';
}
}
/**
* The pinned core-mt build can stall on Chromium when FFmpeg auto-selects a
* large codec thread count. Keep the WASM pthread core, but bound decoder and
* encoder codec threads to the smallest upstream-reported stable setting.
*/
export function prepareEngineArguments(
original: readonly string[],
mode: EngineMode,
inputPaths: readonly string[],
outputPaths: readonly string[]
): string[] {
const args = [...original];
if (mode !== 'multithread' || args.length === 0) return args;
const constrained: string[] = [];
const inputPathSet = new Set(inputPaths);
const outputPathSet = new Set(outputPaths);
let constrainedOutput = false;
for (const [index, argument] of args.entries()) {
if (argument === '-i' && inputPathSet.has(args[index + 1] ?? '')) {
constrained.push(
'-threads',
String(MULTITHREAD_CODEC_THREAD_LIMIT),
'-i'
);
continue;
}
if (outputPathSet.has(argument)) {
constrained.push(
'-threads',
String(MULTITHREAD_CODEC_THREAD_LIMIT),
argument
);
constrainedOutput = true;
continue;
}
constrained.push(argument);
}
// Segment/image sequence plans can use a pattern instead of an exact
// expected output path. Every typed command plan keeps its output URL last.
if (!constrainedOutput && constrained.length > 0) {
constrained.splice(
constrained.length - 1,
0,
'-threads',
String(MULTITHREAD_CODEC_THREAD_LIMIT)
);
}
return constrained;
}
export function engineJobTimeoutMilliseconds(
request: Pick<
EngineJobRequest,
'expectedDurationSeconds' | 'timeoutMilliseconds'
>
): number {
const inferred =
request.expectedDurationSeconds === undefined
? DEFAULT_JOB_TIMEOUT_MILLISECONDS
: Math.max(120_000, request.expectedDurationSeconds * 30_000);
return Math.min(
request.timeoutMilliseconds ?? inferred,
DEFAULT_JOB_TIMEOUT_MILLISECONDS
);
}
export const engineManager = new EngineManager();
function describeInitializationFailure(error: unknown): string {
if (error instanceof EngineError) {
return error.details ? `${error.message}\n${error.details}` : error.message;
}
if (error instanceof Error) {
return error.stack ?? error.message;
}
return String(error);
}
function isProbeReportShape(value: unknown): boolean {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
const report = value as Record<string, unknown>;
return (
(Array.isArray(report.streams) || report.format !== undefined) &&
(report.format === undefined ||
(typeof report.format === 'object' && report.format !== null))
);
}
function probeReportHasStreams(value: unknown): boolean {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
const streams = (value as Record<string, unknown>).streams;
return Array.isArray(streams) && streams.length > 0;
}
+36
View File
@@ -0,0 +1,36 @@
import type { EngineMode } from './ffmpeg.types';
import { FFMPEG_CORE_VERSION } from '../version';
export function resolveFromAppBase(
relativePath: string,
locationHref = window.location.href,
viteBase = import.meta.env.BASE_URL
): string {
const appBase = new URL(viteBase, locationHref);
return new URL(relativePath, appBase).href;
}
export interface CoreAssetUrls {
coreURL: string;
wasmURL: string;
workerURL?: string;
}
export function getCoreAssetUrls(
mode: EngineMode,
resolver: (path: string) => string = resolveFromAppBase
): CoreAssetUrls {
const directory =
mode === 'multithread'
? `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/mt/`
: `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/st/`;
const base = resolver(directory);
const urls: CoreAssetUrls = {
coreURL: new URL('ffmpeg-core.js', base).href,
wasmURL: new URL('ffmpeg-core.wasm', base).href,
};
if (mode === 'multithread') {
urls.workerURL = new URL('ffmpeg-core.worker.js', base).href;
}
return urls;
}
+74
View File
@@ -0,0 +1,74 @@
import type { LogEvent } from '@ffmpeg/ffmpeg';
import {
parseBuildConfiguration,
parseCodecs,
parseFilters,
parseFormats,
parseNamedComponents,
} from './capability-parser';
import { EngineError } from './ffmpeg-error';
import type { FFmpegAdapter, FFmpegCapabilities } from './ffmpeg.types';
async function captureCommand(
ffmpeg: FFmpegAdapter,
args: string[]
): Promise<string> {
const messages: string[] = [];
const listener = ({ message }: LogEvent) => messages.push(message);
ffmpeg.on('log', listener);
try {
const exitCode = await ffmpeg.exec(args);
if (exitCode !== 0) {
throw new EngineError(
'execution-failed',
`FFmpeg capability query failed with exit code ${exitCode}`,
{ exitCode, details: messages.join('\n') }
);
}
} finally {
ffmpeg.off('log', listener);
}
return messages.join('\n');
}
export async function queryCapabilities(
ffmpeg: FFmpegAdapter
): Promise<FFmpegCapabilities> {
// A core is a single process and must not receive overlapping executions.
const versionText = await captureCommand(ffmpeg, ['-version']);
const buildText = await captureCommand(ffmpeg, ['-buildconf']);
const formatsText = await captureCommand(ffmpeg, [
'-hide_banner',
'-formats',
]);
const codecsText = await captureCommand(ffmpeg, ['-hide_banner', '-codecs']);
const encodersText = await captureCommand(ffmpeg, [
'-hide_banner',
'-encoders',
]);
const decodersText = await captureCommand(ffmpeg, [
'-hide_banner',
'-decoders',
]);
const filtersText = await captureCommand(ffmpeg, [
'-hide_banner',
'-filters',
]);
const formats = parseFormats(formatsText);
const codecs = parseCodecs(codecsText);
const explicitEncoders = parseNamedComponents(encodersText);
for (const encoder of explicitEncoders) codecs.encoders.add(encoder);
const explicitDecoders = parseNamedComponents(decodersText);
for (const decoder of explicitDecoders) codecs.decoders.add(decoder);
return {
versionText,
buildConfiguration: parseBuildConfiguration(`${versionText}\n${buildText}`),
demuxers: formats.demuxers,
muxers: formats.muxers,
decoders: codecs.decoders,
encoders: codecs.encoders,
filters: parseFilters(filtersText),
};
}
+109
View File
@@ -0,0 +1,109 @@
import type {
FFmpegCapabilities,
SerializableFFmpegCapabilities,
} from './ffmpeg.types';
function normalizedLines(text: string): string[] {
return text
.replaceAll('\r', '')
.split('\n')
.map((line) => line.trimEnd());
}
export function parseFormats(text: string): {
demuxers: Set<string>;
muxers: Set<string>;
} {
const demuxers = new Set<string>();
const muxers = new Set<string>();
for (const line of normalizedLines(text)) {
const match = /^ ([D ])([E ])\s+([^\s]+)\s/.exec(line);
if (!match) continue;
const names = (match[3] ?? '')
.split(',')
.filter((name) => /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name));
for (const name of names) {
if (match[1] === 'D') demuxers.add(name);
if (match[2] === 'E') muxers.add(name);
}
}
return { demuxers, muxers };
}
export function parseCodecs(text: string): {
decoders: Set<string>;
encoders: Set<string>;
} {
const decoders = new Set<string>();
const encoders = new Set<string>();
for (const line of normalizedLines(text)) {
const match = /^ ([D.])([E.])[VASD.TILX]{4}\s+([^\s]+)\s/.exec(line);
if (!match) continue;
const name = match[3];
if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/u.test(name)) continue;
if (match[1] === 'D') decoders.add(name);
if (match[2] === 'E') encoders.add(name);
}
return { decoders, encoders };
}
export function parseNamedComponents(text: string): Set<string> {
const names = new Set<string>();
for (const line of normalizedLines(text)) {
const match = /^ [A-Z.]{1,8}\s+([^\s=]+)\s/.exec(line);
if (match?.[1]) names.add(match[1]);
}
return names;
}
export function parseFilters(text: string): Set<string> {
const names = new Set<string>();
for (const line of normalizedLines(text)) {
const match = /^ [TSC.]{3}\s+([^\s]+)\s+[AVN|]+->[AVN|]+/.exec(line);
if (match?.[1]) names.add(match[1]);
}
return names;
}
export function parseBuildConfiguration(text: string): string[] {
const line = normalizedLines(text).find((entry) =>
entry.trimStart().startsWith('configuration:')
);
if (!line) return [];
return line
.slice(line.indexOf(':') + 1)
.trim()
.split(/\s+/)
.filter(Boolean);
}
export function serializeCapabilities(
capabilities: FFmpegCapabilities
): SerializableFFmpegCapabilities {
const sorted = (values: Set<string>) =>
[...values].sort((left, right) => left.localeCompare(right, 'en'));
return {
versionText: capabilities.versionText,
buildConfiguration: [...capabilities.buildConfiguration],
demuxers: sorted(capabilities.demuxers),
muxers: sorted(capabilities.muxers),
decoders: sorted(capabilities.decoders),
encoders: sorted(capabilities.encoders),
filters: sorted(capabilities.filters),
};
}
export function deserializeCapabilities(
capabilities: SerializableFFmpegCapabilities
): FFmpegCapabilities {
return {
versionText: capabilities.versionText,
buildConfiguration: [...capabilities.buildConfiguration],
demuxers: new Set(capabilities.demuxers),
muxers: new Set(capabilities.muxers),
decoders: new Set(capabilities.decoders),
encoders: new Set(capabilities.encoders),
filters: new Set(capabilities.filters),
};
}
+55
View File
@@ -0,0 +1,55 @@
import { engineManager } from './EngineManager';
export interface DisposableRuntimeResource {
dispose(): void;
}
type DeferredCallback = (callback: () => void) => void;
/**
* Reference-counts an application-wide runtime resource. Final disposal is
* deferred by one microtask so React StrictMode's development cleanup/remount
* cycle can reacquire the same singleton without terminating a healthy engine.
*/
export class DeferredResourceLifecycle {
readonly #resource: DisposableRuntimeResource;
readonly #defer: DeferredCallback;
#leaseCount = 0;
#generation = 0;
constructor(
resource: DisposableRuntimeResource,
defer: DeferredCallback = globalThis.queueMicrotask.bind(globalThis)
) {
this.#resource = resource;
this.#defer = defer;
}
get leaseCount(): number {
return this.#leaseCount;
}
acquire(): () => void {
this.#leaseCount += 1;
this.#generation += 1;
let active = true;
return () => {
if (!active) return;
active = false;
this.#leaseCount -= 1;
const releaseGeneration = ++this.#generation;
if (this.#leaseCount !== 0) return;
this.#defer(() => {
if (this.#leaseCount === 0 && this.#generation === releaseGeneration) {
this.#resource.dispose();
}
});
};
}
}
export const engineManagerLifecycle = new DeferredResourceLifecycle(
engineManager
);
+40
View File
@@ -0,0 +1,40 @@
import type {
EngineEnvironment,
EngineMode,
EnginePreference,
} from './ffmpeg.types';
export function detectEngineEnvironment(
scope: Window = window
): EngineEnvironment {
return {
secureContext: scope.isSecureContext === true,
crossOriginIsolated: scope.crossOriginIsolated === true,
sharedArrayBuffer: typeof globalThis.SharedArrayBuffer !== 'undefined',
hardwareConcurrency: scope.navigator.hardwareConcurrency,
};
}
export function supportsMultithread(environment: EngineEnvironment): boolean {
return (
environment.secureContext &&
environment.crossOriginIsolated &&
environment.sharedArrayBuffer
);
}
export function selectEngineMode(
environment: EngineEnvironment,
preference: EnginePreference
): EngineMode {
if (preference === 'force-single-thread') {
return 'single-thread';
}
return supportsMultithread(environment) ? 'multithread' : 'single-thread';
}
export function describeEngineMode(mode: EngineMode): string {
return mode === 'multithread'
? 'Multithreaded'
: 'Single-threaded compatibility mode';
}
+38
View File
@@ -0,0 +1,38 @@
import type { EngineErrorShape } from './ffmpeg.types';
export class EngineError extends Error implements EngineErrorShape {
readonly code: EngineErrorShape['code'];
readonly details?: string;
readonly exitCode?: number;
constructor(
code: EngineErrorShape['code'],
message: string,
options: {
cause?: unknown;
details?: string;
exitCode?: number;
} = {}
) {
super(message, { cause: options.cause });
this.name = 'EngineError';
this.code = code;
this.details = options.details;
this.exitCode = options.exitCode;
}
}
export function toEngineError(error: unknown): EngineError {
if (error instanceof EngineError) {
return error;
}
if (error instanceof Error) {
return new EngineError('execution-failed', error.message, {
cause: error,
details: error.stack,
});
}
return new EngineError('execution-failed', 'Unknown FFmpeg error', {
details: String(error),
});
}
+177
View File
@@ -0,0 +1,177 @@
import type { FFmpeg, FileData, LogEvent, ProgressEvent } from '@ffmpeg/ffmpeg';
export type EngineMode = 'multithread' | 'single-thread';
export type EngineInputMode = 'workerfs' | 'memory-copy';
export type EnginePreference =
'automatic' | 'prefer-multithread' | 'force-single-thread';
export interface EngineEnvironment {
secureContext: boolean;
crossOriginIsolated: boolean;
sharedArrayBuffer: boolean;
hardwareConcurrency?: number;
}
export interface FFmpegCapabilities {
versionText: string;
buildConfiguration: string[];
demuxers: Set<string>;
muxers: Set<string>;
decoders: Set<string>;
encoders: Set<string>;
filters: Set<string>;
}
export interface SerializableFFmpegCapabilities {
versionText: string;
buildConfiguration: string[];
demuxers: string[];
muxers: string[];
decoders: string[];
encoders: string[];
filters: string[];
}
export interface EngineWarning {
code: 'multithread-unavailable' | 'multithread-load-failed';
message: string;
}
export type EngineState =
| { status: 'idle' }
| {
status: 'loading';
mode: EngineMode;
progress?: number;
stage?: string;
}
| {
status: 'ready';
mode: EngineMode;
capabilities: FFmpegCapabilities;
warning?: EngineWarning;
}
| {
status: 'running';
mode: EngineMode;
jobId: string;
operation: string;
inputMode: EngineInputMode;
inputBytes: number;
copiedInputBytes: number;
progress?: number;
elapsedSeconds?: number;
speed?: number;
frame?: number;
}
| {
status: 'recovering';
previousMode: EngineMode;
warning?: EngineWarning;
}
| { status: 'error'; error: EngineErrorShape };
export interface EngineErrorShape {
code:
| 'unsupported-environment'
| 'load-failed'
| 'execution-failed'
| 'probe-failed'
| 'cancelled'
| 'busy'
| 'filesystem-failed';
message: string;
details?: string;
exitCode?: number;
}
export interface EngineOutputRequest {
name: string;
mimeType: string;
}
export interface EngineJobContext {
inputPaths: readonly string[];
temporaryPaths: readonly string[];
outputPaths: readonly string[];
workDirectory: string;
}
export interface EngineTemporaryFileRequest {
name: string;
content:
string | Uint8Array | ((context: EngineJobContext) => string | Uint8Array);
}
export interface EngineJobRequest {
id: string;
/** Human-readable operation shown in the engine state. */
operation: string;
/** Stable typed plan operation used for core-lifecycle workload policy. */
coreWorkload?: string;
inputs: readonly File[];
temporaryFiles?: readonly EngineTemporaryFileRequest[];
outputs: readonly EngineOutputRequest[];
buildArguments: (context: EngineJobContext) => readonly string[];
expectedDurationSeconds?: number;
timeoutMilliseconds?: number;
}
export interface EngineJobOutput {
name: string;
mimeType: string;
bytes: Uint8Array;
}
export interface EngineJobTimings {
/** Time spent inside FFmpeg's exec call. */
conversionMilliseconds: number;
/** Time spent copying expected outputs out of the WebAssembly filesystem. */
outputReadMilliseconds: number;
/** Time spent deleting temporary paths and unmounting the job filesystem. */
cleanupMilliseconds: number;
}
export interface EngineJobResult {
id: string;
exitCode: 0;
inputMode: EngineInputMode;
inputBytes: number;
copiedInputBytes: number;
outputs: readonly EngineJobOutput[];
logs: readonly LogEvent[];
timings: EngineJobTimings;
}
export interface ProbeResult {
json: unknown;
inputMode: EngineInputMode;
inputBytes: number;
copiedInputBytes: number;
logs: readonly LogEvent[];
}
export interface FFmpegAdapter extends Pick<
FFmpeg,
| 'loaded'
| 'load'
| 'exec'
| 'ffprobe'
| 'terminate'
| 'on'
| 'off'
| 'writeFile'
| 'mount'
| 'unmount'
| 'readFile'
| 'listDir'
| 'deleteFile'
| 'createDir'
| 'deleteDir'
> {
readFile(path: string, encoding?: string): Promise<FileData>;
}
export type EngineStateListener = (state: EngineState) => void;
export type EngineLogListener = (event: LogEvent) => void;
export type EngineProgressListener = (event: ProgressEvent) => void;
+7
View File
@@ -0,0 +1,7 @@
import type { FFMessageLoadConfig } from '@ffmpeg/ffmpeg';
import { getCoreAssetUrls } from './asset-urls';
import type { EngineMode } from './ffmpeg.types';
export function createLoadConfig(mode: EngineMode): FFMessageLoadConfig {
return getCoreAssetUrls(mode);
}
+43
View File
@@ -0,0 +1,43 @@
import type { LogEvent } from '@ffmpeg/ffmpeg';
export class LogBuffer {
readonly #maximumEntries: number;
readonly #maximumCharacters: number;
#entries: LogEvent[] = [];
#characters = 0;
constructor(maximumEntries = 2_000, maximumCharacters = 500_000) {
this.#maximumEntries = maximumEntries;
this.#maximumCharacters = maximumCharacters;
}
append(event: LogEvent): void {
const safeEvent = {
type: event.type,
message: event.message.replaceAll('\u0000', ''),
};
this.#entries.push(safeEvent);
this.#characters += safeEvent.message.length;
while (
this.#entries.length > this.#maximumEntries ||
this.#characters > this.#maximumCharacters
) {
const removed = this.#entries.shift();
this.#characters -= removed?.message.length ?? 0;
}
}
clear(): void {
this.#entries = [];
this.#characters = 0;
}
snapshot(): readonly LogEvent[] {
return this.#entries.map((event) => ({ ...event }));
}
text(): string {
return this.#entries.map(({ message }) => message).join('\n');
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { ProgressEvent } from '@ffmpeg/ffmpeg';
export function normalizeProgress(event: ProgressEvent): ProgressEvent {
const finiteProgress = Number.isFinite(event.progress) ? event.progress : 0;
const finiteTime = Number.isFinite(event.time) ? event.time : 0;
return {
progress: Math.min(1, Math.max(0, finiteProgress)),
time: Math.max(0, finiteTime),
};
}
export function progressPercent(event: ProgressEvent): number {
return Math.round(normalizeProgress(event).progress * 100);
}
+191
View File
@@ -0,0 +1,191 @@
import type { FFFSType } from '@ffmpeg/ffmpeg';
import { EngineError } from './ffmpeg-error';
import type { EngineInputMode, FFmpegAdapter } from './ffmpeg.types';
const WORKER_FS = 'WORKERFS' as FFFSType;
const DEFAULT_COPY_LIMIT_BYTES = 256 * 1024 * 1024;
function safeJobId(jobId: string): string {
const normalized = jobId
.replaceAll(/[^a-zA-Z0-9_-]/g, '-')
.replaceAll(/-+/g, '-')
.replaceAll(/^[-_]+|[-_]+$/g, '')
.slice(0, 80);
return normalized || 'anonymous';
}
function safeExtension(fileName: string): string {
const match = /\.([a-zA-Z0-9]{1,10})$/.exec(fileName);
return match?.[1]?.toLowerCase() ?? 'bin';
}
async function createDirIfNeeded(
ffmpeg: FFmpegAdapter,
path: string
): Promise<void> {
try {
await ffmpeg.createDir(path);
} catch {
// Emscripten exposes no portable "exists" primitive. Creating an existing
// parent is harmless, and a later mount/write will surface real failures.
}
}
async function deleteFileIfPresent(
ffmpeg: FFmpegAdapter,
path: string
): Promise<void> {
try {
await ffmpeg.deleteFile(path);
} catch {
// Cleanup is intentionally idempotent.
}
}
async function deleteDirIfEmpty(
ffmpeg: FFmpegAdapter,
path: string
): Promise<void> {
try {
await ffmpeg.deleteDir(path);
} catch {
// Parent or a terminated filesystem may already be gone.
}
}
export interface MountedJobFiles {
inputPaths: readonly string[];
outputPaths: readonly string[];
workDirectory: string;
inputMode: EngineInputMode;
/** Total size of every mounted source. */
inputBytes: number;
/** Exact bytes copied into MEMFS; always zero for WORKERFS. */
copiedInputBytes: number;
/**
* Returns false when the per-job directories can still be observed after
* cleanup. Callers must discard that engine instance in that case.
*/
cleanup: () => Promise<boolean>;
}
export async function mountJobFiles(
ffmpeg: FFmpegAdapter,
jobId: string,
files: readonly File[],
outputNames: readonly string[],
copyLimitBytes = DEFAULT_COPY_LIMIT_BYTES
): Promise<MountedJobFiles> {
const id = safeJobId(jobId);
const inputDirectory = `/input/job-${id}`;
const workDirectory = `/work/job-${id}`;
const inputPaths = files.map(
(file, index) =>
`${inputDirectory}/source-${index}.${safeExtension(file.name)}`
);
const outputPaths = outputNames.map((name, index) => {
const extension = safeExtension(name);
return `${workDirectory}/output-${index}.${extension}`;
});
const inputBytes = files.reduce((total, file) => total + file.size, 0);
await createDirIfNeeded(ffmpeg, '/input');
await createDirIfNeeded(ffmpeg, '/work');
await createDirIfNeeded(ffmpeg, inputDirectory);
await createDirIfNeeded(ffmpeg, workDirectory);
let mounted = false;
let inputMode: MountedJobFiles['inputMode'] = 'workerfs';
try {
await ffmpeg.mount(
WORKER_FS,
{
blobs: files.map((file, index) => ({
name: inputPaths[index]?.split('/').at(-1) ?? `source-${index}.bin`,
data: file,
})),
},
inputDirectory
);
mounted = true;
} catch (mountError) {
inputMode = 'memory-copy';
if (inputBytes > copyLimitBytes) {
throw new EngineError(
'filesystem-failed',
'This browser could not mount the source file without copying it, and the file exceeds the compatibility-mode memory limit.',
{
cause: mountError,
details: `${inputBytes} bytes exceeds ${copyLimitBytes} bytes`,
}
);
}
try {
for (const [index, file] of files.entries()) {
const path = inputPaths[index];
if (path) {
await ffmpeg.writeFile(
path,
new Uint8Array(await file.arrayBuffer())
);
}
}
} catch (copyError) {
throw new EngineError(
'filesystem-failed',
'The input could not be copied into the FFmpeg virtual filesystem.',
{ cause: copyError }
);
}
}
return {
inputPaths,
outputPaths,
workDirectory,
inputMode,
inputBytes,
copiedInputBytes: inputMode === 'memory-copy' ? inputBytes : 0,
cleanup: async () => {
for (const path of outputPaths) await deleteFileIfPresent(ffmpeg, path);
if (mounted) {
try {
await ffmpeg.unmount(inputDirectory);
} catch {
// The engine may have been terminated by cancellation.
}
} else {
for (const path of inputPaths) await deleteFileIfPresent(ffmpeg, path);
}
await deleteDirIfEmpty(ffmpeg, inputDirectory);
await deleteDirIfEmpty(ffmpeg, workDirectory);
try {
const [inputEntries, workEntries] = await Promise.all([
ffmpeg.listDir('/input'),
ffmpeg.listDir('/work'),
]);
const inputName = inputDirectory.split('/').at(-1);
const workName = workDirectory.split('/').at(-1);
return (
!inputEntries.some((entry) => entry.name === inputName) &&
!workEntries.some((entry) => entry.name === workName)
);
} catch {
// A terminated engine has no inspectable filesystem. Its caller owns
// recovery; a live caller conservatively recreates the instance.
return false;
}
},
};
}
export function describeMountedInputAccess(
mounted: Pick<
MountedJobFiles,
'inputMode' | 'inputBytes' | 'copiedInputBytes'
>
): string {
return mounted.inputMode === 'workerfs'
? `Input access: ${mounted.inputBytes} bytes mounted read-only through WORKERFS; 0 bytes copied into engine memory.`
: `Input compatibility fallback: WORKERFS mounting was unavailable, so ${mounted.copiedInputBytes} bytes were copied into engine memory.`;
}
+2740
View File
File diff suppressed because it is too large Load Diff
+121
View File
@@ -0,0 +1,121 @@
import type { JobMetadataStore, MediaJob } from './job.types';
export class MemoryJobMetadataStore implements JobMetadataStore {
readonly #jobs = new Map<string, MediaJob>();
async list(): Promise<readonly MediaJob[]> {
return structuredClone(
[...this.#jobs.values()].sort((left, right) =>
left.createdAt.localeCompare(right.createdAt)
)
);
}
async put(job: MediaJob): Promise<void> {
this.#jobs.set(job.id, structuredClone(job));
}
async delete(id: string): Promise<void> {
this.#jobs.delete(id);
}
async clear(): Promise<void> {
this.#jobs.clear();
}
}
export interface IndexedDbJobStoreOptions {
readonly databaseName?: string;
readonly storeName?: string;
readonly indexedDB?: IDBFactory;
}
export class IndexedDbJobMetadataStore implements JobMetadataStore {
readonly #databaseName: string;
readonly #storeName: string;
readonly #factory: IDBFactory;
#database?: Promise<IDBDatabase>;
constructor(options: IndexedDbJobStoreOptions = {}) {
const factory = options.indexedDB ?? globalThis.indexedDB;
if (!factory) {
throw new Error('IndexedDB is unavailable');
}
this.#factory = factory;
this.#databaseName = options.databaseName ?? 'av-tools';
this.#storeName = options.storeName ?? 'media-jobs';
}
async list(): Promise<readonly MediaJob[]> {
const request = (await this.#store('readonly')).getAll();
const result = await requestResult<MediaJob[]>(request);
return Object.freeze(
result.sort((left, right) =>
left.createdAt.localeCompare(right.createdAt)
)
);
}
async put(job: MediaJob): Promise<void> {
const store = await this.#store('readwrite');
const completion = transactionComplete(store.transaction);
const request = store.put(structuredClone(job));
await requestResult<IDBValidKey>(request);
await completion;
}
async delete(id: string): Promise<void> {
const store = await this.#store('readwrite');
const completion = transactionComplete(store.transaction);
await requestResult<undefined>(store.delete(id));
await completion;
}
async clear(): Promise<void> {
const store = await this.#store('readwrite');
const completion = transactionComplete(store.transaction);
await requestResult<undefined>(store.clear());
await completion;
}
async #store(mode: IDBTransactionMode): Promise<IDBObjectStore> {
const database = await (this.#database ??= this.#open());
return database
.transaction(this.#storeName, mode)
.objectStore(this.#storeName);
}
#open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = this.#factory.open(this.#databaseName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(this.#storeName)) {
request.result.createObjectStore(this.#storeName, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
reject(request.error ?? new Error('Could not open IndexedDB'));
request.onblocked = () =>
reject(new Error('IndexedDB upgrade is blocked'));
});
}
}
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
reject(request.error ?? new Error('IndexedDB request failed'));
});
}
function transactionComplete(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () =>
reject(transaction.error ?? new Error('IndexedDB transaction failed'));
transaction.onabort = () =>
reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
});
}
+5
View File
@@ -0,0 +1,5 @@
export * from './durable-store';
export * from './job-queue';
export * from './job.types';
export * from './object-url-registry';
export * from './progress';
+669
View File
@@ -0,0 +1,669 @@
import { weightedJobProgress, validateStepWeights } from './progress';
import type {
JobMetadataStore,
JobExecutionResult,
JobQueueListener,
JobRecoveryProvider,
MediaJob,
MediaJobDefinition,
MediaJobError,
MediaJobExecutor,
MediaJobStep,
MediaJobStatus,
} from './job.types';
export interface MediaJobQueueOptions {
readonly executor: MediaJobExecutor;
readonly store: JobMetadataStore;
readonly recoveryProvider?: JobRecoveryProvider;
readonly now?: () => Date;
}
export class MediaJobQueue {
readonly #executor: MediaJobExecutor;
readonly #store: JobMetadataStore;
readonly #recoveryProvider?: JobRecoveryProvider;
readonly #now: () => Date;
readonly #listeners = new Set<JobQueueListener>();
readonly #definitions = new Map<string, MediaJobDefinition>();
readonly #results = new Map<string, readonly JobExecutionResult[]>();
readonly #cancellationTasks = new Map<string, Promise<void>>();
#jobs = new Map<string, MediaJob>();
#activeJobId?: string;
#activeAbort?: AbortController;
#scheduled = false;
#initialized = false;
#persistenceTail: Promise<void> = Promise.resolve();
constructor(options: MediaJobQueueOptions) {
this.#executor = options.executor;
this.#store = options.store;
this.#recoveryProvider = options.recoveryProvider;
this.#now = options.now ?? (() => new Date());
}
async initialize(): Promise<void> {
if (this.#initialized) {
return;
}
const stored = await this.#store.list();
this.#jobs = new Map(stored.map((job) => [job.id, freezeJob(job)]));
for (const job of stored) {
if (isInterruptedStatus(job.status)) {
await this.#recoverPersistedJob(job);
}
}
this.#initialized = true;
this.#emit();
this.#schedule();
}
list(): readonly MediaJob[] {
return Object.freeze(
[...this.#jobs.values()].sort((left, right) =>
left.createdAt.localeCompare(right.createdAt)
)
);
}
get(id: string): MediaJob | undefined {
return this.#jobs.get(id);
}
get activeJobId(): string | undefined {
return this.#activeJobId;
}
getResults(id: string): readonly JobExecutionResult[] | undefined {
return this.#results.get(id);
}
releaseResults(id: string): boolean {
return this.#results.delete(id);
}
subscribe(listener: JobQueueListener): () => void {
this.#listeners.add(listener);
listener(this.list(), this.#activeJobId);
return () => this.#listeners.delete(listener);
}
async enqueue(definition: MediaJobDefinition): Promise<MediaJob> {
this.#assertInitialized();
validateDefinition(definition);
if (this.#jobs.has(definition.id)) {
throw new RangeError(`Job already exists: ${definition.id}`);
}
this.#definitions.set(definition.id, freezeDefinition(definition));
const timestamp = this.#timestamp();
const job = freezeJob({
id: definition.id,
operation: definition.operation,
status: 'queued',
steps: definition.steps.map((step) => ({
id: step.id,
name: step.name,
weight: step.weight,
status: 'queued',
progress: 0,
planId: step.plan.id,
})),
progress: 0,
createdAt: timestamp,
updatedAt: timestamp,
retryCount: 0,
});
await this.#replace(job);
this.#schedule();
return job;
}
async cancel(id: string): Promise<boolean> {
this.#assertInitialized();
const job = this.#jobs.get(id);
if (!job || isTerminalStatus(job.status)) {
return false;
}
if (job.status === 'queued') {
await this.#update(id, {
status: 'cancelled',
completedAt: this.#timestamp(),
steps: job.steps.map((step) =>
step.status === 'queued' ? { ...step, status: 'cancelled' } : step
),
});
return true;
}
if (id !== this.#activeJobId) {
return false;
}
const existing = this.#cancellationTasks.get(id);
if (existing) {
await existing;
return true;
}
const task = this.#cancelActive(id);
this.#cancellationTasks.set(id, task);
try {
await task;
} finally {
this.#cancellationTasks.delete(id);
}
return true;
}
async retry(
id: string,
replacementDefinition?: MediaJobDefinition
): Promise<MediaJob> {
this.#assertInitialized();
const job = this.#jobs.get(id);
if (!job || (job.status !== 'failed' && job.status !== 'cancelled')) {
throw new RangeError('Only failed or cancelled jobs can be retried');
}
const definition = replacementDefinition ?? this.#definitions.get(id);
if (!definition) {
throw new Error('The command plans for this job are no longer available');
}
validateDefinition(definition);
if (definition.id !== id) {
throw new TypeError('Replacement definition must retain the job ID');
}
this.#definitions.set(id, freezeDefinition(definition));
this.#results.delete(id);
const retried = await this.#update(id, {
status: 'queued',
progress: 0,
startedAt: undefined,
completedAt: undefined,
error: undefined,
recoveryNote: undefined,
retryCount: job.retryCount + 1,
steps: definition.steps.map((step) => ({
id: step.id,
name: step.name,
weight: step.weight,
status: 'queued',
progress: 0,
planId: step.plan.id,
})),
});
this.#schedule();
return retried;
}
async remove(id: string): Promise<boolean> {
this.#assertInitialized();
const job = this.#jobs.get(id);
if (!job) {
return false;
}
if (!isTerminalStatus(job.status)) {
throw new Error('Active or queued jobs cannot be removed');
}
this.#jobs.delete(id);
this.#definitions.delete(id);
this.#results.delete(id);
await this.#deletePersisted(id);
this.#emit();
return true;
}
async clearCompleted(): Promise<void> {
const removable = this.list().filter((job) => isTerminalStatus(job.status));
for (const job of removable) {
this.#jobs.delete(job.id);
this.#definitions.delete(job.id);
this.#results.delete(job.id);
await this.#deletePersisted(job.id);
}
this.#emit();
}
async waitForIdle(): Promise<void> {
while (
this.#activeJobId !== undefined ||
this.list().some((job) => job.status === 'queued')
) {
await new Promise<void>((resolve) => {
const unsubscribe = this.subscribe((jobs, activeJobId) => {
if (
activeJobId === undefined &&
!jobs.some((job) => job.status === 'queued')
) {
unsubscribe();
resolve();
}
});
});
}
}
async #recoverPersistedJob(job: MediaJob): Promise<void> {
const recovering = freezeJob({
...job,
status: 'recovering',
updatedAt: this.#timestamp(),
recoveryNote:
'The previous browser session ended before this job reached a terminal state.',
});
this.#jobs.set(job.id, recovering);
await this.#persist(recovering);
const definition = await this.#recoveryProvider?.restore(recovering);
if (definition) {
validateDefinition(definition);
if (definition.id !== job.id) {
throw new TypeError('Recovered job definition has a mismatched ID');
}
this.#definitions.set(job.id, freezeDefinition(definition));
await this.#update(job.id, {
status: 'queued',
progress: 0,
startedAt: undefined,
completedAt: undefined,
error: undefined,
steps: definition.steps.map((step) => ({
id: step.id,
name: step.name,
weight: step.weight,
status: 'queued',
progress: 0,
planId: step.plan.id,
})),
});
return;
}
await this.#update(job.id, {
status: 'failed',
completedAt: this.#timestamp(),
error: {
code: 'session-interrupted',
message:
'The job was interrupted and its source handles or command plans could not be restored.',
recoverable: true,
},
steps: job.steps.map((step) =>
step.status === 'completed'
? step
: {
...step,
status: 'failed',
progress: Math.min(step.progress, 0.999),
}
),
});
}
async #run(definition: MediaJobDefinition): Promise<void> {
const id = definition.id;
this.#activeJobId = id;
this.#activeAbort = new AbortController();
const completedResults: JobExecutionResult[] = [];
this.#emit();
try {
const existing = this.#requireJob(id);
await this.#update(id, {
status: 'preparing',
startedAt: existing.startedAt ?? this.#timestamp(),
});
await this.#executor.prepare?.(
this.#requireJob(id),
this.#activeAbort.signal
);
for (let index = 0; index < definition.steps.length; index += 1) {
this.#throwIfCancelling(id);
const definitionStep = definition.steps[index];
if (!definitionStep) {
continue;
}
await this.#setStep(id, index, { status: 'running', progress: 0 });
await this.#update(id, { status: 'running' });
let result = await this.#executor.execute(definitionStep.plan, {
signal: this.#activeAbort.signal,
onProgress: (update) => {
if (update.progress !== undefined) {
this.#setStepProgress(id, index, update.progress);
}
},
onLog: () => {
// Engine-specific log buffers own full logs. The queue deliberately
// persists only bounded job metadata.
},
});
this.#throwIfCancelling(id);
if (this.#executor.readOutputs) {
await this.#update(id, { status: 'reading-output' });
result = await this.#executor.readOutputs(
definitionStep.plan,
result,
this.#activeAbort.signal
);
}
completedResults.push(result);
await this.#setStep(id, index, { status: 'completed', progress: 1 });
}
await this.#executor.cleanup(id);
this.#results.set(id, Object.freeze(completedResults));
await this.#update(id, {
status: 'completed',
progress: 1,
completedAt: this.#timestamp(),
});
} catch (error) {
const cancellation = this.#cancellationTasks.get(id);
if (cancellation) {
await cancellation;
} else {
await this.#handleFailure(id, error);
}
} finally {
if (this.#activeJobId === id) {
this.#activeJobId = undefined;
this.#activeAbort = undefined;
}
this.#emit();
this.#schedule();
}
}
async #cancelActive(id: string): Promise<void> {
this.#results.delete(id);
await this.#update(id, {
status: 'cancelling',
steps: this.#requireJob(id).steps.map((step) =>
step.status === 'queued' || step.status === 'running'
? {
...step,
status: 'cancelled',
progress: Math.min(step.progress, 0.999),
}
: step
),
});
this.#activeAbort?.abort(new DOMException('Job cancelled', 'AbortError'));
try {
await this.#executor.cancelCurrentJob();
try {
await this.#executor.cleanup(id);
} catch {
// Termination may already remove the virtual filesystem. Recovery below
// is authoritative before another job may start.
}
await this.#update(id, { status: 'recovering' });
await this.#executor.recoverAfterCancellation();
await this.#update(id, {
status: 'cancelled',
completedAt: this.#timestamp(),
});
this.#results.delete(id);
} catch (error) {
await this.#update(id, {
status: 'failed',
completedAt: this.#timestamp(),
error: toMediaJobError(error, 'cancellation-recovery-failed', false),
});
}
}
async #handleFailure(id: string, error: unknown): Promise<void> {
this.#results.delete(id);
try {
await this.#executor.cleanup(id);
} catch (cleanupError) {
await this.#update(id, {
status: 'recovering',
recoveryNote:
'Cleanup failed. The execution engine is being recreated before the queue continues.',
});
try {
await this.#executor.cancelCurrentJob();
await this.#executor.recoverAfterCancellation();
} catch {
error = new AggregateError(
[error, cleanupError],
'Job and engine cleanup both failed'
);
}
}
const job = this.#requireJob(id);
await this.#update(id, {
status: 'failed',
completedAt: this.#timestamp(),
error: toMediaJobError(error, 'execution-failed', true),
steps: job.steps.map((step) =>
step.status === 'running'
? {
...step,
status: 'failed',
progress: Math.min(step.progress, 0.999),
}
: step
),
});
}
#setStepProgress(id: string, index: number, progress: number): void {
const job = this.#jobs.get(id);
if (!job || job.status !== 'running') {
return;
}
const steps = job.steps.map((step, stepIndex) =>
stepIndex === index
? { ...step, progress: Math.min(0.99, Math.max(0, progress)) }
: step
);
const updated = freezeJob({
...job,
steps,
progress: weightedJobProgress(steps),
updatedAt: this.#timestamp(),
});
this.#jobs.set(id, updated);
void this.#persist(updated).catch(() => undefined);
this.#emit();
}
async #setStep(
id: string,
index: number,
patch: Pick<MediaJobStep, 'status' | 'progress'>
): Promise<void> {
const job = this.#requireJob(id);
const steps = job.steps.map((step, stepIndex) =>
stepIndex === index ? { ...step, ...patch } : step
);
await this.#update(id, {
steps,
progress: weightedJobProgress(steps),
});
}
#throwIfCancelling(id: string): void {
const status = this.#requireJob(id).status;
if (
status === 'cancelling' ||
status === 'recovering' ||
status === 'cancelled'
) {
throw new DOMException('Job cancelled', 'AbortError');
}
this.#activeAbort?.signal.throwIfAborted();
}
#schedule(): void {
if (this.#scheduled || this.#activeJobId || !this.#initialized) {
return;
}
this.#scheduled = true;
queueMicrotask(() => {
this.#scheduled = false;
if (this.#activeJobId) {
return;
}
const next = this.list().find(
(job) => job.status === 'queued' && this.#definitions.has(job.id)
);
const definition = next ? this.#definitions.get(next.id) : undefined;
if (definition) {
void this.#run(definition);
}
});
}
async #update(
id: string,
patch: Partial<Omit<MediaJob, 'id' | 'createdAt' | 'updatedAt'>>
): Promise<MediaJob> {
const current = this.#requireJob(id);
const withOptionalFields = { ...current, ...patch };
if (patch.startedAt === undefined && 'startedAt' in patch) {
delete (withOptionalFields as { startedAt?: string }).startedAt;
}
if (patch.completedAt === undefined && 'completedAt' in patch) {
delete (withOptionalFields as { completedAt?: string }).completedAt;
}
if (patch.error === undefined && 'error' in patch) {
delete (withOptionalFields as { error?: MediaJobError }).error;
}
if (patch.recoveryNote === undefined && 'recoveryNote' in patch) {
delete (withOptionalFields as { recoveryNote?: string }).recoveryNote;
}
return this.#replace(
freezeJob({ ...withOptionalFields, updatedAt: this.#timestamp() })
);
}
async #replace(job: MediaJob): Promise<MediaJob> {
this.#jobs.set(job.id, job);
await this.#persist(job);
this.#emit();
return job;
}
#requireJob(id: string): MediaJob {
const job = this.#jobs.get(id);
if (!job) {
throw new RangeError(`Unknown job: ${id}`);
}
return job;
}
#timestamp(): string {
return this.#now().toISOString();
}
#emit(): void {
const jobs = this.list();
for (const listener of this.#listeners) {
try {
listener(jobs, this.#activeJobId);
} catch {
// A view listener must not interrupt execution or durable state updates.
}
}
}
#persist(job: MediaJob): Promise<void> {
const operation = this.#persistenceTail.then(() => this.#store.put(job));
this.#persistenceTail = operation.catch(() => undefined);
return operation;
}
#deletePersisted(id: string): Promise<void> {
const operation = this.#persistenceTail.then(() => this.#store.delete(id));
this.#persistenceTail = operation.catch(() => undefined);
return operation;
}
#assertInitialized(): void {
if (!this.#initialized) {
throw new Error('MediaJobQueue.initialize() must be called first');
}
}
}
export function toMediaJobError(
error: unknown,
fallbackCode: string,
recoverable: boolean
): MediaJobError {
if (isMediaJobError(error)) {
return Object.freeze({ ...error });
}
if (error instanceof Error) {
return Object.freeze({
code: fallbackCode,
message: error.message || error.name,
recoverable,
...(error.stack ? { details: error.stack.slice(0, 4_000) } : {}),
});
}
return Object.freeze({
code: fallbackCode,
message: typeof error === 'string' ? error : 'Unknown media job error',
recoverable,
});
}
function validateDefinition(definition: MediaJobDefinition): void {
if (!definition.id.trim() || !definition.operation.trim()) {
throw new TypeError('Job id and operation are required');
}
validateStepWeights(definition.steps);
if (
definition.steps.some(
(step) =>
!step.name.trim() ||
!step.plan.id.trim() ||
step.plan.id !== step.plan.id.trim()
)
) {
throw new TypeError('Job step names and command plan IDs are required');
}
}
function freezeDefinition(definition: MediaJobDefinition): MediaJobDefinition {
return Object.freeze({
...definition,
steps: Object.freeze(
definition.steps.map((step) => Object.freeze({ ...step }))
),
});
}
function freezeJob(job: MediaJob): MediaJob {
return Object.freeze({
...job,
steps: Object.freeze(job.steps.map((step) => Object.freeze({ ...step }))),
...(job.error ? { error: Object.freeze({ ...job.error }) } : {}),
});
}
function isTerminalStatus(status: MediaJobStatus): boolean {
return (
status === 'completed' || status === 'failed' || status === 'cancelled'
);
}
function isInterruptedStatus(status: MediaJobStatus): boolean {
return (
status === 'queued' ||
status === 'preparing' ||
status === 'running' ||
status === 'reading-output' ||
status === 'cancelling' ||
status === 'recovering'
);
}
function isMediaJobError(value: unknown): value is MediaJobError {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Partial<MediaJobError>;
return (
typeof candidate.code === 'string' &&
typeof candidate.message === 'string' &&
typeof candidate.recoverable === 'boolean'
);
}
+113
View File
@@ -0,0 +1,113 @@
import type { FFmpegCommandPlan } from '../commands/command-plan';
export type MediaJobStatus =
| 'queued'
| 'preparing'
| 'running'
| 'reading-output'
| 'cancelling'
| 'recovering'
| 'completed'
| 'failed'
| 'cancelled';
export type MediaJobStepStatus =
'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
export interface MediaJobError {
readonly code: string;
readonly message: string;
readonly stepId?: string;
readonly recoverable: boolean;
readonly details?: string;
}
export interface MediaJobStep {
readonly id: string;
readonly name: string;
readonly weight: number;
readonly status: MediaJobStepStatus;
readonly progress: number;
readonly planId: string;
}
export interface MediaJob {
readonly id: string;
readonly operation: string;
readonly status: MediaJobStatus;
readonly steps: readonly MediaJobStep[];
readonly progress: number;
readonly createdAt: string;
readonly updatedAt: string;
readonly startedAt?: string;
readonly completedAt?: string;
readonly retryCount: number;
readonly error?: MediaJobError;
readonly recoveryNote?: string;
}
export interface MediaJobDefinition {
readonly id: string;
readonly operation: string;
readonly steps: readonly {
readonly id: string;
readonly name: string;
readonly weight: number;
readonly plan: FFmpegCommandPlan;
}[];
}
export interface JobExecutionResult {
readonly outputs: ReadonlyMap<string, Blob | Uint8Array>;
readonly diagnostics?: readonly string[];
}
export interface JobProgressUpdate {
readonly progress?: number;
readonly elapsedSeconds?: number;
readonly speed?: number;
readonly frame?: number;
readonly message?: string;
}
export interface JobExecutionContext {
readonly signal: AbortSignal;
readonly onProgress: (update: JobProgressUpdate) => void;
readonly onLog: (message: string) => void;
}
/**
* Engine-facing interface. Cancellation must terminate the active FFmpeg
* worker; aborting the signal alone is deliberately insufficient.
*/
export interface MediaJobExecutor {
prepare?(job: MediaJob, signal: AbortSignal): Promise<void>;
execute(
plan: FFmpegCommandPlan,
context: JobExecutionContext
): Promise<JobExecutionResult>;
readOutputs?(
plan: FFmpegCommandPlan,
result: JobExecutionResult,
signal: AbortSignal
): Promise<JobExecutionResult>;
cleanup(jobId: string): Promise<void>;
cancelCurrentJob(): Promise<void>;
recoverAfterCancellation(): Promise<void>;
}
export interface JobMetadataStore {
list(): Promise<readonly MediaJob[]>;
put(job: MediaJob): Promise<void>;
delete(id: string): Promise<void>;
clear(): Promise<void>;
}
export interface JobRecoveryProvider {
restore(job: MediaJob): Promise<MediaJobDefinition | undefined>;
}
export type JobQueueListener = (
jobs: readonly MediaJob[],
activeJobId: string | undefined
) => void;
+45
View File
@@ -0,0 +1,45 @@
export interface ObjectUrlApi {
createObjectURL(blob: Blob): string;
revokeObjectURL(url: string): void;
}
export class ObjectUrlRegistry {
readonly #api: ObjectUrlApi;
readonly #urls = new Map<string, string>();
constructor(api: ObjectUrlApi = URL) {
this.#api = api;
}
replace(key: string, blob: Blob): string {
this.revoke(key);
const url = this.#api.createObjectURL(blob);
this.#urls.set(key, url);
return url;
}
get(key: string): string | undefined {
return this.#urls.get(key);
}
revoke(key: string): boolean {
const url = this.#urls.get(key);
if (!url) {
return false;
}
this.#api.revokeObjectURL(url);
this.#urls.delete(key);
return true;
}
clear(): void {
for (const url of this.#urls.values()) {
this.#api.revokeObjectURL(url);
}
this.#urls.clear();
}
get size(): number {
return this.#urls.size;
}
}
+124
View File
@@ -0,0 +1,124 @@
import type { JobProgressUpdate, MediaJobStep } from './job.types';
export interface ParsedFFmpegProgress {
readonly frame?: number;
readonly fps?: number;
readonly outTimeSeconds?: number;
readonly speed?: number;
readonly terminal: boolean;
}
export function parseFFmpegProgress(text: string): ParsedFFmpegProgress {
const values = new Map<string, string>();
for (const line of text.split(/\r?\n/gu)) {
const separator = line.indexOf('=');
if (separator > 0) {
values.set(
line.slice(0, separator).trim(),
line.slice(separator + 1).trim()
);
}
}
const frame = parseOptionalFinite(values.get('frame'));
const fps = parseOptionalFinite(values.get('fps'));
const outTimeSeconds =
parseMicroseconds(values.get('out_time_us')) ??
parseProgressTime(values.get('out_time'));
const speed = parseSpeed(values.get('speed'));
return Object.freeze({
...(frame !== undefined ? { frame } : {}),
...(fps !== undefined ? { fps } : {}),
...(outTimeSeconds !== undefined ? { outTimeSeconds } : {}),
...(speed !== undefined ? { speed } : {}),
terminal: values.get('progress') === 'end',
});
}
export function progressUpdateFromFFmpeg(
parsed: ParsedFFmpegProgress,
expectedDurationSeconds?: number
): JobProgressUpdate {
const progress = parsed.terminal
? 0.99
: parsed.outTimeSeconds !== undefined &&
expectedDurationSeconds !== undefined &&
expectedDurationSeconds > 0
? Math.min(
0.99,
Math.max(0, parsed.outTimeSeconds / expectedDurationSeconds)
)
: undefined;
return Object.freeze({
...(progress !== undefined ? { progress } : {}),
...(parsed.speed !== undefined ? { speed: parsed.speed } : {}),
...(parsed.frame !== undefined ? { frame: parsed.frame } : {}),
});
}
export function weightedJobProgress(steps: readonly MediaJobStep[]): number {
const totalWeight = steps.reduce((sum, step) => sum + step.weight, 0);
if (totalWeight <= 0) {
return 0;
}
const completedWeight = steps.reduce(
(sum, step) => sum + step.weight * clampProgress(step.progress),
0
);
return Math.min(0.999, completedWeight / totalWeight);
}
export function validateStepWeights(
steps: readonly { readonly weight: number; readonly id: string }[]
): void {
if (steps.length === 0) {
throw new RangeError('A media job requires at least one step');
}
const ids = new Set<string>();
for (const step of steps) {
if (
!step.id ||
ids.has(step.id) ||
!Number.isFinite(step.weight) ||
step.weight <= 0
) {
throw new RangeError(
'Job step IDs must be unique and weights must be positive'
);
}
ids.add(step.id);
}
}
function parseMicroseconds(value: string | undefined): number | undefined {
const microseconds = parseOptionalFinite(value);
return microseconds === undefined ? undefined : microseconds / 1_000_000;
}
function parseProgressTime(value: string | undefined): number | undefined {
if (!value) {
return undefined;
}
const match = /^(\d+):([0-5]\d):([0-5]\d(?:\.\d+)?)$/u.exec(value);
if (!match) {
return undefined;
}
return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
}
function parseSpeed(value: string | undefined): number | undefined {
return value?.endsWith('x')
? parseOptionalFinite(value.slice(0, -1))
: undefined;
}
function parseOptionalFinite(value: string | undefined): number | undefined {
if (value === undefined || value === 'N/A') {
return undefined;
}
const result = Number(value);
return Number.isFinite(result) ? result : undefined;
}
function clampProgress(value: number): number {
return Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0));
}
+11
View File
@@ -0,0 +1,11 @@
/**
* A managed result batch must remain saveable as one ZIP together with its
* optional export report. The archive therefore reserves one of its 256
* entries for that report.
*/
export const MAX_GENERATED_OUTPUT_FILES = 255;
export const MAX_RESULT_ZIP_ENTRIES = MAX_GENERATED_OUTPUT_FILES + 1;
export const MAX_SPLIT_MARKERS = MAX_GENERATED_OUTPUT_FILES - 1;
export const GENERATED_OUTPUT_LIMIT_EXPLANATION =
'The 255-file limit keeps the managed result collection exportable as one ZIP with an optional report.';
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ApplicationRuntime } from './app/ApplicationRuntime';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ApplicationRuntime />
</StrictMode>
);
+308
View File
@@ -0,0 +1,308 @@
import type { MediaProbe, MediaStreamProbe } from './media.types';
import { mimeTypeFromFileName } from './mime-types';
export type BrowserPlaybackSupport =
'probably' | 'maybe' | 'unsupported' | 'unknown';
export interface BrowserPlaybackAssessment {
support: BrowserPlaybackSupport;
candidates: string[];
selectedMimeType?: string;
reason: string;
needsPreviewProxy: boolean;
}
export type CanPlayType = (mimeType: string) => CanPlayTypeResult;
export interface PlaybackCandidateOptions {
fileName?: string;
declaredMimeType?: string;
}
export type GeneratedPreviewKind = 'audio' | 'video' | 'image' | 'other';
export interface GeneratedPreviewInput {
readonly fileName: string;
readonly mimeType: string;
readonly probe?: MediaProbe;
}
export interface GeneratedPreviewAssessment {
readonly kind: GeneratedPreviewKind;
readonly previewable: boolean;
readonly support: BrowserPlaybackSupport;
readonly candidates: readonly string[];
readonly reason: string;
}
const BROWSER_IMAGE_PREVIEW_TYPES = new Set([
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/webp',
]);
export function buildPlaybackMimeCandidates(
probe: MediaProbe,
options: PlaybackCandidateOptions = {}
): string[] {
const hasVideo = probe.streams.some(
(stream) =>
stream.type === 'video' && stream.disposition.attached_pic !== true
);
const baseMimeType =
normalizeMimeType(options.declaredMimeType) ??
(options.fileName ? mimeTypeFromFileName(options.fileName) : undefined) ??
mimeTypeFromFormats(probe.formatNames, hasVideo);
if (baseMimeType === undefined) {
return [];
}
const codecNames = playbackCodecNames(probe.streams);
const withCodecs =
codecNames.length > 0
? `${baseMimeType}; codecs="${codecNames.join(', ')}"`
: undefined;
return [...new Set([withCodecs, baseMimeType].filter(isString))];
}
export function assessBrowserPlayback(
probe: MediaProbe,
canPlayType: CanPlayType,
options: PlaybackCandidateOptions = {}
): BrowserPlaybackAssessment {
const candidates = buildPlaybackMimeCandidates(probe, options);
if (candidates.length === 0) {
return {
support: 'unknown',
candidates,
reason: 'The source container or MIME type could not be identified.',
needsPreviewProxy: false,
};
}
let maybeCandidate: string | undefined;
for (const candidate of candidates) {
const result = canPlayType(candidate);
if (result === 'probably') {
return {
support: 'probably',
candidates,
selectedMimeType: candidate,
reason:
'The browser reports that it can probably decode this container and codec combination.',
needsPreviewProxy: false,
};
}
if (result === 'maybe' && maybeCandidate === undefined) {
maybeCandidate = candidate;
}
}
if (maybeCandidate !== undefined) {
return {
support: 'maybe',
candidates,
selectedMimeType: maybeCandidate,
reason:
'The browser may decode this source; actual playback can still fail.',
needsPreviewProxy: false,
};
}
return {
support: 'unsupported',
candidates,
reason:
'The browser reports no native support. FFmpeg may still be able to read the source.',
needsPreviewProxy: true,
};
}
/**
* Checks a generated result against the browser before a Preview action is
* offered. A broad audio/video MIME prefix is not enough: when a round-trip
* probe is available, its container and codecs are included in the query.
*/
export function assessGeneratedResultPreview(
input: GeneratedPreviewInput,
canPlayType: CanPlayType
): GeneratedPreviewAssessment {
const mimeType = input.mimeType.trim().toLowerCase().split(';')[0]?.trim();
const kind = previewKind(mimeType);
if (kind === 'image') {
const previewable =
mimeType !== undefined && BROWSER_IMAGE_PREVIEW_TYPES.has(mimeType);
return {
kind,
previewable,
support: previewable ? 'maybe' : 'unsupported',
candidates: mimeType ? [mimeType] : [],
reason: previewable
? 'The result uses a commonly supported browser image format; an actual decode error will still be reported by the preview.'
: 'This generated image format is not in the browser-preview allowlist.',
};
}
if (kind !== 'audio' && kind !== 'video') {
return {
kind,
previewable: false,
support: 'unknown',
candidates: [],
reason: 'This result type has no in-browser media preview.',
};
}
const playback = input.probe
? assessBrowserPlayback(input.probe, canPlayType, {
fileName: input.fileName,
declaredMimeType: mimeType,
})
: assessDeclaredPlayback(mimeType, canPlayType);
return {
kind,
previewable:
playback.support === 'probably' || playback.support === 'maybe',
support: playback.support,
candidates: playback.candidates,
reason: playback.reason,
};
}
function assessDeclaredPlayback(
mimeType: string | undefined,
canPlayType: CanPlayType
): BrowserPlaybackAssessment {
if (
mimeType === undefined ||
!/^(?:audio|video)\/[a-z0-9.+-]+$/.test(mimeType)
) {
return {
support: 'unknown',
candidates: [],
reason: 'The generated media type could not be identified.',
needsPreviewProxy: false,
};
}
const result = canPlayType(mimeType);
if (result === 'probably' || result === 'maybe') {
return {
support: result,
candidates: [mimeType],
selectedMimeType: mimeType,
reason:
result === 'probably'
? 'The browser reports that it can probably decode this generated result.'
: 'The browser may decode this generated result; actual playback can still fail.',
needsPreviewProxy: false,
};
}
return {
support: 'unsupported',
candidates: [mimeType],
reason:
'The browser reports no native playback support for this generated result.',
needsPreviewProxy: false,
};
}
function previewKind(mimeType: string | undefined): GeneratedPreviewKind {
if (mimeType?.startsWith('audio/')) return 'audio';
if (mimeType?.startsWith('video/')) return 'video';
if (mimeType?.startsWith('image/')) return 'image';
return 'other';
}
function mimeTypeFromFormats(
formats: readonly string[],
hasVideo: boolean
): string | undefined {
const normalized = new Set(formats.map((format) => format.toLowerCase()));
if (
normalized.has('mp4') ||
normalized.has('mov') ||
normalized.has('m4a') ||
normalized.has('3gp') ||
normalized.has('3g2') ||
normalized.has('mj2')
) {
return hasVideo ? 'video/mp4' : 'audio/mp4';
}
if (normalized.has('webm')) {
return hasVideo ? 'video/webm' : 'audio/webm';
}
if (normalized.has('ogg')) {
return hasVideo ? 'video/ogg' : 'audio/ogg';
}
if (normalized.has('matroska')) {
return hasVideo ? 'video/x-matroska' : 'audio/x-matroska';
}
if (normalized.has('mp3')) {
return 'audio/mpeg';
}
if (normalized.has('wav')) {
return 'audio/wav';
}
if (normalized.has('flac')) {
return 'audio/flac';
}
return undefined;
}
function playbackCodecNames(streams: readonly MediaStreamProbe[]): string[] {
const codecs: string[] = [];
for (const stream of streams) {
if (
(stream.type !== 'video' && stream.type !== 'audio') ||
stream.disposition.attached_pic === true
) {
continue;
}
const codec = browserCodecName(stream.codecName);
if (codec && !codecs.includes(codec)) {
codecs.push(codec);
}
}
return codecs;
}
function browserCodecName(codecName: string | undefined): string | undefined {
switch (codecName?.toLowerCase()) {
case 'h264':
return 'avc1.42E01E';
case 'hevc':
case 'h265':
return 'hvc1';
case 'av1':
return 'av01.0.05M.08';
case 'vp8':
return 'vp8';
case 'vp9':
return 'vp09.00.10.08';
case 'aac':
return 'mp4a.40.2';
case 'mp3':
return 'mp3';
case 'opus':
return 'opus';
case 'vorbis':
return 'vorbis';
default:
return undefined;
}
}
function normalizeMimeType(value: string | undefined): string | undefined {
const mimeType = value?.trim().toLowerCase().split(';')[0]?.trim();
return mimeType && /^(?:audio|video)\/[a-z0-9.+-]+$/.test(mimeType)
? mimeType
: undefined;
}
function isString(value: string | undefined): value is string {
return value !== undefined;
}
+55
View File
@@ -0,0 +1,55 @@
export const DEFAULT_CHAPTER_TIME_BASE = '1/1000' as const;
const TIME_BASE_PATTERN = /^([1-9]\d*)\/([1-9]\d*)$/u;
const MAX_TIME_BASE_COMPONENT = 2_147_483_647n;
export interface ParsedChapterTimeBase {
readonly numerator: number;
readonly denominator: number;
readonly normalized: string;
readonly secondsPerTick: number;
}
/**
* Parses the positive integer rational accepted by FFMETADATA TIMEBASE.
* Components are bounded to FFmpeg's signed 32-bit AVRational range.
*/
export function parseChapterTimeBase(
value: unknown
): ParsedChapterTimeBase | undefined {
if (typeof value !== 'string') return undefined;
const match = TIME_BASE_PATTERN.exec(value.trim());
if (!match) return undefined;
const numeratorValue = match[1];
const denominatorValue = match[2];
if (numeratorValue === undefined || denominatorValue === undefined) {
return undefined;
}
const numerator = BigInt(numeratorValue);
const denominator = BigInt(denominatorValue);
if (
numerator > MAX_TIME_BASE_COMPONENT ||
denominator > MAX_TIME_BASE_COMPONENT
) {
return undefined;
}
const numericNumerator = Number(numerator);
const numericDenominator = Number(denominator);
return Object.freeze({
numerator: numericNumerator,
denominator: numericDenominator,
normalized: `${numericNumerator}/${numericDenominator}`,
secondsPerTick: numericNumerator / numericDenominator,
});
}
export function chapterSecondsToTicks(
seconds: number,
timeBase: ParsedChapterTimeBase
): number | undefined {
if (!Number.isFinite(seconds) || seconds < 0) return undefined;
const ticks = Math.round(
(seconds * timeBase.denominator) / timeBase.numerator
);
return Number.isSafeInteger(ticks) ? ticks : undefined;
}
+152
View File
@@ -0,0 +1,152 @@
export const DEFAULT_MINIMUM_CLIP_DURATION_SECONDS = 0.001;
export const TIME_COMPARISON_EPSILON_SECONDS = 1e-6;
export type SourceRangeIssueCode =
| 'invalid-source-in'
| 'invalid-source-out'
| 'source-out-before-in'
| 'clip-too-short'
| 'source-out-after-duration';
export interface SourceRangeIssue {
code: SourceRangeIssueCode;
message: string;
}
export interface SourceRangeValidation {
valid: boolean;
durationSeconds?: number;
issues: SourceRangeIssue[];
}
export function isValidDuration(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
export function roundSeconds(seconds: number, decimalPlaces = 6): number {
if (!Number.isFinite(seconds)) {
throw new RangeError('Seconds must be finite.');
}
if (
!Number.isInteger(decimalPlaces) ||
decimalPlaces < 0 ||
decimalPlaces > 9
) {
throw new RangeError('Decimal places must be an integer from 0 to 9.');
}
const factor = 10 ** decimalPlaces;
const rounded =
Math.round((seconds + Math.sign(seconds) * Number.EPSILON) * factor) /
factor;
return Object.is(rounded, -0) ? 0 : rounded;
}
export function addDurations(
durations: readonly number[],
decimalPlaces = 6
): number {
let total = 0;
let compensation = 0;
for (const duration of durations) {
if (!isValidDuration(duration)) {
throw new RangeError('Every duration must be finite and non-negative.');
}
const adjusted = duration - compensation;
const next = total + adjusted;
compensation = next - total - adjusted;
total = next;
}
return roundSeconds(total, decimalPlaces);
}
export function subtractDurations(
endSeconds: number,
startSeconds: number,
decimalPlaces = 6
): number {
if (!isValidDuration(startSeconds) || !isValidDuration(endSeconds)) {
throw new RangeError(
'Start and end times must be finite and non-negative.'
);
}
return roundSeconds(endSeconds - startSeconds, decimalPlaces);
}
export function clampTime(seconds: number, durationSeconds: number): number {
if (!Number.isFinite(seconds) || !isValidDuration(durationSeconds)) {
throw new RangeError('Time and duration must be finite.');
}
return Math.min(Math.max(seconds, 0), durationSeconds);
}
export function validateSourceRange(
sourceInSeconds: unknown,
sourceOutSeconds: unknown,
sourceDurationSeconds?: unknown,
minimumDurationSeconds = DEFAULT_MINIMUM_CLIP_DURATION_SECONDS
): SourceRangeValidation {
const issues: SourceRangeIssue[] = [];
const sourceIn = isValidDuration(sourceInSeconds)
? sourceInSeconds
: undefined;
const sourceOut = isValidDuration(sourceOutSeconds)
? sourceOutSeconds
: undefined;
if (sourceIn === undefined) {
issues.push({
code: 'invalid-source-in',
message: 'The source in-point must be finite and non-negative.',
});
}
if (sourceOut === undefined) {
issues.push({
code: 'invalid-source-out',
message: 'The source out-point must be finite and non-negative.',
});
}
if (!Number.isFinite(minimumDurationSeconds) || minimumDurationSeconds <= 0) {
throw new RangeError('Minimum duration must be finite and positive.');
}
let durationSeconds: number | undefined;
if (sourceIn !== undefined && sourceOut !== undefined) {
if (sourceOut < sourceIn) {
issues.push({
code: 'source-out-before-in',
message: 'The source out-point must not be before the in-point.',
});
} else {
durationSeconds = subtractDurations(sourceOut, sourceIn);
if (
durationSeconds + TIME_COMPARISON_EPSILON_SECONDS <
minimumDurationSeconds
) {
issues.push({
code: 'clip-too-short',
message: `The clip must be at least ${minimumDurationSeconds} seconds long.`,
});
}
}
}
if (
sourceOut !== undefined &&
isValidDuration(sourceDurationSeconds) &&
sourceOut > sourceDurationSeconds + TIME_COMPARISON_EPSILON_SECONDS
) {
issues.push({
code: 'source-out-after-duration',
message: 'The source out-point exceeds the source duration.',
});
}
return {
valid: issues.length === 0,
...(durationSeconds === undefined ? {} : { durationSeconds }),
issues,
};
}
+9
View File
@@ -0,0 +1,9 @@
export * from './browser-playback';
export * from './chapter-time-base';
export * from './duration';
export * from './media.types';
export * from './mime-types';
export * from './probe-parser';
export * from './safe-file-name';
export * from './stream-selection';
export * from './timecode';
+107
View File
@@ -0,0 +1,107 @@
export type MediaStreamType =
'video' | 'audio' | 'subtitle' | 'attachment' | 'data' | 'unknown';
export type ProbeWarningCode =
| 'invalid-field'
| 'invalid-fraction'
| 'missing-stream-index'
| 'duplicate-stream-index'
| 'unknown-stream-type'
| 'invalid-stream'
| 'invalid-chapter';
export interface ProbeWarning {
code: ProbeWarningCode;
path: string;
message: string;
received?: string;
}
export interface MediaStreamProbe {
index: number;
type: MediaStreamType;
codecName?: string;
codecLongName?: string;
profile?: string;
durationSeconds?: number;
bitRate?: number;
language?: string;
title?: string;
disposition: Record<string, boolean>;
width?: number;
height?: number;
codedWidth?: number;
codedHeight?: number;
pixelFormat?: string;
timeBase?: string;
sampleAspectRatio?: string;
displayAspectRatio?: string;
/**
* Display-matrix or legacy rotate-tag metadata normalized to the signed
* range (-180, 180]. The encoded width and height are not swapped.
*/
rotationDegrees?: number;
frameRate?: number;
sampleRate?: number;
channels?: number;
channelLayout?: string;
sampleFormat?: string;
tags: Record<string, string>;
}
export interface ChapterProbe {
id: number;
startSeconds: number;
endSeconds: number;
timeBase?: string;
title?: string;
tags: Record<string, string>;
}
export interface MediaProbe {
durationSeconds?: number;
startTimeSeconds?: number;
bitRate?: number;
formatNames: string[];
formatLongName?: string;
tags: Record<string, string>;
streams: MediaStreamProbe[];
chapters: ChapterProbe[];
warnings: ProbeWarning[];
}
export interface ParsedProbeReport {
probe: MediaProbe;
/**
* The exact report emitted by ffprobe. It is kept separate from the
* normalized model so application views cannot accidentally depend on
* unvalidated fields.
*/
rawReport: string;
}
export interface FractionParseOptions {
/** Maximum absolute numerator or denominator accepted. */
maxComponent?: number;
/** Maximum absolute result accepted. */
maxAbsoluteValue?: number;
allowNegative?: boolean;
allowZero?: boolean;
}
export type FractionParseFailure =
| 'not-a-string-or-number'
| 'empty'
| 'invalid-syntax'
| 'division-by-zero'
| 'component-too-large'
| 'result-too-large'
| 'negative-not-allowed'
| 'zero-not-allowed'
| 'not-finite';
export type FractionParseResult =
{ ok: true; value: number } | { ok: false; reason: FractionParseFailure };
+30
View File
@@ -0,0 +1,30 @@
import { splitFileName } from './safe-file-name';
const EXTENSION_MIME_TYPES: Readonly<Record<string, string>> = {
'.aac': 'audio/aac',
'.flac': 'audio/flac',
'.m4a': 'audio/mp4',
'.m4v': 'video/mp4',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.oga': 'audio/ogg',
'.ogg': 'audio/ogg',
'.ogv': 'video/ogg',
'.opus': 'audio/ogg',
'.wav': 'audio/wav',
'.webm': 'video/webm',
};
export function mimeTypeFromFileName(fileName: string): string | undefined {
return EXTENSION_MIME_TYPES[splitFileName(fileName).extension.toLowerCase()];
}
export function isAudioMimeType(mimeType: string): boolean {
return mimeType.toLowerCase().startsWith('audio/');
}
export function isVideoMimeType(mimeType: string): boolean {
return mimeType.toLowerCase().startsWith('video/');
}
+9
View File
@@ -0,0 +1,9 @@
export type {
ChapterProbe,
MediaProbe,
MediaStreamProbe,
MediaStreamType,
ParsedProbeReport,
ProbeWarning,
ProbeWarningCode,
} from './media.types';
+971
View File
@@ -0,0 +1,971 @@
import type {
ChapterProbe,
FractionParseOptions,
FractionParseResult,
MediaProbe,
MediaStreamProbe,
MediaStreamType,
ParsedProbeReport,
ProbeWarning,
ProbeWarningCode,
} from './media.types';
const INTEGER_PATTERN = /^[+-]?\d+$/;
const DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
const FINITE_NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
const DEFAULT_MAX_COMPONENT = 1_000_000_000_000;
const DEFAULT_MAX_ABSOLUTE_VALUE = 1_000_000;
export class ProbeParseError extends Error {
readonly causeValue?: unknown;
constructor(message: string, causeValue?: unknown) {
super(message);
this.name = 'ProbeParseError';
this.causeValue = causeValue;
}
}
export function parseFractionDetailed(
input: unknown,
options: FractionParseOptions = {}
): FractionParseResult {
if (typeof input !== 'string' && typeof input !== 'number') {
return { ok: false, reason: 'not-a-string-or-number' };
}
const text = String(input).trim();
if (!text) {
return { ok: false, reason: 'empty' };
}
const maxComponent = normalizePositiveLimit(
options.maxComponent,
DEFAULT_MAX_COMPONENT
);
const maxAbsoluteValue = normalizePositiveLimit(
options.maxAbsoluteValue,
DEFAULT_MAX_ABSOLUTE_VALUE
);
let value: number;
const slashIndex = text.indexOf('/');
if (slashIndex >= 0) {
if (
text.indexOf('/', slashIndex + 1) >= 0 ||
slashIndex === 0 ||
slashIndex === text.length - 1
) {
return { ok: false, reason: 'invalid-syntax' };
}
const numeratorText = text.slice(0, slashIndex).trim();
const denominatorText = text.slice(slashIndex + 1).trim();
if (
!INTEGER_PATTERN.test(numeratorText) ||
!INTEGER_PATTERN.test(denominatorText)
) {
return { ok: false, reason: 'invalid-syntax' };
}
const numerator = BigInt(numeratorText);
const denominator = BigInt(denominatorText);
if (denominator === 0n) {
return { ok: false, reason: 'division-by-zero' };
}
const componentLimit = BigInt(Math.floor(maxComponent));
if (
absoluteBigInt(numerator) > componentLimit ||
absoluteBigInt(denominator) > componentLimit
) {
return { ok: false, reason: 'component-too-large' };
}
value = Number(numerator) / Number(denominator);
} else {
if (!DECIMAL_PATTERN.test(text)) {
return { ok: false, reason: 'invalid-syntax' };
}
value = Number(text);
}
if (!Number.isFinite(value)) {
return { ok: false, reason: 'not-finite' };
}
if (Math.abs(value) > maxAbsoluteValue) {
return { ok: false, reason: 'result-too-large' };
}
if (value < 0 && options.allowNegative === false) {
return { ok: false, reason: 'negative-not-allowed' };
}
if (value === 0 && options.allowZero === false) {
return { ok: false, reason: 'zero-not-allowed' };
}
return { ok: true, value };
}
export function parseFraction(
input: unknown,
options?: FractionParseOptions
): number | undefined {
const result = parseFractionDetailed(input, options);
return result.ok ? result.value : undefined;
}
export function parseFrameRate(input: unknown): number | undefined {
return parseFraction(input, {
allowNegative: false,
allowZero: false,
maxComponent: DEFAULT_MAX_COMPONENT,
maxAbsoluteValue: 1_000,
});
}
export function parseFfprobeJson(rawReport: string): ParsedProbeReport {
let raw: unknown;
try {
raw = JSON.parse(rawReport) as unknown;
} catch (error) {
throw new ProbeParseError('ffprobe returned invalid JSON.', error);
}
return {
probe: normalizeFfprobeReport(raw),
rawReport,
};
}
export function normalizeFfprobeReport(raw: unknown): MediaProbe {
if (!isRecord(raw)) {
throw new ProbeParseError('The ffprobe report must be a JSON object.', raw);
}
const warnings: ProbeWarning[] = [];
const format = isRecord(raw.format) ? raw.format : {};
if (raw.format !== undefined && !isRecord(raw.format)) {
warn(
warnings,
'invalid-field',
'format',
'Expected the format section to be an object.',
raw.format
);
}
const formatName = optionalString(
format.format_name,
'format.format_name',
warnings
);
const streams = normalizeStreams(raw.streams, warnings);
const chapters = normalizeChapters(raw.chapters, warnings);
return compactObject({
durationSeconds: optionalNonNegativeNumber(
format.duration,
'format.duration',
warnings
),
startTimeSeconds: optionalFiniteNumber(
format.start_time,
'format.start_time',
warnings
),
bitRate: optionalNonNegativeInteger(
format.bit_rate,
'format.bit_rate',
warnings
),
formatNames: formatName
? [
...new Set(
formatName
.split(',')
.map((name) => name.trim())
.filter(Boolean)
),
]
: [],
formatLongName: optionalString(
format.format_long_name,
'format.format_long_name',
warnings
),
tags: normalizeTags(format.tags, 'format.tags', warnings),
streams,
chapters,
warnings,
});
}
function normalizeStreams(
value: unknown,
warnings: ProbeWarning[]
): MediaStreamProbe[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
warn(
warnings,
'invalid-field',
'streams',
'Expected streams to be an array.',
value
);
return [];
}
const streams: MediaStreamProbe[] = [];
const streamIndexes = new Set<number>();
value.forEach((entry, arrayIndex) => {
const path = `streams[${arrayIndex}]`;
if (!isRecord(entry)) {
warn(
warnings,
'invalid-stream',
path,
'Ignored a stream entry that was not an object.',
entry
);
return;
}
const index = optionalNonNegativeInteger(
entry.index,
`${path}.index`,
warnings
);
if (index === undefined) {
warn(
warnings,
'missing-stream-index',
`${path}.index`,
'Ignored a stream without a valid non-negative index.',
entry.index
);
return;
}
if (streamIndexes.has(index)) {
warn(
warnings,
'duplicate-stream-index',
`${path}.index`,
`Ignored duplicate stream index ${index}.`,
index
);
return;
}
streamIndexes.add(index);
const tags = normalizeTags(entry.tags, `${path}.tags`, warnings);
const type = normalizeStreamType(entry.codec_type, path, warnings);
const frameRate = normalizeFrameRate(entry, path, warnings);
const timeBase = normalizeStreamTimeBase(entry.time_base, path, warnings);
const rotationDegrees = normalizeRotation(entry, tags, path, warnings);
streams.push(
compactObject({
index,
type,
codecName: optionalString(
entry.codec_name,
`${path}.codec_name`,
warnings
),
codecLongName: optionalString(
entry.codec_long_name,
`${path}.codec_long_name`,
warnings
),
profile: optionalString(entry.profile, `${path}.profile`, warnings),
durationSeconds: optionalNonNegativeNumber(
entry.duration,
`${path}.duration`,
warnings
),
bitRate: optionalNonNegativeInteger(
entry.bit_rate,
`${path}.bit_rate`,
warnings
),
language: tags.language,
title: tags.title,
disposition: normalizeDisposition(
entry.disposition,
`${path}.disposition`,
warnings
),
width: optionalPositiveInteger(entry.width, `${path}.width`, warnings),
height: optionalPositiveInteger(
entry.height,
`${path}.height`,
warnings
),
codedWidth: optionalPositiveInteger(
entry.coded_width,
`${path}.coded_width`,
warnings
),
codedHeight: optionalPositiveInteger(
entry.coded_height,
`${path}.coded_height`,
warnings
),
pixelFormat: optionalString(entry.pix_fmt, `${path}.pix_fmt`, warnings),
timeBase,
sampleAspectRatio: normalizeAspectRatio(
entry.sample_aspect_ratio,
`${path}.sample_aspect_ratio`,
warnings
),
displayAspectRatio: normalizeAspectRatio(
entry.display_aspect_ratio,
`${path}.display_aspect_ratio`,
warnings
),
rotationDegrees,
frameRate,
sampleRate: optionalPositiveInteger(
entry.sample_rate,
`${path}.sample_rate`,
warnings
),
channels: optionalPositiveInteger(
entry.channels,
`${path}.channels`,
warnings
),
channelLayout: optionalString(
entry.channel_layout,
`${path}.channel_layout`,
warnings
),
sampleFormat: optionalString(
entry.sample_fmt,
`${path}.sample_fmt`,
warnings
),
tags,
})
);
});
return streams.sort((left, right) => left.index - right.index);
}
function normalizeStreamTimeBase(
value: unknown,
path: string,
warnings: ProbeWarning[]
): string | undefined {
const timeBase = optionalString(value, `${path}.time_base`, warnings);
if (timeBase === undefined) {
return undefined;
}
if (
parseFraction(timeBase, {
allowNegative: false,
allowZero: false,
maxAbsoluteValue: 1,
}) === undefined
) {
warn(
warnings,
'invalid-fraction',
`${path}.time_base`,
'Ignored an invalid stream time base.',
value
);
return undefined;
}
return timeBase;
}
function normalizeChapters(
value: unknown,
warnings: ProbeWarning[]
): ChapterProbe[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
warn(
warnings,
'invalid-field',
'chapters',
'Expected chapters to be an array.',
value
);
return [];
}
const chapters: ChapterProbe[] = [];
value.forEach((entry, arrayIndex) => {
const path = `chapters[${arrayIndex}]`;
if (!isRecord(entry)) {
warn(
warnings,
'invalid-chapter',
path,
'Ignored a chapter entry that was not an object.',
entry
);
return;
}
const id =
optionalNonNegativeInteger(entry.id, `${path}.id`, warnings) ??
arrayIndex;
const timeBase = optionalString(
entry.time_base,
`${path}.time_base`,
warnings
);
const timeBaseValue =
timeBase === undefined
? undefined
: parseFraction(timeBase, {
allowNegative: false,
allowZero: false,
maxAbsoluteValue: 1,
});
if (timeBase !== undefined && timeBaseValue === undefined) {
warn(
warnings,
'invalid-fraction',
`${path}.time_base`,
'Ignored an invalid chapter time base.',
timeBase
);
}
const startSeconds =
optionalNonNegativeNumber(
entry.start_time,
`${path}.start_time`,
warnings
) ??
ticksToSeconds(entry.start, timeBaseValue, `${path}.start`, warnings);
const endSeconds =
optionalNonNegativeNumber(entry.end_time, `${path}.end_time`, warnings) ??
ticksToSeconds(entry.end, timeBaseValue, `${path}.end`, warnings);
if (
startSeconds === undefined ||
endSeconds === undefined ||
endSeconds < startSeconds
) {
warn(
warnings,
'invalid-chapter',
path,
'Ignored a chapter without a valid start and end time.',
entry
);
return;
}
const tags = normalizeTags(entry.tags, `${path}.tags`, warnings);
chapters.push(
compactObject({
id,
startSeconds,
endSeconds,
timeBase,
title: tags.title,
tags,
})
);
});
return chapters.sort(
(left, right) =>
left.startSeconds - right.startSeconds || left.id - right.id
);
}
function normalizeFrameRate(
stream: Record<string, unknown>,
path: string,
warnings: ProbeWarning[]
): number | undefined {
const candidates: Array<[string, unknown]> = [
['avg_frame_rate', stream.avg_frame_rate],
['r_frame_rate', stream.r_frame_rate],
];
for (const [field, value] of candidates) {
if (value === undefined || value === null || value === '') {
continue;
}
const frameRate = parseFrameRate(value);
if (frameRate !== undefined) {
return frameRate;
}
warn(
warnings,
'invalid-fraction',
`${path}.${field}`,
'Ignored an invalid or implausible frame-rate fraction.',
value
);
}
return undefined;
}
function normalizeAspectRatio(
value: unknown,
path: string,
warnings: ProbeWarning[]
): string | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value !== 'string') {
warn(warnings, 'invalid-field', path, 'Expected an aspect ratio.', value);
return undefined;
}
if (value.length > 64) {
warn(
warnings,
'invalid-fraction',
path,
'Ignored an implausibly long aspect ratio.',
value
);
return undefined;
}
const match = /^\s*(\d+)\s*[:/]\s*(\d+)\s*$/u.exec(value);
if (!match?.[1] || !match[2]) {
warn(
warnings,
'invalid-fraction',
path,
'Ignored an invalid aspect ratio.',
value
);
return undefined;
}
const numerator = BigInt(match[1]);
const denominator = BigInt(match[2]);
const componentLimit = BigInt(DEFAULT_MAX_COMPONENT);
if (
numerator === 0n ||
denominator === 0n ||
numerator > componentLimit ||
denominator > componentLimit
) {
warn(
warnings,
'invalid-fraction',
path,
'Ignored a zero or implausibly large aspect ratio.',
value
);
return undefined;
}
const divisor = greatestCommonDivisor(numerator, denominator);
return `${numerator / divisor}:${denominator / divisor}`;
}
function normalizeRotation(
stream: Record<string, unknown>,
tags: Record<string, string>,
path: string,
warnings: ProbeWarning[]
): number | undefined {
const sideData = stream.side_data_list;
if (sideData !== undefined && !Array.isArray(sideData)) {
warn(
warnings,
'invalid-field',
`${path}.side_data_list`,
'Expected stream side data to be an array.',
sideData
);
} else if (Array.isArray(sideData)) {
for (let index = 0; index < sideData.length; index += 1) {
const entry = sideData[index];
if (!isRecord(entry)) {
warn(
warnings,
'invalid-field',
`${path}.side_data_list[${index}]`,
'Ignored a side-data entry that was not an object.',
entry
);
continue;
}
if (entry.rotation === undefined) continue;
const rotation = normalizeRotationValue(
entry.rotation,
`${path}.side_data_list[${index}].rotation`,
warnings
);
if (rotation !== undefined) return rotation;
}
}
const legacyRotation = Object.entries(tags).find(
([key]) => key.toLowerCase() === 'rotate'
)?.[1];
return legacyRotation === undefined
? undefined
: normalizeRotationValue(legacyRotation, `${path}.tags.rotate`, warnings);
}
function normalizeRotationValue(
value: unknown,
path: string,
warnings: ProbeWarning[]
): number | undefined {
const rotation = parseFiniteNumericValue(value);
if (!Number.isFinite(rotation) || Math.abs(rotation) > 1_000_000) {
warn(
warnings,
'invalid-field',
path,
'Ignored invalid or implausible rotation metadata.',
value
);
return undefined;
}
const normalized = ((((rotation + 180) % 360) + 360) % 360) - 180;
const signed = normalized === -180 ? 180 : normalized;
return Object.is(signed, -0) ? 0 : signed;
}
function normalizeStreamType(
value: unknown,
path: string,
warnings: ProbeWarning[]
): MediaStreamType {
if (
value === 'video' ||
value === 'audio' ||
value === 'subtitle' ||
value === 'attachment' ||
value === 'data'
) {
return value;
}
if (value !== undefined && value !== 'unknown') {
warn(
warnings,
'unknown-stream-type',
`${path}.codec_type`,
'Mapped an unrecognized stream type to "unknown".',
value
);
}
return 'unknown';
}
function normalizeTags(
value: unknown,
path: string,
warnings: ProbeWarning[]
): Record<string, string> {
if (value === undefined) {
return {};
}
if (!isRecord(value)) {
warn(
warnings,
'invalid-field',
path,
'Expected tags to be an object.',
value
);
return {};
}
const tags: Record<string, string> = {};
for (const [key, tagValue] of Object.entries(value)) {
if (!isSafeRecordKey(key)) {
warn(
warnings,
'invalid-field',
`${path}.${key}`,
'Ignored an unsafe tag key.',
key
);
} else if (typeof tagValue === 'string') {
tags[key] = tagValue;
} else {
warn(
warnings,
'invalid-field',
`${path}.${key}`,
'Ignored a tag whose value was not a string.',
tagValue
);
}
}
return tags;
}
function normalizeDisposition(
value: unknown,
path: string,
warnings: ProbeWarning[]
): Record<string, boolean> {
if (value === undefined) {
return {};
}
if (!isRecord(value)) {
warn(
warnings,
'invalid-field',
path,
'Expected stream disposition to be an object.',
value
);
return {};
}
const disposition: Record<string, boolean> = {};
for (const [key, flag] of Object.entries(value)) {
if (!isSafeRecordKey(key)) {
warn(
warnings,
'invalid-field',
`${path}.${key}`,
'Ignored an unsafe disposition key.',
key
);
} else if (flag === true || flag === 1 || flag === '1') {
disposition[key] = true;
} else if (flag === false || flag === 0 || flag === '0') {
disposition[key] = false;
} else {
warn(
warnings,
'invalid-field',
`${path}.${key}`,
'Ignored a disposition flag that was not boolean-like.',
flag
);
}
}
return disposition;
}
function ticksToSeconds(
ticks: unknown,
timeBase: number | undefined,
path: string,
warnings: ProbeWarning[]
): number | undefined {
if (ticks === undefined || timeBase === undefined) {
return undefined;
}
const tickValue = optionalNonNegativeInteger(ticks, path, warnings);
return tickValue === undefined ? undefined : tickValue * timeBase;
}
function optionalString(
value: unknown,
path: string,
warnings: ProbeWarning[]
): string | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value !== 'string') {
warn(warnings, 'invalid-field', path, 'Expected a string value.', value);
return undefined;
}
return value;
}
function optionalFiniteNumber(
value: unknown,
path: string,
warnings: ProbeWarning[]
): number | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
const number = parseFiniteNumericValue(value);
if (!Number.isFinite(number)) {
warn(
warnings,
'invalid-field',
path,
'Expected a finite numeric value.',
value
);
return undefined;
}
return number;
}
function optionalNonNegativeNumber(
value: unknown,
path: string,
warnings: ProbeWarning[]
): number | undefined {
const number = optionalFiniteNumber(value, path, warnings);
if (number === undefined) {
return undefined;
}
if (number < 0) {
warn(
warnings,
'invalid-field',
path,
'Expected a non-negative numeric value.',
value
);
return undefined;
}
return number;
}
function optionalPositiveInteger(
value: unknown,
path: string,
warnings: ProbeWarning[]
): number | undefined {
const integer = optionalInteger(value, path, warnings);
if (integer === undefined) {
return undefined;
}
if (integer <= 0) {
warn(
warnings,
'invalid-field',
path,
'Expected a positive integer.',
value
);
return undefined;
}
return integer;
}
function optionalNonNegativeInteger(
value: unknown,
path: string,
warnings: ProbeWarning[]
): number | undefined {
const integer = optionalInteger(value, path, warnings);
if (integer === undefined) {
return undefined;
}
if (integer < 0) {
warn(
warnings,
'invalid-field',
path,
'Expected a non-negative integer.',
value
);
return undefined;
}
return integer;
}
function optionalInteger(
value: unknown,
path: string,
warnings: ProbeWarning[]
): number | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
const number = parseFiniteNumericValue(value);
if (!Number.isSafeInteger(number)) {
warn(warnings, 'invalid-field', path, 'Expected a safe integer.', value);
return undefined;
}
return number;
}
function warn(
warnings: ProbeWarning[],
code: ProbeWarningCode,
path: string,
message: string,
received: unknown
): void {
warnings.push({
code,
path,
message,
received: describe(received),
});
}
function describe(value: unknown): string {
if (typeof value === 'string') {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
}
try {
const serialized = JSON.stringify(value);
return serialized === undefined ? String(value) : serialized.slice(0, 120);
} catch {
return String(value);
}
}
function compactObject<T extends object>(value: T): T {
for (const key of Object.keys(value) as Array<keyof T>) {
if (value[key] === undefined) {
delete value[key];
}
}
return value;
}
function normalizePositiveLimit(
value: number | undefined,
fallback: number
): number {
return value !== undefined && Number.isFinite(value) && value > 0
? value
: fallback;
}
function absoluteBigInt(value: bigint): bigint {
return value < 0n ? -value : value;
}
function greatestCommonDivisor(left: bigint, right: bigint): bigint {
let dividend = left;
let divisor = right;
while (divisor !== 0n) {
const remainder = dividend % divisor;
dividend = divisor;
divisor = remainder;
}
return dividend;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseFiniteNumericValue(value: unknown): number {
if (typeof value === 'number') {
return value;
}
if (typeof value !== 'string' || !FINITE_NUMBER_PATTERN.test(value.trim())) {
return Number.NaN;
}
return Number(value);
}
function isSafeRecordKey(key: string): boolean {
return key !== '__proto__' && key !== 'prototype' && key !== 'constructor';
}
+127
View File
@@ -0,0 +1,127 @@
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
const COMBINING_MARKS = /[\u0300-\u036f]/g;
const UNSAFE_CHARACTERS = /[^A-Za-z0-9._-]+/g;
const REPEATED_SEPARATORS = /[-_]{2,}/g;
export interface SafeFileNameOptions {
fallback?: string;
maxLength?: number;
preserveExtension?: boolean;
}
export function safeFileName(
originalName: string,
options: SafeFileNameOptions = {}
): string {
const fallback = sanitizePart(options.fallback ?? 'file') || 'file';
const maxLength = options.maxLength ?? 120;
if (!Number.isInteger(maxLength) || maxLength < 8 || maxLength > 255) {
throw new RangeError('Maximum filename length must be from 8 to 255.');
}
const basename = originalName.split(/[\\/]/).at(-1) ?? '';
const normalized = removeControlCharacters(
basename.normalize('NFKD').replace(COMBINING_MARKS, '')
).trim();
const { stem, extension } =
options.preserveExtension === false
? { stem: normalized, extension: '' }
: splitFileName(normalized);
const safeStem =
sanitizePart(stem)
.replace(/\.{2,}/g, '.')
.replace(/^\.+|\.+$/g, '') || fallback;
const safeExtension =
options.preserveExtension === false ? '' : sanitizeExtension(extension);
const reservedStem = WINDOWS_RESERVED_NAME.test(safeStem)
? `_${safeStem}`
: safeStem;
const availableStemLength = Math.max(1, maxLength - safeExtension.length);
const truncatedStem =
reservedStem.slice(0, availableStemLength).replace(/[._-]+$/g, '') ||
fallback.slice(0, availableStemLength);
return `${truncatedStem}${safeExtension}`.slice(0, maxLength);
}
export function createSafeVirtualFileName(
originalName: string,
index: number,
prefix = 'source'
): string {
if (!Number.isSafeInteger(index) || index < 0) {
throw new RangeError('File index must be a non-negative safe integer.');
}
const sanitized = safeFileName(originalName, {
fallback: prefix,
maxLength: 96,
});
const { extension } = splitFileName(sanitized);
return `${safeFileName(prefix, {
preserveExtension: false,
maxLength: 40,
})}-${index}${extension}`;
}
export function safeOutputFileName(
requestedStem: string,
extension: string,
fallback = 'output'
): string {
const normalizedExtension = sanitizeExtension(
extension.startsWith('.') ? extension : `.${extension}`
);
const stem = safeFileName(requestedStem, {
fallback,
preserveExtension: false,
maxLength: Math.max(8, 120 - normalizedExtension.length),
});
return `${stem}${normalizedExtension}`;
}
export function splitFileName(name: string): {
stem: string;
extension: string;
} {
const lastDot = name.lastIndexOf('.');
if (lastDot <= 0 || lastDot === name.length - 1) {
return { stem: name, extension: '' };
}
const extension = name.slice(lastDot);
if (extension.length > 17) {
return { stem: name, extension: '' };
}
return { stem: name.slice(0, lastDot), extension };
}
function sanitizePart(value: string): string {
return value
.normalize('NFKD')
.replace(COMBINING_MARKS, '')
.replace(UNSAFE_CHARACTERS, '-')
.replace(REPEATED_SEPARATORS, (separator) => separator[0] ?? '-')
.replace(/^[._-]+|[._-]+$/g, '');
}
function sanitizeExtension(extension: string): string {
if (!extension) {
return '';
}
const parts = extension
.replace(/^\.+/, '')
.normalize('NFKD')
.replace(COMBINING_MARKS, '')
.split('.')
.map((part) => part.replace(/[^A-Za-z0-9]/g, '').slice(0, 16))
.filter(Boolean);
return parts.length > 0 ? `.${parts.join('.').toLowerCase()}` : '';
}
function removeControlCharacters(value: string): string {
return Array.from(value)
.filter((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint >= 32 && codePoint !== 127;
})
.join('');
}
+233
View File
@@ -0,0 +1,233 @@
import type {
MediaProbe,
MediaStreamProbe,
MediaStreamType,
} from './media.types';
export interface StreamSelection {
videoStreamIndex?: number;
audioStreamIndexes: number[];
subtitleStreamIndexes: number[];
}
export interface DefaultStreamSelectionOptions {
preferredAudioLanguages?: readonly string[];
preferredSubtitleLanguages?: readonly string[];
includeSubtitles?: boolean;
includeAllAudioStreams?: boolean;
}
export type StreamSelectionIssueCode =
'stream-not-found' | 'wrong-stream-type' | 'duplicate-stream';
export interface StreamSelectionIssue {
code: StreamSelectionIssueCode;
field: 'videoStreamIndex' | 'audioStreamIndexes' | 'subtitleStreamIndexes';
streamIndex: number;
message: string;
}
export interface StreamSelectionValidation {
valid: boolean;
issues: StreamSelectionIssue[];
}
export function streamsOfType(
probe: MediaProbe,
type: MediaStreamType
): MediaStreamProbe[] {
return probe.streams.filter((stream) => stream.type === type);
}
export function findStream(
probe: MediaProbe,
index: number
): MediaStreamProbe | undefined {
return probe.streams.find((stream) => stream.index === index);
}
export function selectDefaultStreams(
probe: MediaProbe,
options: DefaultStreamSelectionOptions = {}
): StreamSelection {
const videoCandidates = streamsOfType(probe, 'video').filter(
(stream) => stream.disposition.attached_pic !== true
);
const audioCandidates = streamsOfType(probe, 'audio');
const subtitleCandidates = streamsOfType(probe, 'subtitle');
const defaultVideo = choosePreferredStream(videoCandidates);
const preferredAudio = choosePreferredStream(
audioCandidates,
options.preferredAudioLanguages
);
const preferredSubtitle = choosePreferredStream(
subtitleCandidates,
options.preferredSubtitleLanguages
);
return {
...(defaultVideo === undefined
? {}
: { videoStreamIndex: defaultVideo.index }),
audioStreamIndexes:
options.includeAllAudioStreams === true
? orderByPreference(
audioCandidates,
options.preferredAudioLanguages
).map((stream) => stream.index)
: preferredAudio === undefined
? []
: [preferredAudio.index],
subtitleStreamIndexes:
options.includeSubtitles === true && preferredSubtitle !== undefined
? [preferredSubtitle.index]
: [],
};
}
export function validateStreamSelection(
probe: MediaProbe,
selection: StreamSelection
): StreamSelectionValidation {
const issues: StreamSelectionIssue[] = [];
const seen = new Set<number>();
if (selection.videoStreamIndex !== undefined) {
validateSelectedIndex(
probe,
selection.videoStreamIndex,
'video',
'videoStreamIndex',
seen,
issues
);
}
for (const streamIndex of selection.audioStreamIndexes) {
validateSelectedIndex(
probe,
streamIndex,
'audio',
'audioStreamIndexes',
seen,
issues
);
}
for (const streamIndex of selection.subtitleStreamIndexes) {
validateSelectedIndex(
probe,
streamIndex,
'subtitle',
'subtitleStreamIndexes',
seen,
issues
);
}
return { valid: issues.length === 0, issues };
}
export function streamSelectionMapArgs(
selection: StreamSelection,
inputIndex = 0
): string[] {
if (!Number.isSafeInteger(inputIndex) || inputIndex < 0) {
throw new RangeError('Input index must be a non-negative safe integer.');
}
const args: string[] = [];
const append = (streamIndex: number): void => {
if (!Number.isSafeInteger(streamIndex) || streamIndex < 0) {
throw new RangeError(
'Stream indexes must be non-negative safe integers.'
);
}
args.push('-map', `${inputIndex}:${streamIndex}`);
};
if (selection.videoStreamIndex !== undefined) {
append(selection.videoStreamIndex);
}
selection.audioStreamIndexes.forEach(append);
selection.subtitleStreamIndexes.forEach(append);
return args;
}
function choosePreferredStream(
streams: readonly MediaStreamProbe[],
preferredLanguages?: readonly string[]
): MediaStreamProbe | undefined {
return orderByPreference(streams, preferredLanguages)[0];
}
function orderByPreference(
streams: readonly MediaStreamProbe[],
preferredLanguages?: readonly string[]
): MediaStreamProbe[] {
const languageRank = new Map(
(preferredLanguages ?? []).map((language, index) => [
normalizeLanguage(language),
index,
])
);
return [...streams].sort((left, right) => {
const leftLanguage = left.language
? languageRank.get(normalizeLanguage(left.language))
: undefined;
const rightLanguage = right.language
? languageRank.get(normalizeLanguage(right.language))
: undefined;
const leftRank = leftLanguage ?? Number.POSITIVE_INFINITY;
const rightRank = rightLanguage ?? Number.POSITIVE_INFINITY;
if (leftRank !== rightRank) {
return leftRank - rightRank;
}
const defaultDifference =
Number(right.disposition.default === true) -
Number(left.disposition.default === true);
if (defaultDifference !== 0) {
return defaultDifference;
}
return left.index - right.index;
});
}
function validateSelectedIndex(
probe: MediaProbe,
streamIndex: number,
expectedType: MediaStreamType,
field: StreamSelectionIssue['field'],
seen: Set<number>,
issues: StreamSelectionIssue[]
): void {
if (seen.has(streamIndex)) {
issues.push({
code: 'duplicate-stream',
field,
streamIndex,
message: `Stream ${streamIndex} is selected more than once.`,
});
}
seen.add(streamIndex);
const stream = findStream(probe, streamIndex);
if (stream === undefined) {
issues.push({
code: 'stream-not-found',
field,
streamIndex,
message: `Stream ${streamIndex} does not exist in the source.`,
});
} else if (stream.type !== expectedType) {
issues.push({
code: 'wrong-stream-type',
field,
streamIndex,
message: `Stream ${streamIndex} is ${stream.type}, not ${expectedType}.`,
});
}
}
function normalizeLanguage(language: string): string {
return language.trim().toLowerCase().split(/[-_]/)[0] ?? '';
}
+199
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');
}
+113
View File
@@ -0,0 +1,113 @@
import type { BuiltInExportPreset } from './preset.types';
function audioPreset(
preset: Omit<BuiltInExportPreset, 'builtIn' | 'kind'>
): BuiltInExportPreset {
return Object.freeze({
...preset,
kind: 'audio',
builtIn: true,
requirements: Object.freeze({
...preset.requirements,
...(preset.requirements.muxers
? { muxers: Object.freeze([...preset.requirements.muxers]) }
: {}),
...(preset.requirements.encoders
? { encoders: Object.freeze([...preset.requirements.encoders]) }
: {}),
}),
...(preset.audio ? { audio: Object.freeze({ ...preset.audio }) } : {}),
settingsSummary: Object.freeze([...preset.settingsSummary]),
});
}
const COMMON_POLICY = {
metadataPolicy: 'copy',
chapterPolicy: 'remove',
subtitlePolicy: 'none',
} as const;
export const AUDIO_PRESETS: readonly BuiltInExportPreset[] = Object.freeze([
audioPreset({
id: 'audio-mp3',
name: 'MP3',
description: 'Widely compatible compressed audio.',
container: 'mp3',
fileExtension: 'mp3',
audio: {
codec: 'libmp3lame',
bitrateKbps: 192,
sampleRate: 44_100,
channels: 2,
},
requirements: { muxers: ['mp3'], encoders: ['libmp3lame'] },
settingsSummary: ['libmp3lame 192 kbit/s stereo at 44.1 kHz'],
...COMMON_POLICY,
}),
audioPreset({
id: 'audio-m4a-aac',
name: 'M4A · AAC',
description: 'Browser-friendly compressed audio.',
container: 'ipod',
fileExtension: 'm4a',
audio: { codec: 'aac', bitrateKbps: 160, sampleRate: 48_000, channels: 2 },
requirements: { muxers: ['ipod'], encoders: ['aac'] },
settingsSummary: ['AAC 160 kbit/s stereo at 48 kHz'],
fastStart: true,
...COMMON_POLICY,
}),
audioPreset({
id: 'audio-ogg-vorbis',
name: 'Ogg Vorbis',
description: 'Open compressed audio.',
container: 'ogg',
fileExtension: 'ogg',
audio: {
codec: 'libvorbis',
bitrateKbps: 160,
sampleRate: 48_000,
channels: 2,
},
requirements: { muxers: ['ogg'], encoders: ['libvorbis'] },
settingsSummary: ['libvorbis 160 kbit/s stereo at 48 kHz'],
...COMMON_POLICY,
}),
audioPreset({
id: 'audio-opus',
name: 'Opus',
description: 'Efficient speech and music audio.',
container: 'opus',
fileExtension: 'opus',
audio: {
codec: 'libopus',
bitrateKbps: 128,
sampleRate: 48_000,
channels: 2,
},
requirements: { muxers: ['opus'], encoders: ['libopus'] },
settingsSummary: ['libopus 128 kbit/s stereo at 48 kHz'],
...COMMON_POLICY,
}),
audioPreset({
id: 'audio-wav-pcm',
name: 'WAV · PCM',
description: 'Uncompressed 16-bit PCM audio.',
container: 'wav',
fileExtension: 'wav',
audio: { codec: 'pcm_s16le', sampleRate: 48_000, channels: 2 },
requirements: { muxers: ['wav'], encoders: ['pcm_s16le'] },
settingsSummary: ['16-bit little-endian PCM stereo at 48 kHz'],
...COMMON_POLICY,
}),
audioPreset({
id: 'audio-flac',
name: 'FLAC',
description: 'Lossless compressed audio.',
container: 'flac',
fileExtension: 'flac',
audio: { codec: 'flac', sampleRate: 48_000, channels: 2 },
requirements: { muxers: ['flac'], encoders: ['flac'] },
settingsSummary: ['FLAC stereo at 48 kHz'],
...COMMON_POLICY,
}),
]);
+6
View File
@@ -0,0 +1,6 @@
export * from './audio-presets';
export * from './preset-registry';
export * from './preset-validation';
export * from './preset.types';
export * from './user-presets';
export * from './video-presets';
+121
View File
@@ -0,0 +1,121 @@
import type { PresetRequirements } from '../commands/command-plan';
import { AUDIO_PRESETS } from './audio-presets';
import type {
BuiltInExportPreset,
ExportPresetBase,
FFmpegCapabilitySnapshot,
PresetAvailability,
UserExportPreset,
} from './preset.types';
import { VIDEO_PRESETS } from './video-presets';
export const BUILT_IN_PRESETS: readonly BuiltInExportPreset[] = Object.freeze([
...VIDEO_PRESETS,
...AUDIO_PRESETS,
]);
export function findBuiltInPreset(id: string): BuiltInExportPreset | undefined {
return BUILT_IN_PRESETS.find((preset) => preset.id === id);
}
export function presetAvailability(
preset: Pick<ExportPresetBase, 'requirements'>,
capabilities: FFmpegCapabilitySnapshot,
degradedWhenMissing: PresetRequirements = {}
): PresetAvailability {
const missing = missingRequirements(preset.requirements, capabilities);
if (missing.length > 0) {
return {
status: 'unavailable',
reasons: Object.freeze(
missing.map(({ kind, name }) => `Missing ${kind}: ${name}`)
),
};
}
const degraded = missingRequirements(degradedWhenMissing, capabilities);
if (degraded.length > 0) {
return {
status: 'degraded',
reasons: Object.freeze(
degraded.map(
({ kind, name }) => `Optional ${kind} unavailable: ${name}`
)
),
};
}
return { status: 'available', reasons: Object.freeze([]) };
}
export function missingRequirements(
requirements: PresetRequirements,
capabilities: FFmpegCapabilitySnapshot
): readonly {
readonly kind: keyof PresetRequirements;
readonly name: string;
}[] {
const result: { kind: keyof PresetRequirements; name: string }[] = [];
const kinds = ['muxers', 'encoders', 'decoders', 'filters'] as const;
for (const kind of kinds) {
for (const name of requirements[kind] ?? []) {
if (!capabilities[kind].has(name)) {
result.push({ kind, name });
}
}
}
return Object.freeze(result);
}
export function presetEncodingArguments(
preset: Pick<UserExportPreset, 'video' | 'audio' | 'fastStart'>
): readonly string[] {
const args: string[] = [];
if (preset.video) {
args.push('-c:v', preset.video.codec);
if (preset.video.crf !== undefined) {
if (preset.video.codec === 'libwebp_anim') {
args.push('-quality', String(preset.video.crf));
} else if (preset.video.codec !== 'gif') {
args.push('-crf', String(preset.video.crf));
}
}
if (
preset.video.bitrateKbps !== undefined &&
preset.video.bitrateKbps > 0
) {
args.push('-b:v', `${preset.video.bitrateKbps}k`);
}
if (preset.video.pixelFormat) {
args.push('-pix_fmt', preset.video.pixelFormat);
}
if (preset.video.encoderPreset && preset.video.codec === 'libx264') {
args.push('-preset', preset.video.encoderPreset);
}
if (preset.video.frameRate) {
args.push('-r', String(preset.video.frameRate));
}
if (preset.video.codec === 'libwebp_anim') {
args.push('-loop', '0');
}
} else {
args.push('-vn');
}
if (preset.audio) {
args.push('-c:a', preset.audio.codec);
if (preset.audio.bitrateKbps !== undefined) {
args.push('-b:a', `${preset.audio.bitrateKbps}k`);
}
if (preset.audio.sampleRate !== undefined) {
args.push('-ar', String(preset.audio.sampleRate));
}
if (preset.audio.channels !== undefined) {
args.push('-ac', String(preset.audio.channels));
}
} else {
args.push('-an');
}
if (preset.fastStart) {
args.push('-movflags', '+faststart');
}
return Object.freeze(args);
}
+734
View File
@@ -0,0 +1,734 @@
import type {
AudioCodec,
AudioEncodingSettings,
ChapterPolicy,
MetadataPolicy,
PresetKind,
QualityMode,
SubtitlePolicy,
UserExportPreset,
VideoCodec,
VideoEncodingSettings,
} from './preset.types';
export interface PresetValidationResult {
readonly valid: boolean;
readonly errors: readonly string[];
readonly preset?: UserExportPreset;
}
const ROOT_FIELDS = new Set([
'schemaVersion',
'id',
'name',
'description',
'kind',
'container',
'fileExtension',
'video',
'audio',
'metadataPolicy',
'chapterPolicy',
'subtitlePolicy',
'fastStart',
]);
const VIDEO_FIELDS = new Set([
'codec',
'qualityMode',
'crf',
'bitrateKbps',
'width',
'height',
'frameRate',
'pixelFormat',
'encoderPreset',
]);
const AUDIO_FIELDS = new Set([
'codec',
'bitrateKbps',
'sampleRate',
'channels',
]);
const LEGACY_ROOT_FIELDS = new Set([
'schemaVersion',
'id',
'name',
'description',
'kind',
'format',
'fileExtension',
'video',
'audio',
'metadataPolicy',
'includeChapters',
'subtitlePolicy',
'fastStart',
]);
type UserPresetKind = Exclude<PresetKind, 'remux'>;
interface UserPresetContainerCompatibility {
readonly extension: string;
readonly kinds: readonly UserPresetKind[];
readonly videoCodecs: readonly VideoCodec[];
readonly audioCodecs: readonly AudioCodec[];
readonly requiresVideo?: boolean;
readonly requiresAudio?: boolean;
readonly forbidsVideo?: boolean;
readonly forbidsAudio?: boolean;
readonly supportsFastStart?: boolean;
}
/**
* This is deliberately narrower than everything FFmpeg may happen to accept.
* Each entry represents a reviewed path through the typed command builder.
* New combinations must be added together with a command/runtime regression.
*/
const USER_PRESET_CONTAINER_COMPATIBILITY: Readonly<
Record<string, UserPresetContainerCompatibility>
> = Object.freeze({
mp4: {
extension: 'mp4',
kinds: ['video'],
videoCodecs: ['libx264'],
audioCodecs: ['aac'],
requiresVideo: true,
supportsFastStart: true,
},
webm: {
extension: 'webm',
kinds: ['video'],
videoCodecs: ['libvpx-vp9', 'libvpx'],
audioCodecs: ['libopus', 'libvorbis'],
requiresVideo: true,
},
matroska: {
extension: 'mkv',
kinds: ['video'],
videoCodecs: ['libx264', 'libvpx-vp9', 'libvpx'],
audioCodecs: [
'aac',
'libmp3lame',
'libvorbis',
'libopus',
'pcm_s16le',
'flac',
],
requiresVideo: true,
},
webp: {
extension: 'webp',
kinds: ['derivative'],
videoCodecs: ['libwebp_anim'],
audioCodecs: [],
requiresVideo: true,
forbidsAudio: true,
},
gif: {
extension: 'gif',
kinds: ['derivative'],
videoCodecs: ['gif'],
audioCodecs: [],
requiresVideo: true,
forbidsAudio: true,
},
mp3: {
extension: 'mp3',
kinds: ['audio'],
videoCodecs: [],
audioCodecs: ['libmp3lame'],
requiresAudio: true,
forbidsVideo: true,
},
ipod: {
extension: 'm4a',
kinds: ['audio'],
videoCodecs: [],
audioCodecs: ['aac'],
requiresAudio: true,
forbidsVideo: true,
supportsFastStart: true,
},
ogg: {
extension: 'ogg',
kinds: ['audio'],
videoCodecs: [],
audioCodecs: ['libvorbis'],
requiresAudio: true,
forbidsVideo: true,
},
opus: {
extension: 'opus',
kinds: ['audio'],
videoCodecs: [],
audioCodecs: ['libopus'],
requiresAudio: true,
forbidsVideo: true,
},
wav: {
extension: 'wav',
kinds: ['audio'],
videoCodecs: [],
audioCodecs: ['pcm_s16le'],
requiresAudio: true,
forbidsVideo: true,
},
flac: {
extension: 'flac',
kinds: ['audio'],
videoCodecs: [],
audioCodecs: ['flac'],
requiresAudio: true,
forbidsVideo: true,
},
});
export const USER_PRESET_CONTAINERS: readonly string[] = Object.freeze(
Object.keys(USER_PRESET_CONTAINER_COMPATIBILITY)
);
export function validateUserPreset(value: unknown): PresetValidationResult {
const errors: string[] = [];
if (!isRecord(value)) {
return { valid: false, errors: ['Preset must be a JSON object'] };
}
rejectUnknownFields(value, ROOT_FIELDS, 'preset', errors);
if (value.schemaVersion !== 1) {
errors.push('schemaVersion must be 1');
}
const id = readIdentifier(value.id, 'id', errors);
const name = readText(value.name, 'name', 1, 80, errors);
const description = readOptionalText(
value.description,
'description',
400,
errors
);
const kind = readUnion(
value.kind,
['video', 'audio', 'derivative'],
'kind',
errors
);
const container = readIdentifier(value.container, 'container', errors);
const fileExtension = readExtension(value.fileExtension, errors);
const metadataPolicy = readUnion(
value.metadataPolicy,
['copy', 'edit', 'remove'] satisfies readonly MetadataPolicy[],
'metadataPolicy',
errors
);
const chapterPolicy = readUnion(
value.chapterPolicy,
['keep', 'remove', 'replace'] satisfies readonly ChapterPolicy[],
'chapterPolicy',
errors
);
const subtitlePolicy = readUnion(
value.subtitlePolicy,
[
'none',
'copy-compatible',
'convert',
'burn-in',
] satisfies readonly SubtitlePolicy[],
'subtitlePolicy',
errors
);
const video =
value.video === undefined ? undefined : parseVideo(value.video, errors);
const audio =
value.audio === undefined ? undefined : parseAudio(value.audio, errors);
if (!video && !audio) {
errors.push('At least one of video or audio is required');
}
if (kind === 'audio' && video) {
errors.push('Audio presets cannot include video settings');
}
if (value.fastStart !== undefined && typeof value.fastStart !== 'boolean') {
errors.push('fastStart must be a boolean');
}
validateQuickPresetPolicies(
metadataPolicy,
chapterPolicy,
subtitlePolicy,
errors
);
validateContainerCompatibility(
{
kind,
container,
fileExtension,
video,
audio,
fastStart: value.fastStart,
},
errors
);
if (
errors.length > 0 ||
!id ||
!name ||
!kind ||
!container ||
!fileExtension ||
!metadataPolicy ||
!chapterPolicy ||
!subtitlePolicy
) {
return { valid: false, errors: Object.freeze(errors) };
}
const preset: UserExportPreset = {
schemaVersion: 1,
id,
name,
kind,
container,
fileExtension,
metadataPolicy,
chapterPolicy,
subtitlePolicy,
...(description ? { description } : {}),
...(video ? { video } : {}),
...(audio ? { audio } : {}),
...(typeof value.fastStart === 'boolean'
? { fastStart: value.fastStart }
: {}),
};
return {
valid: true,
errors: Object.freeze([]),
preset: deepFreezePreset(preset),
};
}
function validateQuickPresetPolicies(
metadataPolicy: MetadataPolicy | undefined,
chapterPolicy: ChapterPolicy | undefined,
subtitlePolicy: SubtitlePolicy | undefined,
errors: string[]
): void {
if (metadataPolicy === 'edit') {
errors.push(
'metadataPolicy "edit" is not supported by Quick presets; use "copy" or "remove"'
);
}
if (chapterPolicy === 'replace') {
errors.push(
'chapterPolicy "replace" is not supported by Quick presets; use "keep" or "remove"'
);
}
if (subtitlePolicy === 'burn-in') {
errors.push(
'subtitlePolicy "burn-in" requires an explicit subtitle source and is not supported by Quick presets'
);
}
}
function validateContainerCompatibility(
fields: {
readonly kind: UserPresetKind | undefined;
readonly container: string | undefined;
readonly fileExtension: string | undefined;
readonly video: VideoEncodingSettings | undefined;
readonly audio: AudioEncodingSettings | undefined;
readonly fastStart: unknown;
},
errors: string[]
): void {
if (!fields.container) {
return;
}
const compatibility = USER_PRESET_CONTAINER_COMPATIBILITY[fields.container];
if (!compatibility) {
errors.push(
`container "${fields.container}" is not in the reviewed user-preset allowlist (allowed: ${USER_PRESET_CONTAINERS.join(', ')})`
);
return;
}
if (
fields.fileExtension &&
fields.fileExtension !== compatibility.extension
) {
errors.push(
`fileExtension must be "${compatibility.extension}" for container "${fields.container}"`
);
}
if (fields.kind && !compatibility.kinds.includes(fields.kind)) {
errors.push(
`container "${fields.container}" is not compatible with preset kind "${fields.kind}"`
);
}
if (compatibility.requiresVideo && !fields.video) {
errors.push(`container "${fields.container}" requires video settings`);
}
if (compatibility.requiresAudio && !fields.audio) {
errors.push(`container "${fields.container}" requires audio settings`);
}
if (compatibility.forbidsVideo && fields.video) {
errors.push(
`container "${fields.container}" does not support video settings`
);
}
if (compatibility.forbidsAudio && fields.audio) {
errors.push(
`container "${fields.container}" does not support audio settings`
);
}
if (fields.video && !compatibility.videoCodecs.includes(fields.video.codec)) {
errors.push(
`video codec "${fields.video.codec}" is not supported in container "${fields.container}" (allowed: ${formatAllowlist(compatibility.videoCodecs)})`
);
}
if (fields.audio && !compatibility.audioCodecs.includes(fields.audio.codec)) {
errors.push(
`audio codec "${fields.audio.codec}" is not supported in container "${fields.container}" (allowed: ${formatAllowlist(compatibility.audioCodecs)})`
);
}
if (fields.fastStart === true && !compatibility.supportsFastStart) {
errors.push(
`fastStart is only supported for reviewed MP4/M4A containers, not "${fields.container}"`
);
}
}
function formatAllowlist(values: readonly string[]): string {
return values.length > 0 ? values.join(', ') : 'none';
}
export function parseUserPresetJson(json: string): UserExportPreset {
if (new TextEncoder().encode(json).byteLength > 64 * 1024) {
throw new RangeError('Preset JSON exceeds the 64 KiB import limit');
}
let value: unknown;
try {
value = JSON.parse(json) as unknown;
} catch {
throw new TypeError('Preset JSON is malformed');
}
const result = validateUserPreset(migrateUserPreset(value));
if (!result.valid || !result.preset) {
throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`);
}
return result.preset;
}
/**
* Migrates the historical development schema into schema 1. Unknown legacy
* fields are rejected before migration, so they cannot smuggle executable
* arguments or filter expressions into the allowlisted model.
*/
export function migrateUserPreset(value: unknown): unknown {
if (!isRecord(value) || value.schemaVersion !== 0) {
return value;
}
const errors: string[] = [];
rejectUnknownFields(value, LEGACY_ROOT_FIELDS, 'legacy preset', errors);
if (errors.length > 0) {
throw new TypeError(errors.join('; '));
}
if (typeof value.format !== 'string') {
throw new TypeError('Legacy preset format is required');
}
const extension =
typeof value.fileExtension === 'string'
? value.fileExtension
: value.format === 'matroska'
? 'mkv'
: value.format === 'ipod'
? 'm4a'
: value.format;
return {
schemaVersion: 1,
id: value.id,
name: value.name,
...(value.description !== undefined
? { description: value.description }
: {}),
kind: value.kind,
container: value.format,
fileExtension: extension,
...(value.video !== undefined ? { video: value.video } : {}),
...(value.audio !== undefined ? { audio: value.audio } : {}),
metadataPolicy: value.metadataPolicy ?? 'copy',
chapterPolicy: value.includeChapters === true ? 'keep' : 'remove',
subtitlePolicy: value.subtitlePolicy ?? 'none',
...(value.fastStart !== undefined ? { fastStart: value.fastStart } : {}),
};
}
export function serializeUserPreset(preset: UserExportPreset): string {
const result = validateUserPreset(preset);
if (!result.valid || !result.preset) {
throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`);
}
return `${JSON.stringify(result.preset, null, 2)}\n`;
}
function parseVideo(
value: unknown,
errors: string[]
): VideoEncodingSettings | undefined {
if (!isRecord(value)) {
errors.push('video must be an object');
return undefined;
}
rejectUnknownFields(value, VIDEO_FIELDS, 'video', errors);
const codec = readUnion(
value.codec,
['libx264', 'libvpx-vp9', 'libvpx', 'libwebp_anim', 'gif'],
'video.codec',
errors
);
const qualityMode = readUnion(
value.qualityMode,
[
'compatibility',
'balanced',
'smaller-file',
'higher-quality',
'custom',
] satisfies readonly QualityMode[],
'video.qualityMode',
errors
);
const crf = readOptionalNumber(value.crf, 'video.crf', 0, 63, errors);
const bitrateKbps = readOptionalNumber(
value.bitrateKbps,
'video.bitrateKbps',
0,
1_000_000,
errors
);
const width = readOptionalInteger(
value.width,
'video.width',
2,
8192,
errors
);
const height = readOptionalInteger(
value.height,
'video.height',
2,
8192,
errors
);
const frameRate = readOptionalNumber(
value.frameRate,
'video.frameRate',
0.01,
240,
errors
);
const pixelFormat =
value.pixelFormat === undefined
? undefined
: readUnion(
value.pixelFormat,
['yuv420p', 'yuv422p', 'yuv444p', 'rgba'],
'video.pixelFormat',
errors
);
const encoderPreset =
value.encoderPreset === undefined
? undefined
: readUnion(
value.encoderPreset,
['ultrafast', 'veryfast', 'fast', 'medium', 'slow', 'veryslow'],
'video.encoderPreset',
errors
);
if (!codec || !qualityMode) {
return undefined;
}
return {
codec,
qualityMode,
...(crf !== undefined ? { crf } : {}),
...(bitrateKbps !== undefined ? { bitrateKbps } : {}),
...(width !== undefined ? { width } : {}),
...(height !== undefined ? { height } : {}),
...(frameRate !== undefined ? { frameRate } : {}),
...(pixelFormat ? { pixelFormat } : {}),
...(encoderPreset ? { encoderPreset } : {}),
};
}
function parseAudio(
value: unknown,
errors: string[]
): AudioEncodingSettings | undefined {
if (!isRecord(value)) {
errors.push('audio must be an object');
return undefined;
}
rejectUnknownFields(value, AUDIO_FIELDS, 'audio', errors);
const codec = readUnion(
value.codec,
['aac', 'libmp3lame', 'libvorbis', 'libopus', 'pcm_s16le', 'flac'],
'audio.codec',
errors
);
const bitrateKbps = readOptionalNumber(
value.bitrateKbps,
'audio.bitrateKbps',
8,
2_000,
errors
);
const sampleRate =
value.sampleRate === undefined
? undefined
: readUnion(
value.sampleRate,
[22_050, 32_000, 44_100, 48_000, 96_000],
'audio.sampleRate',
errors
);
const channels =
value.channels === undefined
? undefined
: readUnion(value.channels, [1, 2, 6], 'audio.channels', errors);
if (!codec) {
return undefined;
}
return {
codec,
...(bitrateKbps !== undefined ? { bitrateKbps } : {}),
...(sampleRate ? { sampleRate } : {}),
...(channels ? { channels } : {}),
};
}
function rejectUnknownFields(
value: Record<string, unknown>,
allowlist: ReadonlySet<string>,
label: string,
errors: string[]
): void {
for (const key of Object.keys(value)) {
if (!allowlist.has(key)) {
errors.push(`${label} contains unsupported field "${key}"`);
}
}
}
function readIdentifier(
value: unknown,
label: string,
errors: string[]
): string | undefined {
if (
typeof value !== 'string' ||
!/^[a-z0-9][a-z0-9._-]{0,79}$/iu.test(value)
) {
errors.push(`${label} must be a safe identifier`);
return undefined;
}
return value;
}
function readText(
value: unknown,
label: string,
minimum: number,
maximum: number,
errors: string[]
): string | undefined {
if (
typeof value !== 'string' ||
value.trim().length < minimum ||
value.trim().length > maximum ||
// eslint-disable-next-line no-control-regex -- imported preset text must reject non-printing control characters.
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
) {
errors.push(`${label} must contain ${minimum}${maximum} safe characters`);
return undefined;
}
return value.trim();
}
function readOptionalText(
value: unknown,
label: string,
maximum: number,
errors: string[]
): string | undefined {
if (value === undefined) {
return undefined;
}
return readText(value, label, 1, maximum, errors);
}
function readExtension(value: unknown, errors: string[]): string | undefined {
if (typeof value !== 'string' || !/^[a-z0-9]{1,10}$/u.test(value)) {
errors.push('fileExtension must contain 110 lowercase letters or digits');
return undefined;
}
return value;
}
function readUnion<T extends string | number>(
value: unknown,
values: readonly T[],
label: string,
errors: string[]
): T | undefined {
const match = values.find((entry) => entry === value);
if (match === undefined) {
errors.push(`${label} has an unsupported value`);
}
return match;
}
function readOptionalNumber(
value: unknown,
label: string,
minimum: number,
maximum: number,
errors: string[]
): number | undefined {
if (value === undefined) {
return undefined;
}
if (
typeof value !== 'number' ||
!Number.isFinite(value) ||
value < minimum ||
value > maximum
) {
errors.push(`${label} must be between ${minimum} and ${maximum}`);
return undefined;
}
return value;
}
function readOptionalInteger(
value: unknown,
label: string,
minimum: number,
maximum: number,
errors: string[]
): number | undefined {
const result = readOptionalNumber(value, label, minimum, maximum, errors);
if (result !== undefined && !Number.isSafeInteger(result)) {
errors.push(`${label} must be an integer`);
return undefined;
}
return result;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function deepFreezePreset(preset: UserExportPreset): UserExportPreset {
return Object.freeze({
...preset,
...(preset.video ? { video: Object.freeze({ ...preset.video }) } : {}),
...(preset.audio ? { audio: Object.freeze({ ...preset.audio }) } : {}),
});
}
+84
View File
@@ -0,0 +1,84 @@
import type { PresetRequirements } from '../commands/command-plan';
export type MetadataPolicy = 'copy' | 'edit' | 'remove';
export type ChapterPolicy = 'keep' | 'remove' | 'replace';
export type SubtitlePolicy = 'none' | 'copy-compatible' | 'convert' | 'burn-in';
export type PresetKind = 'video' | 'audio' | 'derivative' | 'remux';
export type QualityMode =
'compatibility' | 'balanced' | 'smaller-file' | 'higher-quality' | 'custom';
export type VideoCodec =
'libx264' | 'libvpx-vp9' | 'libvpx' | 'libwebp_anim' | 'gif';
export type AudioCodec =
'aac' | 'libmp3lame' | 'libvorbis' | 'libopus' | 'pcm_s16le' | 'flac';
export interface VideoEncodingSettings {
readonly codec: VideoCodec;
readonly qualityMode: QualityMode;
readonly crf?: number;
readonly bitrateKbps?: number;
readonly width?: number;
readonly height?: number;
readonly frameRate?: number;
readonly pixelFormat?: 'yuv420p' | 'yuv422p' | 'yuv444p' | 'rgba';
readonly encoderPreset?:
'ultrafast' | 'veryfast' | 'fast' | 'medium' | 'slow' | 'veryslow';
}
export interface AudioEncodingSettings {
readonly codec: AudioCodec;
readonly bitrateKbps?: number;
readonly sampleRate?: 22_050 | 32_000 | 44_100 | 48_000 | 96_000;
readonly channels?: 1 | 2 | 6;
}
export interface ExportPresetBase {
readonly id: string;
readonly name: string;
readonly description: string;
readonly kind: PresetKind;
readonly container: string;
readonly fileExtension: string;
readonly requirements: PresetRequirements;
readonly video?: VideoEncodingSettings;
readonly audio?: AudioEncodingSettings;
readonly metadataPolicy: MetadataPolicy;
readonly chapterPolicy: ChapterPolicy;
readonly subtitlePolicy: SubtitlePolicy;
readonly fastStart?: boolean;
/** Concise documentation of the fixed encoding decisions in the preset. */
readonly settingsSummary: readonly string[];
}
export interface BuiltInExportPreset extends ExportPresetBase {
readonly builtIn: true;
}
export interface UserExportPreset {
readonly schemaVersion: 1;
readonly id: string;
readonly name: string;
readonly description?: string;
readonly kind: Exclude<PresetKind, 'remux'>;
readonly container: string;
readonly fileExtension: string;
readonly video?: VideoEncodingSettings;
readonly audio?: AudioEncodingSettings;
readonly metadataPolicy: MetadataPolicy;
readonly chapterPolicy: ChapterPolicy;
readonly subtitlePolicy: SubtitlePolicy;
readonly fastStart?: boolean;
}
export interface FFmpegCapabilitySnapshot {
readonly muxers: ReadonlySet<string>;
readonly encoders: ReadonlySet<string>;
readonly decoders: ReadonlySet<string>;
readonly filters: ReadonlySet<string>;
}
export interface PresetAvailability {
readonly status: 'available' | 'degraded' | 'unavailable';
readonly reasons: readonly string[];
}
+228
View File
@@ -0,0 +1,228 @@
import type { UserExportPreset } from './preset.types';
import {
parseUserPresetJson,
serializeUserPreset,
validateUserPreset,
} from './preset-validation';
export interface UserPresetPersistence {
load(): Promise<readonly UserExportPreset[]>;
save(presets: readonly UserExportPreset[]): Promise<void>;
}
export class UserPresetRegistry {
readonly #persistence: UserPresetPersistence;
#presets = new Map<string, UserExportPreset>();
constructor(persistence: UserPresetPersistence) {
this.#persistence = persistence;
}
async initialize(): Promise<void> {
const loaded = await this.#persistence.load();
const validated = loaded.map((preset) => {
const result = validateUserPreset(preset);
if (!result.valid || !result.preset) {
throw new TypeError(
`Stored preset is invalid: ${result.errors.join('; ')}`
);
}
return result.preset;
});
this.#presets = new Map(validated.map((preset) => [preset.id, preset]));
}
list(): readonly UserExportPreset[] {
return Object.freeze(
[...this.#presets.values()].sort((left, right) =>
left.name.localeCompare(right.name)
)
);
}
get(id: string): UserExportPreset | undefined {
return this.#presets.get(id);
}
async save(preset: UserExportPreset): Promise<void> {
const result = validateUserPreset(preset);
if (!result.valid || !result.preset) {
throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`);
}
this.#presets.set(result.preset.id, result.preset);
await this.#persist();
}
async clone(
id: string,
newId: string,
newName: string
): Promise<UserExportPreset> {
const original = this.#presets.get(id);
if (!original) {
throw new RangeError(`Unknown preset: ${id}`);
}
const cloned = { ...original, id: newId, name: newName };
await this.save(cloned);
return this.#presets.get(newId) as UserExportPreset;
}
async rename(id: string, name: string): Promise<void> {
const preset = this.#presets.get(id);
if (!preset) {
throw new RangeError(`Unknown preset: ${id}`);
}
await this.save({ ...preset, name });
}
async delete(id: string): Promise<boolean> {
const deleted = this.#presets.delete(id);
if (deleted) {
await this.#persist();
}
return deleted;
}
async reset(): Promise<void> {
this.#presets.clear();
await this.#persist();
}
export(id: string): string {
const preset = this.#presets.get(id);
if (!preset) {
throw new RangeError(`Unknown preset: ${id}`);
}
return serializeUserPreset(preset);
}
async import(json: string): Promise<UserExportPreset> {
const preset = parseUserPresetJson(json);
await this.save(preset);
return preset;
}
async #persist(): Promise<void> {
await this.#persistence.save(this.list());
}
}
export class MemoryUserPresetPersistence implements UserPresetPersistence {
#presets: readonly UserExportPreset[] = [];
async load(): Promise<readonly UserExportPreset[]> {
return this.#presets;
}
async save(presets: readonly UserExportPreset[]): Promise<void> {
this.#presets = structuredClone(presets);
}
}
export interface IndexedDbUserPresetPersistenceOptions {
readonly databaseName?: string;
readonly storeName?: string;
readonly indexedDB?: IDBFactory;
}
/**
* Stores only validated typed preset documents. A dedicated database avoids
* version races with durable job metadata stores that may be opened first.
*/
export class IndexedDbUserPresetPersistence implements UserPresetPersistence {
readonly #databaseName: string;
readonly #storeName: string;
readonly #factory: IDBFactory;
#database?: Promise<IDBDatabase>;
constructor(options: IndexedDbUserPresetPersistenceOptions = {}) {
const factory = options.indexedDB ?? globalThis.indexedDB;
if (!factory) {
throw new Error('IndexedDB is unavailable');
}
this.#factory = factory;
this.#databaseName = options.databaseName ?? 'av-tools-user-presets-v1';
this.#storeName = options.storeName ?? 'presets';
}
async load(): Promise<readonly UserExportPreset[]> {
const request = (await this.#store('readonly')).get('all');
const value = await requestResult<unknown>(request);
if (value === undefined) {
return Object.freeze([]);
}
if (!Array.isArray(value)) {
throw new TypeError('Stored user presets are invalid');
}
return Object.freeze(
value.map((preset) => {
const result = validateUserPreset(preset);
if (!result.valid || !result.preset) {
throw new TypeError(
`Stored preset is invalid: ${result.errors.join('; ')}`
);
}
return result.preset;
})
);
}
async save(presets: readonly UserExportPreset[]): Promise<void> {
const validated = presets.map((preset) => {
const result = validateUserPreset(preset);
if (!result.valid || !result.preset) {
throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`);
}
return result.preset;
});
const store = await this.#store('readwrite');
const completion = transactionComplete(store.transaction);
await requestResult<IDBValidKey>(
store.put(structuredClone(validated), 'all')
);
await completion;
}
async #store(mode: IDBTransactionMode): Promise<IDBObjectStore> {
const database = await (this.#database ??= this.#open());
return database
.transaction(this.#storeName, mode)
.objectStore(this.#storeName);
}
#open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = this.#factory.open(this.#databaseName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(this.#storeName)) {
request.result.createObjectStore(this.#storeName);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
reject(request.error ?? new Error('Could not open preset storage'));
request.onblocked = () =>
reject(new Error('Preset storage upgrade is blocked'));
});
}
}
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
reject(request.error ?? new Error('Preset storage request failed'));
});
}
function transactionComplete(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () =>
reject(
transaction.error ?? new Error('Preset storage transaction failed')
);
transaction.onabort = () =>
reject(transaction.error ?? new Error('Preset storage was aborted'));
});
}
+201
View File
@@ -0,0 +1,201 @@
import type { BuiltInExportPreset } from './preset.types';
function immutable<T extends BuiltInExportPreset>(preset: T): Readonly<T> {
return Object.freeze({
...preset,
requirements: Object.freeze({
...preset.requirements,
...(preset.requirements.muxers
? { muxers: Object.freeze([...preset.requirements.muxers]) }
: {}),
...(preset.requirements.encoders
? { encoders: Object.freeze([...preset.requirements.encoders]) }
: {}),
...(preset.requirements.filters
? { filters: Object.freeze([...preset.requirements.filters]) }
: {}),
}),
...(preset.video ? { video: Object.freeze({ ...preset.video }) } : {}),
...(preset.audio ? { audio: Object.freeze({ ...preset.audio }) } : {}),
settingsSummary: Object.freeze([...preset.settingsSummary]),
});
}
export const VIDEO_PRESETS: readonly BuiltInExportPreset[] = Object.freeze([
immutable({
id: 'mp4-h264-balanced',
name: 'MP4 · H.264 + AAC',
description: 'Balanced browser-friendly video.',
kind: 'video',
container: 'mp4',
fileExtension: 'mp4',
video: {
codec: 'libx264',
qualityMode: 'balanced',
crf: 23,
pixelFormat: 'yuv420p',
encoderPreset: 'medium',
},
audio: { codec: 'aac', bitrateKbps: 160, sampleRate: 48_000, channels: 2 },
fastStart: true,
metadataPolicy: 'copy',
chapterPolicy: 'keep',
subtitlePolicy: 'convert',
requirements: {
muxers: ['mp4'],
encoders: ['libx264', 'aac'],
},
settingsSummary: [
'libx264 CRF 23, medium preset',
'yuv420p pixel format',
'AAC 160 kbit/s stereo at 48 kHz',
'fast-start metadata',
],
builtIn: true,
}),
immutable({
id: 'mp4-h264-compatibility',
name: 'MP4 · H.264 compatibility',
description: 'Conservative settings for broad playback support.',
kind: 'video',
container: 'mp4',
fileExtension: 'mp4',
video: {
codec: 'libx264',
qualityMode: 'compatibility',
crf: 24,
pixelFormat: 'yuv420p',
encoderPreset: 'fast',
},
audio: { codec: 'aac', bitrateKbps: 128, sampleRate: 44_100, channels: 2 },
fastStart: true,
metadataPolicy: 'copy',
chapterPolicy: 'keep',
subtitlePolicy: 'convert',
requirements: { muxers: ['mp4'], encoders: ['libx264', 'aac'] },
settingsSummary: [
'libx264 CRF 24, fast preset',
'yuv420p pixel format',
'AAC 128 kbit/s stereo at 44.1 kHz',
],
builtIn: true,
}),
immutable({
id: 'webm-vp9-opus',
name: 'WebM · VP9 + Opus',
description: 'Efficient open web video when both encoders are available.',
kind: 'video',
container: 'webm',
fileExtension: 'webm',
video: {
codec: 'libvpx-vp9',
qualityMode: 'smaller-file',
crf: 32,
bitrateKbps: 0,
pixelFormat: 'yuv420p',
},
audio: {
codec: 'libopus',
bitrateKbps: 128,
sampleRate: 48_000,
channels: 2,
},
metadataPolicy: 'copy',
chapterPolicy: 'keep',
subtitlePolicy: 'convert',
requirements: {
muxers: ['webm'],
encoders: ['libvpx-vp9', 'libopus'],
},
settingsSummary: ['libvpx-vp9 CRF 32', 'Opus 128 kbit/s stereo at 48 kHz'],
builtIn: true,
}),
immutable({
id: 'webm-vp8-vorbis',
name: 'WebM · VP8 + Vorbis',
description: 'Compatibility-oriented open web video.',
kind: 'video',
container: 'webm',
fileExtension: 'webm',
video: {
codec: 'libvpx',
qualityMode: 'compatibility',
crf: 18,
bitrateKbps: 1_500,
pixelFormat: 'yuv420p',
},
audio: {
codec: 'libvorbis',
bitrateKbps: 128,
sampleRate: 48_000,
channels: 2,
},
metadataPolicy: 'copy',
chapterPolicy: 'keep',
subtitlePolicy: 'convert',
requirements: { muxers: ['webm'], encoders: ['libvpx', 'libvorbis'] },
settingsSummary: ['libvpx CRF 18, 1.5 Mbit/s target', 'Vorbis 128 kbit/s'],
builtIn: true,
}),
immutable({
id: 'animated-webp',
name: 'Animated WebP',
description: 'Animated image derivative without audio.',
kind: 'derivative',
container: 'webp',
fileExtension: 'webp',
video: {
codec: 'libwebp_anim',
qualityMode: 'balanced',
crf: 60,
pixelFormat: 'rgba',
},
metadataPolicy: 'remove',
chapterPolicy: 'remove',
subtitlePolicy: 'none',
requirements: { muxers: ['webp'], encoders: ['libwebp_anim'] },
settingsSummary: ['libwebp_anim quality 60', 'audio omitted'],
builtIn: true,
}),
immutable({
id: 'animated-gif',
name: 'Animated GIF',
description: 'Advanced compatibility derivative with limited colours.',
kind: 'derivative',
container: 'gif',
fileExtension: 'gif',
video: { codec: 'gif', qualityMode: 'compatibility' },
metadataPolicy: 'remove',
chapterPolicy: 'remove',
subtitlePolicy: 'none',
requirements: {
muxers: ['gif'],
encoders: ['gif'],
filters: ['palettegen', 'paletteuse'],
},
settingsSummary: ['palettegen/paletteuse filter workflow', 'audio omitted'],
builtIn: true,
}),
immutable({
id: 'matroska-h264-aac',
name: 'Matroska · H.264 + AAC',
description: 'Flexible Matroska output with selected compatible streams.',
kind: 'video',
container: 'matroska',
fileExtension: 'mkv',
video: {
codec: 'libx264',
qualityMode: 'higher-quality',
crf: 20,
pixelFormat: 'yuv420p',
encoderPreset: 'medium',
},
audio: { codec: 'aac', bitrateKbps: 192, sampleRate: 48_000, channels: 2 },
metadataPolicy: 'copy',
chapterPolicy: 'keep',
subtitlePolicy: 'copy-compatible',
requirements: { muxers: ['matroska'], encoders: ['libx264', 'aac'] },
settingsSummary: ['libx264 CRF 20', 'AAC 192 kbit/s', 'Matroska container'],
builtIn: true,
}),
]);
+8
View File
@@ -0,0 +1,8 @@
export * from './migrations';
export * from './project.reducer';
export * from './project.schema';
export * from './project.selectors';
export * from './project.serialization';
export * from './project.types';
export * from './project.validation';
export * from './source-reattachment';
+67
View File
@@ -0,0 +1,67 @@
import type { AvProjectV1, LegacyAvProjectV0 } from '../project.types';
import { migrateV0ToV1, type ProjectMigrationWarning } from './v0-to-v1';
export { migrateV0ToV1 };
export type { ProjectMigrationWarning };
export interface ProjectMigrationResult {
project: AvProjectV1;
fromVersion: 0 | 1;
toVersion: 1;
warnings: ProjectMigrationWarning[];
}
export class ProjectMigrationError extends Error {
readonly schemaVersion: unknown;
constructor(message: string, schemaVersion: unknown) {
super(message);
this.name = 'ProjectMigrationError';
this.schemaVersion = schemaVersion;
}
}
export function migrateProjectDocument(value: unknown): ProjectMigrationResult {
if (!isRecord(value)) {
throw new ProjectMigrationError(
'A project document must be a JSON object.',
undefined
);
}
if (value.schemaVersion === 1) {
let project: AvProjectV1;
try {
project = structuredClone(value) as unknown as AvProjectV1;
} catch {
throw new ProjectMigrationError(
'The project contains a value that cannot be migrated.',
value.schemaVersion
);
}
return {
project,
fromVersion: 1,
toVersion: 1,
warnings: [],
};
}
if (value.schemaVersion === 0) {
const migration = migrateV0ToV1(value as unknown as LegacyAvProjectV0);
return {
project: migration.project,
fromVersion: 0,
toVersion: 1,
warnings: migration.warnings,
};
}
throw new ProjectMigrationError(
value.schemaVersion === undefined
? 'The project document does not declare a schema version.'
: `Project schema version ${String(value.schemaVersion)} is not supported.`,
value.schemaVersion
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
+298
View File
@@ -0,0 +1,298 @@
import type { AvProjectV1, LegacyAvProjectV0 } from '../project.types';
export interface ProjectMigrationWarning {
code: 'renamed-field' | 'defaulted-field' | 'source-reattachment-required';
path: string;
message: string;
}
export interface V0MigrationResult {
project: AvProjectV1;
warnings: ProjectMigrationWarning[];
}
export function migrateV0ToV1(input: LegacyAvProjectV0): V0MigrationResult {
const warnings: ProjectMigrationWarning[] = [];
const rawAssets = Array.isArray(input.assets)
? input.assets
: Array.isArray(input.media)
? input.media
: [];
const rawTimeline = Array.isArray(input.timeline)
? input.timeline
: Array.isArray(input.clips)
? input.clips
: [];
if (input.title !== undefined && input.name === undefined) {
warnings.push({
code: 'renamed-field',
path: 'title',
message: 'Migrated the legacy "title" field to "name".',
});
}
if (input.media !== undefined && input.assets === undefined) {
warnings.push({
code: 'renamed-field',
path: 'media',
message: 'Migrated the legacy "media" collection to "assets".',
});
}
if (input.clips !== undefined && input.timeline === undefined) {
warnings.push({
code: 'renamed-field',
path: 'clips',
message: 'Migrated the legacy "clips" collection to "timeline".',
});
}
const project: AvProjectV1 = {
schemaVersion: 1,
id: stringValue(input.id),
name: stringValue(input.name ?? input.title ?? 'Untitled project'),
createdAt: stringValue(input.createdAt),
updatedAt: stringValue(input.updatedAt),
assets: rawAssets.map((asset, index) =>
migrateAsset(asset, index, warnings)
),
timeline: rawTimeline.map((clip, index) => migrateClip(clip, index)),
output: migrateOutput(input.output, warnings),
metadata: migrateMetadata(input.metadata),
chapters: (Array.isArray(input.chapters) ? input.chapters : []).map(
migrateChapter
),
subtitles: (Array.isArray(input.subtitles) ? input.subtitles : []).map(
migrateSubtitle
),
...(isRecord(input.ui) ? { ui: input.ui as AvProjectV1['ui'] } : {}),
};
return { project, warnings };
}
function migrateAsset(
value: unknown,
index: number,
warnings: ProjectMigrationWarning[]
): AvProjectV1['assets'][number] {
const asset = isRecord(value) ? value : {};
const originalName = stringValue(
asset.originalName ?? asset.fileName ?? asset.name ?? `source-${index}`
);
if (asset.originalName === undefined) {
warnings.push({
code: 'renamed-field',
path: `assets[${index}].originalName`,
message: 'Migrated a legacy source filename field.',
});
}
return {
id: stringValue(asset.id ?? `asset-${index}`),
originalName,
size: numberValue(asset.size),
lastModified: numberValue(asset.lastModified),
mimeType: stringValue(asset.mimeType ?? asset.type ?? ''),
sourceStatus: asset.sourceStatus === 'attached' ? 'attached' : 'missing',
...(isRecord(asset.probe)
? {
probe:
asset.probe as unknown as AvProjectV1['assets'][number]['probe'],
}
: {}),
...(isRecord(asset.hash)
? {
hash: asset.hash as unknown as AvProjectV1['assets'][number]['hash'],
}
: {}),
};
}
function migrateClip(
value: unknown,
index: number
): AvProjectV1['timeline'][number] {
const clip = isRecord(value) ? value : {};
return {
id: stringValue(clip.id ?? `clip-${index}`),
assetId: stringValue(clip.assetId),
sourceInSeconds: numberValue(
clip.sourceInSeconds ?? clip.inSeconds ?? clip.startSeconds
),
sourceOutSeconds: numberValue(
clip.sourceOutSeconds ?? clip.outSeconds ?? clip.endSeconds
),
...(isRecord(clip.crop)
? {
crop: clip.crop as unknown as AvProjectV1['timeline'][number]['crop'],
}
: {}),
...(isRecord(clip.resize)
? {
resize:
clip.resize as unknown as AvProjectV1['timeline'][number]['resize'],
}
: {}),
...(clip.rotation !== undefined
? {
rotation:
clip.rotation as AvProjectV1['timeline'][number]['rotation'],
}
: {}),
...(clip.frameRate !== undefined
? { frameRate: numberValue(clip.frameRate) }
: {}),
...(isRecord(clip.audio)
? {
audio:
clip.audio as unknown as AvProjectV1['timeline'][number]['audio'],
}
: {}),
...(isRecord(clip.video)
? {
video:
clip.video as unknown as AvProjectV1['timeline'][number]['video'],
}
: {}),
};
}
function migrateOutput(
value: unknown,
warnings: ProjectMigrationWarning[]
): AvProjectV1['output'] {
const output = isRecord(value) ? value : {};
const defaultsUsed = [
'presetId',
'fileName',
'container',
'videoEnabled',
'audioEnabled',
].some((field) => output[field] === undefined);
if (defaultsUsed) {
warnings.push({
code: 'defaulted-field',
path: 'output',
message: 'Filled missing legacy output settings with safe defaults.',
});
}
return {
presetId: stringValue(output.presetId ?? 'mp4-h264-balanced'),
fileName: stringValue(output.fileName ?? 'output.mp4'),
container: stringValue(output.container ?? 'mp4'),
videoEnabled:
typeof output.videoEnabled === 'boolean' ? output.videoEnabled : true,
audioEnabled:
typeof output.audioEnabled === 'boolean' ? output.audioEnabled : true,
...(finiteNumber(output.width) ? { width: Number(output.width) } : {}),
...(finiteNumber(output.height) ? { height: Number(output.height) } : {}),
...(finiteNumber(output.frameRate)
? { frameRate: Number(output.frameRate) }
: {}),
sampleRate: finiteNumber(output.sampleRate)
? Number(output.sampleRate)
: 48_000,
channelLayout:
output.channelLayout === 'mono' || output.channelLayout === '5.1'
? output.channelLayout
: 'stereo',
missingAudioPolicy:
output.missingAudioPolicy === 'drop-all' ||
output.missingAudioPolicy === 'reject'
? output.missingAudioPolicy
: 'insert-silence',
};
}
function migrateMetadata(value: unknown): AvProjectV1['metadata'] {
const metadata = isRecord(value) ? value : {};
return {
...(typeof metadata.title === 'string' ? { title: metadata.title } : {}),
...(typeof metadata.artist === 'string' ? { artist: metadata.artist } : {}),
...(typeof metadata.album === 'string' ? { album: metadata.album } : {}),
...(typeof metadata.comment === 'string'
? { comment: metadata.comment }
: {}),
custom: isStringRecord(metadata.custom) ? metadata.custom : {},
};
}
function migrateChapter(
value: unknown,
index: number
): AvProjectV1['chapters'][number] {
const chapter = isRecord(value) ? value : {};
return {
id: stringValue(chapter.id ?? `chapter-${index}`),
startSeconds: numberValue(chapter.startSeconds ?? chapter.start),
endSeconds: numberValue(chapter.endSeconds ?? chapter.end),
title: stringValue(chapter.title ?? `Chapter ${index + 1}`),
timeBase:
typeof chapter.timeBase === 'string' ? chapter.timeBase : '1/1000',
...(isStringRecord(chapter.metadata) ? { metadata: chapter.metadata } : {}),
};
}
function migrateSubtitle(
value: unknown,
index: number
): AvProjectV1['subtitles'][number] {
const subtitle = isRecord(value) ? value : {};
return {
id: stringValue(subtitle.id ?? `subtitle-${index}`),
...(typeof subtitle.assetId === 'string'
? { assetId: subtitle.assetId }
: {}),
...(typeof subtitle.sourceName === 'string'
? { sourceName: subtitle.sourceName }
: {}),
...(subtitle.sourceFormat === 'srt' ||
subtitle.sourceFormat === 'vtt' ||
subtitle.sourceFormat === 'ass'
? { sourceFormat: subtitle.sourceFormat }
: {}),
...(typeof subtitle.language === 'string'
? { language: subtitle.language }
: {}),
...(typeof subtitle.title === 'string' ? { title: subtitle.title } : {}),
...(typeof subtitle.offsetSeconds === 'number' &&
Number.isFinite(subtitle.offsetSeconds)
? { offsetSeconds: subtitle.offsetSeconds }
: {}),
...(typeof subtitle.default === 'boolean'
? { default: subtitle.default }
: {}),
...(typeof subtitle.forced === 'boolean'
? { forced: subtitle.forced }
: {}),
mode:
subtitle.mode === 'mux' || subtitle.mode === 'burn-in'
? subtitle.mode
: 'exclude',
...(subtitle.sourceStatus === 'attached' ||
subtitle.sourceStatus === 'missing'
? { sourceStatus: subtitle.sourceStatus }
: {}),
};
}
function numberValue(value: unknown): number {
return typeof value === 'number' ? value : Number(value ?? 0);
}
function finiteNumber(value: unknown): boolean {
return typeof value === 'number' && Number.isFinite(value);
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value : String(value ?? '');
}
function isStringRecord(value: unknown): value is Record<string, string> {
return (
isRecord(value) &&
Object.values(value).every((entry) => typeof entry === 'string')
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
+306
View File
@@ -0,0 +1,306 @@
import { validateSourceRange } from '../media/duration';
import type {
AvProjectV1,
MediaAssetReference,
OutputSettings,
ProjectChapter,
ProjectMetadataSettings,
ProjectSubtitleTrack,
ProjectUiState,
TimelineClip,
} from './project.types';
import { assertValidProject } from './project.validation';
export type ProjectAction =
| { type: 'project/replace'; project: AvProjectV1 }
| { type: 'project/rename'; name: string; updatedAt: string }
| {
type: 'asset/add';
asset: MediaAssetReference;
updatedAt: string;
}
| { type: 'asset/remove'; assetId: string; updatedAt: string }
| {
type: 'asset/set-source-status';
assetId: string;
sourceStatus: 'attached' | 'missing';
updatedAt: string;
}
| {
type: 'asset/update';
assetId: string;
changes: Partial<
Pick<MediaAssetReference, 'probe' | 'mimeType' | 'hash'>
>;
updatedAt: string;
}
| { type: 'clip/add'; clip: TimelineClip; index?: number; updatedAt: string }
| {
type: 'clip/update';
clipId: string;
changes: Partial<Omit<TimelineClip, 'id'>>;
updatedAt: string;
}
| { type: 'clip/remove'; clipId: string; updatedAt: string }
| {
type: 'clip/move';
clipId: string;
toIndex: number;
updatedAt: string;
}
| {
type: 'output/set';
output: OutputSettings;
updatedAt: string;
}
| {
type: 'metadata/set';
metadata: ProjectMetadataSettings;
updatedAt: string;
}
| {
type: 'chapters/set';
chapters: ProjectChapter[];
updatedAt: string;
}
| {
type: 'subtitles/set';
subtitles: ProjectSubtitleTrack[];
updatedAt: string;
}
| { type: 'ui/set'; ui: ProjectUiState | undefined };
export function projectReducer(
project: AvProjectV1,
action: ProjectAction
): AvProjectV1 {
switch (action.type) {
case 'project/replace':
return assertValidProject(action.project);
case 'project/rename': {
const name = action.name.trim();
if (!name) {
throw new ProjectReducerError('A project name cannot be empty.');
}
return touch(project, action.updatedAt, { name });
}
case 'asset/add': {
assertUniqueId(project.assets, action.asset.id, 'asset');
return touch(project, action.updatedAt, {
assets: [...project.assets, action.asset],
});
}
case 'asset/remove': {
assertExists(project.assets, action.assetId, 'asset');
const removedClipIds = new Set(
project.timeline
.filter((clip) => clip.assetId === action.assetId)
.map((clip) => clip.id)
);
return touch(project, action.updatedAt, {
assets: project.assets.filter((asset) => asset.id !== action.assetId),
timeline: project.timeline.filter(
(clip) => clip.assetId !== action.assetId
),
subtitles: project.subtitles.filter(
(track) => track.assetId !== action.assetId
),
ui: cleanUiAfterRemoval(project.ui, action.assetId, removedClipIds),
});
}
case 'asset/set-source-status':
assertExists(project.assets, action.assetId, 'asset');
return touch(project, action.updatedAt, {
assets: project.assets.map((asset) =>
asset.id === action.assetId
? { ...asset, sourceStatus: action.sourceStatus }
: asset
),
});
case 'asset/update':
assertExists(project.assets, action.assetId, 'asset');
return touch(project, action.updatedAt, {
assets: project.assets.map((asset) =>
asset.id === action.assetId ? { ...asset, ...action.changes } : asset
),
});
case 'clip/add': {
assertUniqueId(project.timeline, action.clip.id, 'clip');
assertAssetReference(project, action.clip.assetId);
assertClipRange(action.clip);
const index = normalizeInsertionIndex(
action.index,
project.timeline.length
);
const timeline = [...project.timeline];
timeline.splice(index, 0, action.clip);
return touch(project, action.updatedAt, { timeline });
}
case 'clip/update': {
assertExists(project.timeline, action.clipId, 'clip');
const timeline = project.timeline.map((clip) => {
if (clip.id !== action.clipId) {
return clip;
}
const updated = { ...clip, ...action.changes, id: clip.id };
assertAssetReference(project, updated.assetId);
assertClipRange(updated);
return updated;
});
return touch(project, action.updatedAt, { timeline });
}
case 'clip/remove':
assertExists(project.timeline, action.clipId, 'clip');
return touch(project, action.updatedAt, {
timeline: project.timeline.filter((clip) => clip.id !== action.clipId),
ui:
project.ui?.selectedClipId === action.clipId
? { ...project.ui, selectedClipId: undefined }
: project.ui,
});
case 'clip/move': {
const fromIndex = project.timeline.findIndex(
(clip) => clip.id === action.clipId
);
if (fromIndex < 0) {
throw new ProjectReducerError(
`Cannot move unknown clip "${action.clipId}".`
);
}
if (
!Number.isSafeInteger(action.toIndex) ||
action.toIndex < 0 ||
action.toIndex >= project.timeline.length
) {
throw new ProjectReducerError(
'Clip destination must be a valid timeline index.'
);
}
if (fromIndex === action.toIndex) {
return project;
}
const timeline = [...project.timeline];
const [clip] = timeline.splice(fromIndex, 1);
if (clip === undefined) {
return project;
}
timeline.splice(action.toIndex, 0, clip);
return touch(project, action.updatedAt, { timeline });
}
case 'output/set':
return touch(project, action.updatedAt, { output: action.output });
case 'metadata/set':
return touch(project, action.updatedAt, {
metadata: action.metadata,
});
case 'chapters/set':
return touch(project, action.updatedAt, {
chapters: action.chapters,
});
case 'subtitles/set':
return touch(project, action.updatedAt, {
subtitles: action.subtitles,
});
case 'ui/set':
return { ...project, ui: action.ui };
}
}
export class ProjectReducerError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProjectReducerError';
}
}
function touch(
project: AvProjectV1,
updatedAt: string,
changes: Partial<AvProjectV1>
): AvProjectV1 {
const timestamp = Date.parse(updatedAt);
if (!/^\d{4}-\d{2}-\d{2}T/.test(updatedAt) || !Number.isFinite(timestamp)) {
throw new ProjectReducerError('updatedAt must be an ISO-8601 date.');
}
if (timestamp < Date.parse(project.createdAt)) {
throw new ProjectReducerError(
'updatedAt cannot be before the project creation time.'
);
}
if (timestamp < Date.parse(project.updatedAt)) {
throw new ProjectReducerError(
'updatedAt cannot be before the previous project update time.'
);
}
return { ...project, ...changes, updatedAt };
}
function assertClipRange(clip: TimelineClip): void {
const range = validateSourceRange(
clip.sourceInSeconds,
clip.sourceOutSeconds
);
if (!range.valid) {
throw new ProjectReducerError(range.issues[0]?.message ?? 'Invalid clip.');
}
}
function assertAssetReference(project: AvProjectV1, assetId: string): void {
if (!project.assets.some((asset) => asset.id === assetId)) {
throw new ProjectReducerError(
`Clip references unknown asset "${assetId}".`
);
}
}
function assertExists(
entries: readonly { id: string }[],
id: string,
kind: string
): void {
if (!entries.some((entry) => entry.id === id)) {
throw new ProjectReducerError(`Unknown ${kind} "${id}".`);
}
}
function assertUniqueId(
entries: readonly { id: string }[],
id: string,
kind: string
): void {
if (entries.some((entry) => entry.id === id)) {
throw new ProjectReducerError(`Duplicate ${kind} ID "${id}".`);
}
}
function normalizeInsertionIndex(
index: number | undefined,
length: number
): number {
if (index === undefined) {
return length;
}
if (!Number.isSafeInteger(index) || index < 0 || index > length) {
throw new ProjectReducerError(
'Clip insertion index must be within the timeline.'
);
}
return index;
}
function cleanUiAfterRemoval(
ui: ProjectUiState | undefined,
assetId: string,
removedClipIds: ReadonlySet<string>
): ProjectUiState | undefined {
if (ui === undefined) {
return undefined;
}
return {
...ui,
...(ui.selectedAssetId === assetId ? { selectedAssetId: undefined } : {}),
...(ui.selectedClipId !== undefined && removedClipIds.has(ui.selectedClipId)
? { selectedClipId: undefined }
: {}),
};
}
+101
View File
@@ -0,0 +1,101 @@
import {
AV_PROJECT_SCHEMA_VERSION,
type AvProjectV1,
type BrowserFileIdentity,
type CreateMediaAssetOptions,
type CreateProjectOptions,
type MediaAssetReference,
} from './project.types';
export function createEmptyProject(
options: CreateProjectOptions = {}
): AvProjectV1 {
const timestamp = options.timestamp ?? new Date().toISOString();
if (!isIsoDate(timestamp)) {
throw new TypeError('Project timestamp must be a valid ISO-8601 date.');
}
return {
schemaVersion: AV_PROJECT_SCHEMA_VERSION,
id: options.id ?? createProjectId(),
name: options.name?.trim() || 'Untitled project',
createdAt: timestamp,
updatedAt: timestamp,
assets: [],
timeline: [],
output: {
presetId: options.output?.presetId ?? 'mp4-h264-balanced',
fileName: options.output?.fileName ?? 'output.mp4',
container: options.output?.container ?? 'mp4',
videoEnabled: options.output?.videoEnabled ?? true,
audioEnabled: options.output?.audioEnabled ?? true,
...(options.output?.width === undefined
? {}
: { width: options.output.width }),
...(options.output?.height === undefined
? {}
: { height: options.output.height }),
...(options.output?.frameRate === undefined
? {}
: { frameRate: options.output.frameRate }),
sampleRate: options.output?.sampleRate ?? 48_000,
channelLayout: options.output?.channelLayout ?? 'stereo',
missingAudioPolicy:
options.output?.missingAudioPolicy ?? 'insert-silence',
},
metadata: {
custom: {},
},
chapters: [],
subtitles: [],
};
}
export function createMediaAssetReference(
file: BrowserFileIdentity,
options: CreateMediaAssetOptions = {}
): MediaAssetReference {
if (!file.name.trim()) {
throw new TypeError('A source file must have a name.');
}
if (!Number.isSafeInteger(file.size) || file.size < 0) {
throw new TypeError('A source file size must be a non-negative integer.');
}
if (!Number.isSafeInteger(file.lastModified) || file.lastModified < 0) {
throw new TypeError(
'A source last-modified time must be a non-negative integer.'
);
}
return {
id: options.id ?? createId('asset'),
originalName: file.name,
size: file.size,
lastModified: file.lastModified,
mimeType: file.type,
sourceStatus: options.sourceStatus ?? 'attached',
...(options.probe === undefined ? {} : { probe: options.probe }),
...(options.hash === undefined ? {} : { hash: options.hash }),
};
}
function createProjectId(): string {
return createId('project');
}
function createId(prefix: string): string {
if (
typeof globalThis.crypto !== 'undefined' &&
typeof globalThis.crypto.randomUUID === 'function'
) {
return globalThis.crypto.randomUUID();
}
return `${prefix}-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 10)}`;
}
function isIsoDate(value: string): boolean {
return (
/^\d{4}-\d{2}-\d{2}T/.test(value) && Number.isFinite(Date.parse(value))
);
}
+107
View File
@@ -0,0 +1,107 @@
import { addDurations, subtractDurations } from '../media/duration';
import type {
AvProjectV1,
MediaAssetReference,
TimelineClip,
} from './project.types';
export interface TimelineEntry {
clip: TimelineClip;
asset?: MediaAssetReference;
timelineStartSeconds: number;
timelineEndSeconds: number;
durationSeconds: number;
}
export function selectAssetById(
project: AvProjectV1,
assetId: string
): MediaAssetReference | undefined {
return project.assets.find((asset) => asset.id === assetId);
}
export function selectClipById(
project: AvProjectV1,
clipId: string
): TimelineClip | undefined {
return project.timeline.find((clip) => clip.id === clipId);
}
export function selectClipDuration(clip: TimelineClip): number {
return Math.max(
0,
subtractDurations(clip.sourceOutSeconds, clip.sourceInSeconds)
);
}
export function selectTimelineDuration(project: AvProjectV1): number {
return addDurations(project.timeline.map(selectClipDuration));
}
export function selectTimelineEntries(project: AvProjectV1): TimelineEntry[] {
let cursor = 0;
return project.timeline.map((clip) => {
const durationSeconds = selectClipDuration(clip);
const timelineStartSeconds = cursor;
cursor = addDurations([cursor, durationSeconds]);
return {
clip,
asset: selectAssetById(project, clip.assetId),
timelineStartSeconds,
timelineEndSeconds: cursor,
durationSeconds,
};
});
}
export function selectMissingAssets(
project: AvProjectV1
): MediaAssetReference[] {
return project.assets.filter((asset) => asset.sourceStatus === 'missing');
}
export function selectAttachedAssets(
project: AvProjectV1
): MediaAssetReference[] {
return project.assets.filter((asset) => asset.sourceStatus === 'attached');
}
export function selectReferencedAssetIds(project: AvProjectV1): Set<string> {
return new Set([
...project.timeline.map((clip) => clip.assetId),
...project.subtitles.flatMap((track) =>
track.assetId === undefined ? [] : [track.assetId]
),
]);
}
export function selectUnusedAssets(
project: AvProjectV1
): MediaAssetReference[] {
const referenced = selectReferencedAssetIds(project);
return project.assets.filter((asset) => !referenced.has(asset.id));
}
export function selectCanExport(project: AvProjectV1): {
canExport: boolean;
reasons: string[];
} {
const reasons: string[] = [];
if (project.timeline.length === 0) {
reasons.push('The timeline is empty.');
}
const missingReferenced = selectMissingAssets(project).filter((asset) =>
selectReferencedAssetIds(project).has(asset.id)
);
if (missingReferenced.length > 0) {
reasons.push(
`${missingReferenced.length} referenced source ${
missingReferenced.length === 1 ? 'file is' : 'files are'
} missing.`
);
}
if (!project.output.videoEnabled && !project.output.audioEnabled) {
reasons.push('No output stream type is enabled.');
}
return { canExport: reasons.length === 0, reasons };
}
+146
View File
@@ -0,0 +1,146 @@
import { safeOutputFileName } from '../media/safe-file-name';
import {
migrateProjectDocument,
type ProjectMigrationWarning,
} from './migrations';
import type { AvProjectV1 } from './project.types';
import {
ProjectValidationError,
assertValidProject,
} from './project.validation';
export interface SerializeProjectOptions {
pretty?: boolean;
includeUiState?: boolean;
}
export interface ImportProjectOptions {
markSourcesMissing?: boolean;
maximumBytes?: number;
}
export interface ProjectImportWarning {
code: 'source-reattachment-required' | ProjectMigrationWarning['code'];
path: string;
message: string;
}
export interface ProjectImportResult {
project: AvProjectV1;
fromVersion: 0 | 1;
warnings: ProjectImportWarning[];
}
export class ProjectImportError extends Error {
readonly causeValue?: unknown;
constructor(message: string, causeValue?: unknown) {
super(message);
this.name = 'ProjectImportError';
this.causeValue = causeValue;
}
}
export function serializeProject(
project: AvProjectV1,
options: SerializeProjectOptions = {}
): string {
const validProject = assertValidProject(project);
const document =
options.includeUiState === false ? omitUi(validProject) : validProject;
const indentation = options.pretty === false ? undefined : 2;
return `${JSON.stringify(document, null, indentation)}\n`;
}
export function importProjectDocument(
input: string | unknown,
options: ImportProjectOptions = {}
): ProjectImportResult {
const maximumBytes = options.maximumBytes ?? 10 * 1024 * 1024;
if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) {
throw new RangeError('Maximum project size must be a positive integer.');
}
let parsed: unknown = input;
if (typeof input === 'string') {
if (new TextEncoder().encode(input).byteLength > maximumBytes) {
throw new ProjectImportError(
`The project document exceeds the ${maximumBytes}-byte import limit.`
);
}
try {
parsed = JSON.parse(input) as unknown;
} catch (error) {
throw new ProjectImportError(
'The project document is not valid JSON.',
error
);
}
}
const migration = migrateProjectDocument(parsed);
let project: AvProjectV1;
try {
project = assertValidProject(migration.project);
} catch (error) {
if (error instanceof ProjectValidationError) {
throw error;
}
throw new ProjectImportError(
'The project document could not be validated.',
error
);
}
const warnings: ProjectImportWarning[] = [...migration.warnings];
if (options.markSourcesMissing !== false) {
project = {
...project,
assets: project.assets.map((asset) => ({
...asset,
sourceStatus: 'missing',
})),
subtitles: project.subtitles.map((track) =>
track.sourceStatus === undefined
? track
: { ...track, sourceStatus: 'missing' }
),
};
if (
project.assets.length +
project.subtitles.filter((track) => track.sourceStatus !== undefined)
.length >
0
) {
warnings.push({
code: 'source-reattachment-required',
path: 'assets',
message:
'Browser File objects are not stored in a project document. Reattach source files before export.',
});
}
}
return {
project,
fromVersion: migration.fromVersion,
warnings,
};
}
export function deserializeProject(
json: string,
options?: ImportProjectOptions
): AvProjectV1 {
return importProjectDocument(json, options).project;
}
export function projectDocumentFileName(project: AvProjectV1): string {
return safeOutputFileName(project.name, 'avproject.json', 'av-tools-project');
}
function omitUi(project: AvProjectV1): AvProjectV1 {
const document = { ...project };
delete document.ui;
return document;
}

Some files were not shown because too many files have changed in this diff Show More