feat: make media inspection progressive
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user