Files
av-tools/src/components/StructuralOperations.tsx

1052 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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&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}
/>
<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.0410 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.0410 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')}`;
}