feat: make media inspection progressive

This commit is contained in:
2026-07-27 18:25:47 +02:00
parent 6c68f9bfbd
commit f2c0fc12d2
32 changed files with 2169 additions and 462 deletions

View File

@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import type { ImportedMediaAsset } from '../app/application-state';
import { assetDurationSeconds, assetHasVideo } from '../app/application-state';
import type { EngineState } from '../ffmpeg/ffmpeg.types';
import type { MediaStreamProbe } from '../media';
import type { StreamSelection } from '../commands/command-utils';
import {
validateRemuxCompatibility,
@@ -18,8 +18,12 @@ import {
import { formatBytes, formatDuration } from '../app/application-state';
import { FFMPEG_OPUS_REGRESSION_VERIFIED } from '../version';
import { Icon } from './Icon';
import {
quickStreamUnavailableReason,
type QuickOperation,
} from './quick-convert-selection';
export type QuickOperation = 'convert' | 'remux';
export type { QuickOperation } from './quick-convert-selection';
export interface QuickExportConfiguration {
operation: QuickOperation;
@@ -28,6 +32,11 @@ export interface QuickExportConfiguration {
streamSelection: StreamSelection;
removeMetadata: boolean;
removeChapters: boolean;
/**
* The source was not authoritatively inspected when the user submitted the
* form. Resolve a safe default selection from the on-demand probe.
*/
selectDetectedStreams?: true;
}
export interface QuickConvertProps {
@@ -36,6 +45,7 @@ export interface QuickConvertProps {
busy: boolean;
queueing?: boolean;
presets?: readonly ExportPreset[];
onInspect?: () => void;
onExport: (configuration: QuickExportConfiguration) => void;
}
@@ -45,6 +55,7 @@ export function QuickConvert({
busy,
queueing = false,
presets = BUILT_IN_PRESETS,
onInspect,
onExport,
}: QuickConvertProps) {
const [operation, setOperation] = useState<QuickOperation>('convert');
@@ -59,6 +70,7 @@ export function QuickConvert({
}>();
const preset = presets.find((entry) => entry.id === presetId);
const hasDetailedInformation = asset?.probe !== undefined;
const streams = useMemo(() => asset?.probe?.streams ?? [], [asset?.probe]);
const requestedStreams = useMemo(
() =>
@@ -143,7 +155,9 @@ export function QuickConvert({
const opusBlockedByPinnedCore =
preset?.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
const presetNeedsMissingAudio =
preset?.kind === 'audio' && effectiveSelection.audio.length === 0;
hasDetailedInformation &&
preset?.kind === 'audio' &&
effectiveSelection.audio.length === 0;
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
stream.type === 'video' ||
stream.type === 'audio' ||
@@ -159,16 +173,17 @@ export function QuickConvert({
]
: []
);
const remuxDiagnostics = remuxContainer
? validateRemuxCompatibility(
{
streams: remuxStreams,
hasChapters: Boolean(asset?.probe?.chapters.length),
},
remuxContainer,
effectiveSelection
)
: [];
const remuxDiagnostics =
remuxContainer && hasDetailedInformation
? validateRemuxCompatibility(
{
streams: remuxStreams,
hasChapters: Boolean(asset?.probe?.chapters.length),
},
remuxContainer,
effectiveSelection
)
: [];
const remuxErrors = remuxDiagnostics.filter(
(diagnostic) => diagnostic.severity === 'error'
);
@@ -179,9 +194,9 @@ export function QuickConvert({
const remuxUnavailableReason = remuxMuxerMissing
? `The loaded core is missing muxer ${remuxContainer}.`
: remuxErrors.map((diagnostic) => diagnostic.message).join(' ');
const hasSelectedStream = selectedStreams.size > 0;
const hasSelectedStream = !hasDetailedInformation || selectedStreams.size > 0;
const canExport =
asset?.phase === 'ready' &&
asset !== undefined &&
!busy &&
hasSelectedStream &&
(operation === 'remux'
@@ -228,27 +243,23 @@ export function QuickConvert({
{asset ? (
<div className="source-summary">
<span className="source-summary__icon">
<Icon
name={
asset.probe?.streams.some((stream) => stream.type === 'video')
? 'video'
: 'audio'
}
/>
<Icon name={assetHasVideo(asset) === true ? 'video' : 'audio'} />
</span>
<div>
<strong>{asset.file.name}</strong>
<span>
{formatBytes(asset.file.size)} ·{' '}
{formatDuration(asset.probe?.durationSeconds)}
{formatDuration(assetDurationSeconds(asset))}
</span>
</div>
<span className={`phase phase--${asset.phase}`}>
{asset.phase === 'ready'
? 'Ready'
: asset.phase === 'error'
? 'Could not inspect'
: 'Preparing'}
? 'Details ready'
: asset.phase === 'probing'
? 'Inspecting details'
: asset.phase === 'error'
? 'Ready to play'
: 'Ready to play'}
</span>
</div>
) : (
@@ -340,14 +351,16 @@ export function QuickConvert({
['ogg', 'Ogg (.ogg)'],
] as const
).map(([container, label]) => {
const diagnostics = validateRemuxCompatibility(
{
streams: remuxStreams,
hasChapters: Boolean(asset?.probe?.chapters.length),
},
container,
effectiveSelection
);
const diagnostics = hasDetailedInformation
? validateRemuxCompatibility(
{
streams: remuxStreams,
hasChapters: Boolean(asset?.probe?.chapters.length),
},
container,
effectiveSelection
)
: [];
const missingMuxer =
engineState.status === 'ready' &&
!engineState.capabilities.muxers.has(container);
@@ -410,7 +423,25 @@ export function QuickConvert({
);
})
) : (
<p>Streams appear after local inspection.</p>
<div className="stream-picker__empty">
<p>
Streams will be identified only when you inspect details or
start this export.
</p>
{asset && asset.phase !== 'probing' && onInspect ? (
<button
type="button"
className="button button--secondary"
disabled={busy}
onClick={onInspect}
>
<Icon name="info" />
{asset.phase === 'error'
? 'Retry inspection'
: 'Inspect details now'}
</button>
) : null}
</div>
)}
</fieldset>
@@ -451,6 +482,13 @@ export function QuickConvert({
Select at least one audio stream for an audio-only preset.
</p>
) : null}
{asset?.phase === 'error' && !asset.probe ? (
<p className="notice notice--warning">
<Icon name="warning" />
Playback and basic file information remain available. Detailed
inspection can be retried when an export needs it.
</p>
) : null}
{!hasSelectedStream && streams.length > 0 ? (
<p className="notice notice--error">
<Icon name="warning" />
@@ -474,7 +512,11 @@ export function QuickConvert({
? 'Validated user preset'
: 'Stream copy'}
</strong>
<span>Resource estimates are checked before allocation.</span>
<span>
{hasDetailedInformation
? 'Resource estimates are checked before allocation.'
: 'Detailed stream inspection will run before this export.'}
</span>
</div>
<button
className="button button--primary button--large"
@@ -488,6 +530,9 @@ export function QuickConvert({
streamSelection: effectiveSelection,
removeMetadata,
removeChapters,
...(!hasDetailedInformation
? { selectDetectedStreams: true as const }
: {}),
})
}
>
@@ -497,47 +542,14 @@ export function QuickConvert({
: queueing
? 'Add to queue'
: operation === 'remux'
? 'Remux'
: 'Convert'}
? hasDetailedInformation
? 'Remux'
: 'Inspect & remux'
: hasDetailedInformation
? 'Convert'
: 'Inspect & convert'}
</button>
</footer>
</section>
);
}
function quickStreamUnavailableReason(
stream: MediaStreamProbe,
operation: QuickOperation,
preset: ExportPreset | undefined
): string | undefined {
if (stream.type === 'unknown') {
return 'unknown stream kinds cannot be mapped safely.';
}
if (operation === 'remux') {
return undefined;
}
if (!preset) {
return 'choose an export preset first.';
}
if (stream.type === 'video' && !preset.video) {
return 'the selected preset creates audio only.';
}
if (stream.type === 'audio' && !preset.audio) {
return 'the selected preset creates video or images without audio.';
}
if (stream.type === 'subtitle') {
if (preset.subtitlePolicy === 'none') {
return 'the selected preset explicitly omits subtitles.';
}
if (preset.subtitlePolicy === 'burn-in') {
return 'burn-in requires an explicitly attached subtitle source.';
}
}
if (
(stream.type === 'attachment' || stream.type === 'data') &&
preset.container !== 'matroska'
) {
return `${stream.type} streams are only preserved by the reviewed Matroska conversion path.`;
}
return undefined;
}