feat: publish av-tools 0.1.0
This commit is contained in:
+4584
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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] },
|
||||
};
|
||||
}
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(':');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.`
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.'
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 1–500 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 32–4096 pixels');
|
||||
}
|
||||
const spacing = options.spacing ?? 4;
|
||||
if (!Number.isSafeInteger(spacing) || spacing < 0 || spacing > 100) {
|
||||
throw new RangeError('Contact sheet spacing must be 0–100 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;
|
||||
}
|
||||
@@ -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 1–100 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`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.'
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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.'
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'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')}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './durable-store';
|
||||
export * from './job-queue';
|
||||
export * from './job.types';
|
||||
export * from './object-url-registry';
|
||||
export * from './progress';
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.';
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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/');
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
ChapterProbe,
|
||||
MediaProbe,
|
||||
MediaStreamProbe,
|
||||
MediaStreamType,
|
||||
ParsedProbeReport,
|
||||
ProbeWarning,
|
||||
ProbeWarningCode,
|
||||
} from './media.types';
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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] ?? '';
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 1–10 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 }) } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user