feat: publish av-tools 0.2.0

This commit is contained in:
2026-07-27 01:04:21 +02:00
parent fcc537b024
commit 0a4548851c
68 changed files with 4130 additions and 605 deletions

View File

@@ -2,7 +2,11 @@ import { useId, useState } from 'react';
import './StructuralOperations.css';
export type StructuralCapabilityKey =
'trim-fast' | 'trim-accurate' | 'concat-fast' | 'concat-normalized';
| 'trim-fast'
| 'trim-accurate'
| 'concat-fast'
| 'concat-normalized'
| 'concat-crossfade';
export interface StructuralCapabilityStatus {
readonly available: boolean;
@@ -35,6 +39,7 @@ export interface StructuralTimelineClip {
readonly hasAudio: boolean;
readonly hasVideo: boolean;
readonly hasSubtitles: boolean;
readonly durationSeconds?: number;
}
export interface StructuralCompatibilityDiagnostic {
@@ -92,6 +97,7 @@ export type StructuralConcatRequest =
readonly presetId: string;
readonly missingAudioPolicy: MissingAudioPolicy;
readonly subtitlePolicy: NormalizedSubtitlePolicy;
readonly crossfadeSeconds: number;
};
type RequestHandler<T> = (request: T) => void | Promise<void>;
@@ -135,6 +141,7 @@ export function StructuralOperations({
const [subtitlePolicy, setSubtitlePolicy] = useState<
NormalizedSubtitlePolicy | ''
>('');
const [crossfadeSeconds, setCrossfadeSeconds] = useState('0');
const [pending, setPending] = useState(false);
const [status, setStatus] = useState<string>();
@@ -156,6 +163,7 @@ export function StructuralOperations({
preset: concatPreset,
missingAudioPolicy,
subtitlePolicy,
crossfadeSeconds,
availability,
connected: Boolean(onConcat),
busy: busy || pending,
@@ -445,6 +453,30 @@ export function StructuralOperations({
)}
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>
</>
)}
@@ -488,6 +520,7 @@ export function StructuralOperations({
presetId: concatPreset.id,
missingAudioPolicy,
subtitlePolicy,
crossfadeSeconds: Number(crossfadeSeconds),
},
'Normalized concat queued.'
);
@@ -856,6 +889,7 @@ function reasonForConcat({
preset,
missingAudioPolicy,
subtitlePolicy,
crossfadeSeconds,
availability,
connected,
busy,
@@ -865,6 +899,7 @@ function reasonForConcat({
readonly preset: StructuralExportPresetOption | undefined;
readonly missingAudioPolicy: MissingAudioPolicy | '';
readonly subtitlePolicy: NormalizedSubtitlePolicy | '';
readonly crossfadeSeconds: string;
readonly availability: StructuralOperationsProps['availability'];
readonly connected: boolean;
readonly busy: boolean;
@@ -924,13 +959,39 @@ function reasonForConcat({
) {
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.';
}
return capabilityReason(
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(