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 { StreamSelection } from '../commands/command-utils'; import { validateRemuxCompatibility, type RemuxStream, } from '../commands/remux'; import { requirementsForUserPreset, type ExportPreset, } from '../commands/convert'; import { BUILT_IN_PRESETS, presetAvailability, } from '../presets/preset-registry'; 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 } from './quick-convert-selection'; export interface QuickExportConfiguration { operation: QuickOperation; preset?: ExportPreset; remuxContainer?: 'mp4' | 'webm' | 'matroska' | 'ogg'; 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 { asset?: ImportedMediaAsset; engineState: EngineState; busy: boolean; queueing?: boolean; presets?: readonly ExportPreset[]; onInspect?: () => void; onExport: (configuration: QuickExportConfiguration) => void; } export function QuickConvert({ asset, engineState, busy, queueing = false, presets = BUILT_IN_PRESETS, onInspect, onExport, }: QuickConvertProps) { const [operation, setOperation] = useState('convert'); const [presetId, setPresetId] = useState('mp4-h264-balanced'); const [remuxContainer, setRemuxContainer] = useState('matroska'); const [removeMetadata, setRemoveMetadata] = useState(false); const [removeChapters, setRemoveChapters] = useState(false); const [streamOverride, setStreamOverride] = useState<{ readonly assetId: string; readonly indices: ReadonlySet; }>(); const preset = presets.find((entry) => entry.id === presetId); const hasDetailedInformation = asset?.probe !== undefined; const streams = useMemo(() => asset?.probe?.streams ?? [], [asset?.probe]); const requestedStreams = useMemo( () => streamOverride !== undefined && asset !== undefined && streamOverride.assetId === asset.id ? streamOverride.indices : new Set(streams.map((stream) => stream.index)), [asset, streamOverride, streams] ); const streamUnavailableReasons = useMemo( () => new Map( streams.flatMap((stream) => { const reason = quickStreamUnavailableReason( stream, operation, preset ); return reason ? ([[stream.index, reason] as const] as const) : []; }) ), [operation, preset, streams] ); const selectedStreams = useMemo( () => new Set( [...requestedStreams].filter( (index) => !streamUnavailableReasons.has(index) ) ), [requestedStreams, streamUnavailableReasons] ); const effectiveSelection = useMemo(() => { return { video: streams .filter( (stream) => stream.type === 'video' && selectedStreams.has(stream.index) ) .map((stream) => stream.index), audio: streams .filter( (stream) => stream.type === 'audio' && selectedStreams.has(stream.index) ) .map((stream) => stream.index), subtitles: streams .filter( (stream) => stream.type === 'subtitle' && selectedStreams.has(stream.index) ) .map((stream) => stream.index), attachments: streams .filter( (stream) => stream.type === 'attachment' && selectedStreams.has(stream.index) ) .map((stream) => stream.index), data: streams .filter( (stream) => stream.type === 'data' && selectedStreams.has(stream.index) ) .map((stream) => stream.index), }; }, [selectedStreams, streams]); const presetRequirements = preset && 'builtIn' in preset ? preset.requirements : preset ? requirementsForUserPreset(preset) : undefined; const availability = presetRequirements && engineState.status === 'ready' ? presetAvailability( { requirements: presetRequirements }, engineState.capabilities ) : undefined; const opusBlockedByPinnedCore = preset?.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED; const presetNeedsMissingAudio = hasDetailedInformation && preset?.kind === 'audio' && effectiveSelection.audio.length === 0; const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) => stream.type === 'video' || stream.type === 'audio' || stream.type === 'subtitle' || stream.type === 'attachment' || stream.type === 'data' ? [ { index: stream.index, kind: stream.type, codec: stream.codecName ?? 'unknown', }, ] : [] ); const remuxDiagnostics = remuxContainer && hasDetailedInformation ? validateRemuxCompatibility( { streams: remuxStreams, hasChapters: Boolean(asset?.probe?.chapters.length), }, remuxContainer, effectiveSelection ) : []; const remuxErrors = remuxDiagnostics.filter( (diagnostic) => diagnostic.severity === 'error' ); const remuxMuxerMissing = remuxContainer !== undefined && engineState.status === 'ready' && !engineState.capabilities.muxers.has(remuxContainer); const remuxUnavailableReason = remuxMuxerMissing ? `The loaded core is missing muxer ${remuxContainer}.` : remuxErrors.map((diagnostic) => diagnostic.message).join(' '); const hasSelectedStream = !hasDetailedInformation || selectedStreams.size > 0; const canExport = asset !== undefined && !busy && hasSelectedStream && (operation === 'remux' ? !remuxMuxerMissing && remuxErrors.length === 0 : preset !== undefined && availability?.status !== 'unavailable' && !opusBlockedByPinnedCore && !presetNeedsMissingAudio); const presetOption = (entry: ExportPreset) => { const requirements = 'builtIn' in entry ? entry.requirements : requirementsForUserPreset(entry); const entryAvailability = engineState.status === 'ready' ? presetAvailability({ requirements }, engineState.capabilities) : undefined; const reason = entry.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED ? 'the reviewed core has not passed the ST/MT Opus regression encode' : entryAvailability?.status === 'unavailable' ? entryAvailability.reasons.join(', ') : undefined; return { disabled: reason !== undefined, label: reason ? `${entry.name} — unavailable: ${reason}` : entry.name, }; }; return (

One source · one output

Quick Convert

Local processing
{asset ? (
{asset.file.name} {formatBytes(asset.file.size)} ·{' '} {formatDuration(assetDurationSeconds(asset))}
{asset.phase === 'ready' ? 'Details ready' : asset.phase === 'probing' ? 'Inspecting details' : asset.phase === 'error' ? 'Ready to play' : 'Ready to play'}
) : (

Add and select a media source to continue.

)}
{operation === 'convert' ? ( ) : ( )}
Streams {streams.length > 0 ? ( streams.map((stream) => { const checked = selectedStreams.has(stream.index); const unavailableReason = streamUnavailableReasons.get( stream.index ); return ( ); }) ) : (

Streams will be identified only when you inspect details or start this export.

{asset && asset.phase !== 'probing' && onInspect ? ( ) : null}
)}
{availability?.status === 'unavailable' ? (

This core cannot run the selected preset:{' '} {availability.reasons.join(', ')}

) : null} {operation === 'remux' && remuxUnavailableReason ? (

{remuxUnavailableReason}

) : null} {operation === 'convert' && presetNeedsMissingAudio ? (

Select at least one audio stream for an audio-only preset.

) : null} {asset?.phase === 'error' && !asset.probe ? (

Playback and basic file information remain available. Detailed inspection can be retried when an export needs it.

) : null} {!hasSelectedStream && streams.length > 0 ? (

Select at least one output stream.

) : null} {opusBlockedByPinnedCore ? (

Opus export is disabled until the reviewed core passes its non-empty encode and re-probe gate in both engine modes.

) : null}
); }