import { useMemo, useState } from 'react'; import type { ImportedMediaAsset } 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, 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'; export type QuickOperation = 'convert' | 'remux'; export interface QuickExportConfiguration { operation: QuickOperation; preset?: ExportPreset; remuxContainer?: 'mp4' | 'webm' | 'matroska' | 'ogg'; streamSelection: StreamSelection; removeMetadata: boolean; removeChapters: boolean; } export interface QuickConvertProps { asset?: ImportedMediaAsset; engineState: EngineState; busy: boolean; queueing?: boolean; presets?: readonly ExportPreset[]; onExport: (configuration: QuickExportConfiguration) => void; } export function QuickConvert({ asset, engineState, busy, queueing = false, presets = BUILT_IN_PRESETS, 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 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 = 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 ? 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 = selectedStreams.size > 0; const canExport = asset?.phase === 'ready' && !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 ? (
stream.type === 'video') ? 'video' : 'audio' } />
{asset.file.name} {formatBytes(asset.file.size)} ·{' '} {formatDuration(asset.probe?.durationSeconds)}
{asset.phase === 'ready' ? 'Ready' : asset.phase === 'error' ? 'Could not inspect' : 'Preparing'}
) : (

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 appear after local inspection.

)}
{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} {!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}
); } 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; }