feat: publish av-tools 0.1.0

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

View File

@@ -0,0 +1,990 @@
import { useId, useState } from 'react';
import './StructuralOperations.css';
export type StructuralCapabilityKey =
'trim-fast' | 'trim-accurate' | 'concat-fast' | 'concat-normalized';
export interface StructuralCapabilityStatus {
readonly available: boolean;
readonly reason?: string;
}
export interface StructuralExportPresetOption {
readonly id: string;
readonly name: string;
readonly fileExtension: string;
readonly supportsAudio: boolean;
readonly supportsVideo: boolean;
readonly disabledReason?: string;
}
export interface StructuralSelectedClip {
readonly id: string;
readonly label: string;
readonly sourceInSeconds: number;
readonly sourceOutSeconds: number;
readonly sourceDurationSeconds?: number;
readonly sourceExtension: string;
readonly hasAudio: boolean;
readonly hasVideo: boolean;
}
export interface StructuralTimelineClip {
readonly id: string;
readonly label: string;
readonly hasAudio: boolean;
readonly hasVideo: boolean;
readonly hasSubtitles: boolean;
}
export interface StructuralCompatibilityDiagnostic {
readonly code: string;
readonly severity: 'info' | 'warning' | 'error';
readonly message: string;
readonly path?: string;
}
export interface StructuralTimeline {
/** Clips in their exact export order. */
readonly clips: readonly StructuralTimelineClip[];
readonly fastTargetExtension: string;
readonly fastTargetMuxer: string;
/**
* `undefined` means the command layer has not checked the inputs. An empty
* array means it checked them and found no compatibility diagnostics.
*/
readonly fastCompatibilityDiagnostics?: readonly StructuralCompatibilityDiagnostic[];
}
export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject';
export type NormalizedSubtitlePolicy = 'drop-all' | 'reject';
export type StructuralTrimRequest =
| {
readonly operation: 'trim';
readonly mode: 'fast';
readonly clipId: string;
readonly startSeconds: number;
readonly endSeconds: number;
readonly targetExtension: string;
}
| {
readonly operation: 'trim';
readonly mode: 'accurate';
readonly clipId: string;
readonly startSeconds: number;
readonly endSeconds: number;
readonly presetId: string;
};
export type StructuralConcatRequest =
| {
readonly operation: 'concat';
readonly mode: 'fast';
readonly clipIds: readonly string[];
readonly targetExtension: string;
readonly targetMuxer: string;
}
| {
readonly operation: 'concat';
readonly mode: 'normalized';
readonly clipIds: readonly string[];
readonly presetId: string;
readonly missingAudioPolicy: MissingAudioPolicy;
readonly subtitlePolicy: NormalizedSubtitlePolicy;
};
type RequestHandler<T> = (request: T) => void | Promise<void>;
export interface StructuralOperationsProps {
readonly selectedClip?: StructuralSelectedClip;
readonly timeline?: StructuralTimeline;
readonly exportPresets?: readonly StructuralExportPresetOption[];
/**
* Missing entries fail closed. The integration layer must opt in only after
* checking the loaded FFmpeg core and the current browser environment.
*/
readonly availability?: Partial<
Readonly<Record<StructuralCapabilityKey, StructuralCapabilityStatus>>
>;
readonly busy?: boolean;
readonly onTrim?: RequestHandler<StructuralTrimRequest>;
readonly onConcat?: RequestHandler<StructuralConcatRequest>;
}
type TrimMode = StructuralTrimRequest['mode'];
type ConcatMode = StructuralConcatRequest['mode'];
export function StructuralOperations({
selectedClip,
timeline,
exportPresets = [],
availability = {},
busy = false,
onTrim,
onConcat,
}: StructuralOperationsProps) {
const componentId = useId();
const [trimMode, setTrimMode] = useState<TrimMode>('fast');
const [concatMode, setConcatMode] = useState<ConcatMode>('fast');
const [trimPresetId, setTrimPresetId] = useState('');
const [concatPresetId, setConcatPresetId] = useState('');
const [missingAudioPolicy, setMissingAudioPolicy] = useState<
MissingAudioPolicy | ''
>('');
const [subtitlePolicy, setSubtitlePolicy] = useState<
NormalizedSubtitlePolicy | ''
>('');
const [pending, setPending] = useState(false);
const [status, setStatus] = useState<string>();
const trimPreset = exportPresets.find((preset) => preset.id === trimPresetId);
const concatPreset = exportPresets.find(
(preset) => preset.id === concatPresetId
);
const trimReason = reasonForTrim({
mode: trimMode,
selectedClip,
preset: trimPreset,
availability,
connected: Boolean(onTrim),
busy: busy || pending,
});
const concatReason = reasonForConcat({
mode: concatMode,
timeline,
preset: concatPreset,
missingAudioPolicy,
subtitlePolicy,
availability,
connected: Boolean(onConcat),
busy: busy || pending,
});
const run = <T,>(
handler: RequestHandler<T> | undefined,
request: T,
successMessage: string
) => {
if (!handler) {
return;
}
setPending(true);
setStatus(undefined);
void Promise.resolve()
.then(() => handler(request))
.then(() => setStatus(successMessage))
.catch((error: unknown) => {
setStatus(
error instanceof Error
? `The operation could not be queued: ${error.message}`
: 'The operation could not be queued.'
);
})
.finally(() => setPending(false));
};
return (
<section
className="structural-operations"
aria-labelledby={`${componentId}-title`}
>
<header className="structural-operations__header">
<div>
<p className="structural-operations__eyebrow">Structural exports</p>
<h2 id={`${componentId}-title`}>Trim or join without guesswork</h2>
<p>
Choose stream-copy speed or a preset-backed re-encode. Every action
runs locally in this browser.
</p>
</div>
<span className="structural-operations__local">Local only</span>
</header>
<div className="structural-operations__grid">
<section className="structural-operation-card">
<header>
<span className="structural-operation-card__step">1</span>
<div>
<h3>Trim the selected clip</h3>
<p>
The current timeline in/out points are used as-is; this panel
does not change the edit.
</p>
</div>
</header>
{selectedClip ? (
<dl className="structural-range-summary">
<div>
<dt>Clip</dt>
<dd>{selectedClip.label}</dd>
</div>
<div>
<dt>Current range</dt>
<dd>
{formatTime(selectedClip.sourceInSeconds)}
{formatTime(selectedClip.sourceOutSeconds)}
</dd>
</div>
<div>
<dt>Export duration</dt>
<dd>
{formatDuration(
selectedClip.sourceOutSeconds - selectedClip.sourceInSeconds
)}
</dd>
</div>
</dl>
) : (
<p className="structural-empty">
Select one timeline clip to export its current range.
</p>
)}
<fieldset
className="structural-choice-cards"
disabled={busy || pending}
>
<legend>Trim method</legend>
<label>
<input
type="radio"
name={`${componentId}-trim-mode`}
value="fast"
checked={trimMode === 'fast'}
onChange={() => setTrimMode('fast')}
/>
<span>
<strong>Fast stream copy</strong>
<small>
No generation loss and usually much faster. The first frame
may move to a nearby keyframe, so the boundary is not
frame-accurate.
</small>
</span>
</label>
<label>
<input
type="radio"
name={`${componentId}-trim-mode`}
value="accurate"
checked={trimMode === 'accurate'}
onChange={() => setTrimMode('accurate')}
/>
<span>
<strong>Accurate re-encode</strong>
<small>
Decodes around the requested in/out points for practical frame
accuracy. It is slower and applies the selected preset&apos;s
quality settings.
</small>
</span>
</label>
</fieldset>
{trimMode === 'accurate' ? (
<PresetField
id={`${componentId}-trim-preset`}
label="Accurate trim preset"
value={trimPresetId}
presets={exportPresets}
suitable={(preset) =>
selectedClip?.hasVideo
? preset.supportsVideo
: Boolean(selectedClip?.hasAudio && preset.supportsAudio)
}
disabled={busy || pending}
onChange={setTrimPresetId}
/>
) : (
<p className="structural-hint">
The fast export keeps the source streams and writes{' '}
<strong>
.
{normalizeExtension(selectedClip?.sourceExtension) ||
'source format'}
</strong>
.
</p>
)}
<StructuralAction
label={
trimMode === 'fast' ? 'Export fast trim' : 'Export exact trim'
}
reason={trimReason}
onClick={() => {
if (!selectedClip) {
return;
}
if (trimMode === 'fast') {
run(
onTrim,
{
operation: 'trim',
mode: 'fast',
clipId: selectedClip.id,
startSeconds: selectedClip.sourceInSeconds,
endSeconds: selectedClip.sourceOutSeconds,
targetExtension: normalizeExtension(
selectedClip.sourceExtension
),
},
'Fast trim queued.'
);
return;
}
if (!trimPreset) {
return;
}
run(
onTrim,
{
operation: 'trim',
mode: 'accurate',
clipId: selectedClip.id,
startSeconds: selectedClip.sourceInSeconds,
endSeconds: selectedClip.sourceOutSeconds,
presetId: trimPreset.id,
},
'Accurate trim queued.'
);
}}
/>
</section>
<section className="structural-operation-card">
<header>
<span className="structural-operation-card__step">2</span>
<div>
<h3>Join the sequential timeline</h3>
<p>
Clips are submitted in their visible timeline order. Fast
concatenation is enabled only after an explicit stream check.
</p>
</div>
</header>
{timeline ? (
<TimelineSummary clips={timeline.clips} />
) : (
<p className="structural-empty">
Add at least two clips to the sequential timeline.
</p>
)}
<fieldset
className="structural-choice-cards"
disabled={busy || pending}
>
<legend>Join method</legend>
<label>
<input
type="radio"
name={`${componentId}-concat-mode`}
value="fast"
checked={concatMode === 'fast'}
onChange={() => setConcatMode('fast')}
/>
<span>
<strong>Fast compatible concat</strong>
<small>
Copies streams without quality loss. Every clip must have the
same stream order, codecs, dimensions, time bases, and audio
layout.
</small>
</span>
</label>
<label>
<input
type="radio"
name={`${componentId}-concat-mode`}
value="normalized"
checked={concatMode === 'normalized'}
onChange={() => setConcatMode('normalized')}
/>
<span>
<strong>Normalized re-encode</strong>
<small>
Scales and pads video, normalizes frame rate and audio layout,
then encodes the complete sequence with one typed preset.
</small>
</span>
</label>
</fieldset>
{concatMode === 'fast' ? (
<CompatibilityReport
diagnostics={timeline?.fastCompatibilityDiagnostics}
clipCount={timeline?.clips.length ?? 0}
/>
) : (
<>
<PresetField
id={`${componentId}-concat-preset`}
label="Normalized concat preset"
value={concatPresetId}
presets={exportPresets}
suitable={(preset) => preset.supportsVideo}
disabled={busy || pending}
onChange={setConcatPresetId}
/>
<MissingAudioPolicyField
componentId={componentId}
value={missingAudioPolicy}
disabled={busy || pending}
onChange={setMissingAudioPolicy}
/>
<SubtitlePolicyField
componentId={componentId}
value={subtitlePolicy}
disabled={busy || pending}
hasSubtitles={Boolean(
timeline?.clips.some((clip) => clip.hasSubtitles)
)}
onChange={setSubtitlePolicy}
/>
</>
)}
<StructuralAction
label={
concatMode === 'fast'
? 'Export fast concat'
: 'Export normalized concat'
}
reason={concatReason}
onClick={() => {
if (!timeline) {
return;
}
const clipIds = timeline.clips.map((clip) => clip.id);
if (concatMode === 'fast') {
run(
onConcat,
{
operation: 'concat',
mode: 'fast',
clipIds,
targetExtension: normalizeExtension(
timeline.fastTargetExtension
),
targetMuxer: timeline.fastTargetMuxer.trim(),
},
'Fast concat queued.'
);
return;
}
if (!concatPreset || !missingAudioPolicy || !subtitlePolicy) {
return;
}
run(
onConcat,
{
operation: 'concat',
mode: 'normalized',
clipIds,
presetId: concatPreset.id,
missingAudioPolicy,
subtitlePolicy,
},
'Normalized concat queued.'
);
}}
/>
</section>
</div>
<p
className="structural-operations__status"
role="status"
aria-live="polite"
>
{pending ? 'Queueing local operation…' : status}
</p>
</section>
);
}
function TimelineSummary({
clips,
}: {
readonly clips: readonly StructuralTimelineClip[];
}) {
const audioCount = clips.filter((clip) => clip.hasAudio).length;
const subtitleCount = clips.filter((clip) => clip.hasSubtitles).length;
const missingAudio = clips.filter((clip) => !clip.hasAudio);
return (
<div className="structural-timeline-summary">
<p>
<strong>{clips.length}</strong> clips in sequence ·{' '}
<strong>{audioCount}</strong> with audio ·{' '}
<strong>{subtitleCount}</strong> with subtitles
</p>
{missingAudio.length > 0 ? (
<p>No audio: {missingAudio.map((clip) => clip.label).join(', ')}</p>
) : (
<p>Every clip has an audio stream.</p>
)}
</div>
);
}
function CompatibilityReport({
diagnostics,
clipCount,
}: {
readonly diagnostics:
readonly StructuralCompatibilityDiagnostic[] | undefined;
readonly clipCount: number;
}) {
if (diagnostics === undefined) {
return (
<div className="structural-compatibility structural-compatibility--error">
<h4>Fast compatibility not checked</h4>
<p>
Inspect every input stream before enabling stream-copy concatenation.
</p>
</div>
);
}
if (diagnostics.length === 0) {
return (
<div className="structural-compatibility structural-compatibility--ok">
<h4>Fast compatibility passed</h4>
<p>
All {clipCount} clips have matching stream properties for stream copy.
</p>
</div>
);
}
const hasErrors = diagnostics.some(
(diagnostic) => diagnostic.severity === 'error'
);
return (
<div
className={`structural-compatibility ${
hasErrors
? 'structural-compatibility--error'
: 'structural-compatibility--warning'
}`}
>
<h4>
{hasErrors ? 'Fast compatibility failed' : 'Fast compatibility notes'}
</h4>
<ul>
{diagnostics.map((diagnostic, index) => (
<li key={`${diagnostic.code}-${diagnostic.path ?? index}`}>
<span>{diagnostic.severity}</span>
<span>
{diagnostic.message}
{diagnostic.path ? (
<small>Checked field: {diagnostic.path}</small>
) : null}
</span>
</li>
))}
</ul>
</div>
);
}
function PresetField({
id,
label,
value,
presets,
suitable,
disabled,
onChange,
}: {
readonly id: string;
readonly label: string;
readonly value: string;
readonly presets: readonly StructuralExportPresetOption[];
readonly suitable: (preset: StructuralExportPresetOption) => boolean;
readonly disabled: boolean;
readonly onChange: (value: string) => void;
}) {
const selectableCount = presets.filter(
(preset) => suitable(preset) && !preset.disabledReason
).length;
return (
<label className="structural-field" htmlFor={id}>
<span>{label}</span>
<select
id={id}
aria-label={label}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value)}
>
<option value="">Choose an available preset</option>
{presets.map((preset) => {
const unsuitable = !suitable(preset);
const reason =
preset.disabledReason ??
(unsuitable ? 'wrong media type for this operation' : undefined);
return (
<option
key={preset.id}
value={preset.id}
disabled={Boolean(reason)}
>
{preset.name} · .{normalizeExtension(preset.fileExtension)}
{reason ? ` — unavailable: ${reason}` : ''}
</option>
);
})}
</select>
<small>
{selectableCount > 0
? `${selectableCount} verified preset${
selectableCount === 1 ? '' : 's'
} available.`
: 'No suitable preset has passed the current FFmpeg capability check.'}
</small>
</label>
);
}
function MissingAudioPolicyField({
componentId,
value,
disabled,
onChange,
}: {
readonly componentId: string;
readonly value: MissingAudioPolicy | '';
readonly disabled: boolean;
readonly onChange: (value: MissingAudioPolicy) => void;
}) {
const options: readonly {
readonly value: MissingAudioPolicy;
readonly title: string;
readonly detail: string;
}[] = [
{
value: 'insert-silence',
title: 'Insert silence',
detail: 'Keep an audio track and fill each silent clip to its duration.',
},
{
value: 'drop-all',
title: 'Drop all audio',
detail: 'Export one video-only sequence, even from clips with audio.',
},
{
value: 'reject',
title: 'Reject mixed audio',
detail: 'Stop if some clips have audio and others do not.',
},
];
return (
<fieldset
className="structural-policy"
disabled={disabled}
aria-describedby={`${componentId}-audio-policy-hint`}
>
<legend>Missing-audio policy</legend>
<p id={`${componentId}-audio-policy-hint`}>
Required: choose how a normalized sequence handles clips without audio.
</p>
<div>
{options.map((option) => (
<label key={option.value}>
<input
type="radio"
name={`${componentId}-audio-policy`}
value={option.value}
checked={value === option.value}
onChange={() => onChange(option.value)}
/>
<span>
<strong>{option.title}</strong>
<small>{option.detail}</small>
</span>
</label>
))}
</div>
</fieldset>
);
}
function SubtitlePolicyField({
componentId,
value,
disabled,
hasSubtitles,
onChange,
}: {
readonly componentId: string;
readonly value: NormalizedSubtitlePolicy | '';
readonly disabled: boolean;
readonly hasSubtitles: boolean;
readonly onChange: (value: NormalizedSubtitlePolicy) => void;
}) {
return (
<fieldset
className="structural-policy"
disabled={disabled}
aria-describedby={`${componentId}-subtitle-policy-hint`}
>
<legend>Subtitle policy</legend>
<p id={`${componentId}-subtitle-policy-hint`}>
Required: normalized concat has no reviewed, container-safe subtitle
retiming path. The selected preset and loaded FFmpeg capabilities still
gate the final export.
</p>
<div>
<label>
<input
type="radio"
name={`${componentId}-subtitle-policy`}
value="reject"
checked={value === 'reject'}
onChange={() => onChange('reject')}
/>
<span>
<strong>Reject subtitle input</strong>
<small>
{hasSubtitles
? 'Keep the operation blocked because at least one clip contains subtitles.'
: 'Proceed only while every clip remains subtitle-free.'}
</small>
</span>
</label>
<label>
<input
type="radio"
name={`${componentId}-subtitle-policy`}
value="drop-all"
checked={value === 'drop-all'}
onChange={() => onChange('drop-all')}
/>
<span>
<strong>Drop all subtitles</strong>
<small>
Explicitly omit subtitle streams from every normalized input. This
cannot be undone in the output.
</small>
</span>
</label>
</div>
</fieldset>
);
}
function StructuralAction({
label,
reason,
onClick,
}: {
readonly label: string;
readonly reason?: string;
readonly onClick: () => void;
}) {
const reasonId = useId();
return (
<div className="structural-action">
<button
type="button"
disabled={Boolean(reason)}
aria-describedby={reason ? reasonId : undefined}
onClick={onClick}
>
{label}
</button>
{reason ? <small id={reasonId}>{reason}</small> : null}
</div>
);
}
function reasonForTrim({
mode,
selectedClip,
preset,
availability,
connected,
busy,
}: {
readonly mode: TrimMode;
readonly selectedClip: StructuralSelectedClip | undefined;
readonly preset: StructuralExportPresetOption | undefined;
readonly availability: StructuralOperationsProps['availability'];
readonly connected: boolean;
readonly busy: boolean;
}): string | undefined {
if (busy) {
return 'Another local operation is currently running.';
}
if (!selectedClip) {
return 'Select a timeline clip first.';
}
if (!validRange(selectedClip)) {
return 'The selected clip must have a finite in/out range inside its source.';
}
if (mode === 'fast' && !normalizeExtension(selectedClip.sourceExtension)) {
return 'The selected source has no safe output extension.';
}
if (mode === 'accurate') {
if (!preset) {
return 'Choose an available typed preset for the accurate trim.';
}
if (preset.disabledReason) {
return preset.disabledReason;
}
if (
(selectedClip.hasVideo && !preset.supportsVideo) ||
(!selectedClip.hasVideo && selectedClip.hasAudio && !preset.supportsAudio)
) {
return 'The selected preset does not encode this clip type.';
}
}
if (!connected) {
return 'This action has not been connected to the job runner.';
}
return capabilityReason(
availability?.[mode === 'fast' ? 'trim-fast' : 'trim-accurate']
);
}
function reasonForConcat({
mode,
timeline,
preset,
missingAudioPolicy,
subtitlePolicy,
availability,
connected,
busy,
}: {
readonly mode: ConcatMode;
readonly timeline: StructuralTimeline | undefined;
readonly preset: StructuralExportPresetOption | undefined;
readonly missingAudioPolicy: MissingAudioPolicy | '';
readonly subtitlePolicy: NormalizedSubtitlePolicy | '';
readonly availability: StructuralOperationsProps['availability'];
readonly connected: boolean;
readonly busy: boolean;
}): string | undefined {
if (busy) {
return 'Another local operation is currently running.';
}
if (!timeline || timeline.clips.length < 2) {
return 'Add at least two timeline clips in the order to be joined.';
}
const clipIds = timeline.clips.map((clip) => clip.id);
if (
clipIds.some((id) => id.trim() === '') ||
new Set(clipIds).size !== clipIds.length
) {
return 'Every timeline clip must have a unique nonempty ID.';
}
if (mode === 'fast') {
if (!normalizeExtension(timeline.fastTargetExtension)) {
return 'The sequence has no safe fast-concat output extension.';
}
if (timeline.fastCompatibilityDiagnostics === undefined) {
return 'Fast concat remains disabled until every input stream is checked.';
}
if (
timeline.fastCompatibilityDiagnostics.some(
(diagnostic) => diagnostic.severity === 'error'
)
) {
return 'Resolve the reported stream incompatibilities or use normalized concat.';
}
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/u.test(timeline.fastTargetMuxer.trim())) {
return 'The fast-concat output muxer is unknown.';
}
} else {
if (timeline.clips.some((clip) => !clip.hasVideo)) {
return 'Normalized concat currently requires video in every clip.';
}
if (!preset) {
return 'Choose an available typed video preset for normalized concat.';
}
if (preset.disabledReason) {
return preset.disabledReason;
}
if (!preset.supportsVideo) {
return 'Normalized concat requires a video preset.';
}
if (!missingAudioPolicy) {
return 'Choose an explicit missing-audio policy.';
}
if (!subtitlePolicy) {
return 'Choose an explicit subtitle policy.';
}
if (
subtitlePolicy === 'reject' &&
timeline.clips.some((clip) => clip.hasSubtitles)
) {
return 'This sequence contains subtitles. Explicitly drop all subtitles, or remove them before normalized concat.';
}
}
if (!connected) {
return 'This action has not been connected to the job runner.';
}
return capabilityReason(
availability?.[mode === 'fast' ? 'concat-fast' : 'concat-normalized']
);
}
function capabilityReason(
status: StructuralCapabilityStatus | undefined
): string | undefined {
if (!status?.available) {
return (
status?.reason ??
'The required browser and FFmpeg capabilities have not been verified.'
);
}
return undefined;
}
function validRange(clip: StructuralSelectedClip): boolean {
if (
!Number.isFinite(clip.sourceInSeconds) ||
!Number.isFinite(clip.sourceOutSeconds) ||
clip.sourceInSeconds < 0 ||
clip.sourceOutSeconds <= clip.sourceInSeconds
) {
return false;
}
return (
clip.sourceDurationSeconds === undefined ||
(Number.isFinite(clip.sourceDurationSeconds) &&
clip.sourceDurationSeconds > 0 &&
clip.sourceOutSeconds <= clip.sourceDurationSeconds)
);
}
function normalizeExtension(extension: string | undefined): string {
const normalized = (extension ?? '')
.trim()
.replace(/^\.+/u, '')
.toLowerCase();
return /^[a-z0-9]{1,10}$/u.test(normalized) ? normalized : '';
}
function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return 'Invalid range';
}
return `${seconds.toFixed(3)} s`;
}
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return '—';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainder = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}:${remainder.toFixed(3).padStart(6, '0')}`;
}