feat: publish av-tools 0.1.0
This commit is contained in:
990
src/components/StructuralOperations.tsx
Normal file
990
src/components/StructuralOperations.tsx
Normal 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'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')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user