1052 lines
32 KiB
TypeScript
1052 lines
32 KiB
TypeScript
import { useId, useState } from 'react';
|
||
import './StructuralOperations.css';
|
||
|
||
export type StructuralCapabilityKey =
|
||
| 'trim-fast'
|
||
| 'trim-accurate'
|
||
| 'concat-fast'
|
||
| 'concat-normalized'
|
||
| 'concat-crossfade';
|
||
|
||
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;
|
||
readonly durationSeconds?: number;
|
||
}
|
||
|
||
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;
|
||
readonly crossfadeSeconds: number;
|
||
};
|
||
|
||
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 [crossfadeSeconds, setCrossfadeSeconds] = useState('0');
|
||
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,
|
||
crossfadeSeconds,
|
||
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}
|
||
/>
|
||
<label
|
||
className="structural-field"
|
||
htmlFor={`${componentId}-crossfade`}
|
||
>
|
||
<span>Crossfade duration</span>
|
||
<input
|
||
id={`${componentId}-crossfade`}
|
||
type="number"
|
||
aria-label="Crossfade duration"
|
||
min="0"
|
||
max="10"
|
||
step="0.04"
|
||
inputMode="decimal"
|
||
value={crossfadeSeconds}
|
||
disabled={busy || pending}
|
||
onChange={(event) =>
|
||
setCrossfadeSeconds(event.currentTarget.value)
|
||
}
|
||
/>
|
||
<small>
|
||
0 keeps hard cuts. A value from 0.04–10 seconds overlaps video
|
||
and, when retained, audio at every edit.
|
||
</small>
|
||
</label>
|
||
</>
|
||
)}
|
||
|
||
<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,
|
||
crossfadeSeconds: Number(crossfadeSeconds),
|
||
},
|
||
'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,
|
||
crossfadeSeconds,
|
||
availability,
|
||
connected,
|
||
busy,
|
||
}: {
|
||
readonly mode: ConcatMode;
|
||
readonly timeline: StructuralTimeline | undefined;
|
||
readonly preset: StructuralExportPresetOption | undefined;
|
||
readonly missingAudioPolicy: MissingAudioPolicy | '';
|
||
readonly subtitlePolicy: NormalizedSubtitlePolicy | '';
|
||
readonly crossfadeSeconds: string;
|
||
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.';
|
||
}
|
||
const crossfade = Number(crossfadeSeconds);
|
||
if (
|
||
!Number.isFinite(crossfade) ||
|
||
crossfade < 0 ||
|
||
(crossfade > 0 && (crossfade < 0.04 || crossfade > 10))
|
||
) {
|
||
return 'Crossfade duration must be 0, or a value from 0.04–10 seconds.';
|
||
}
|
||
if (crossfade > 0) {
|
||
for (const [index, clip] of timeline.clips.entries()) {
|
||
if (clip.durationSeconds === undefined) {
|
||
continue;
|
||
}
|
||
const required =
|
||
index === 0 || index === timeline.clips.length - 1
|
||
? crossfade
|
||
: crossfade * 2;
|
||
if (clip.durationSeconds < required) {
|
||
return `Clip ${index + 1} is too short for ${crossfade} second crossfades at its edit boundaries.`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!connected) {
|
||
return 'This action has not been connected to the job runner.';
|
||
}
|
||
const baseReason = capabilityReason(
|
||
availability?.[mode === 'fast' ? 'concat-fast' : 'concat-normalized']
|
||
);
|
||
if (baseReason || mode === 'fast' || Number(crossfadeSeconds) === 0) {
|
||
return baseReason;
|
||
}
|
||
return capabilityReason(availability?.['concat-crossfade']);
|
||
}
|
||
|
||
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')}`;
|
||
}
|