Files
av-tools/src/components/QuickConvert.tsx

556 lines
18 KiB
TypeScript

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<QuickOperation>('convert');
const [presetId, setPresetId] = useState('mp4-h264-balanced');
const [remuxContainer, setRemuxContainer] =
useState<QuickExportConfiguration['remuxContainer']>('matroska');
const [removeMetadata, setRemoveMetadata] = useState(false);
const [removeChapters, setRemoveChapters] = useState(false);
const [streamOverride, setStreamOverride] = useState<{
readonly assetId: string;
readonly indices: ReadonlySet<number>;
}>();
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 (
<section className="panel quick-panel" aria-labelledby="quick-title">
<header className="panel__header">
<div>
<p className="eyebrow">One source · one output</p>
<h2 id="quick-title">Quick Convert</h2>
</div>
<span className="privacy-badge">
<span />
Local processing
</span>
</header>
{asset ? (
<div className="source-summary">
<span className="source-summary__icon">
<Icon name={assetHasVideo(asset) === true ? 'video' : 'audio'} />
</span>
<div>
<strong>{asset.file.name}</strong>
<span>
{formatBytes(asset.file.size)} ·{' '}
{formatDuration(assetDurationSeconds(asset))}
</span>
</div>
<span className={`phase phase--${asset.phase}`}>
{asset.phase === 'ready'
? 'Details ready'
: asset.phase === 'probing'
? 'Inspecting details'
: asset.phase === 'error'
? 'Ready to play'
: 'Ready to play'}
</span>
</div>
) : (
<p className="panel-empty">
Add and select a media source to continue.
</p>
)}
<div className="segmented segmented--compact" aria-label="Output method">
<button
type="button"
className={operation === 'convert' ? 'is-active' : ''}
onClick={() => setOperation('convert')}
>
Re-encode
</button>
<button
type="button"
className={operation === 'remux' ? 'is-active' : ''}
onClick={() => setOperation('remux')}
>
Fast remux
</button>
</div>
<div className="form-grid">
{operation === 'convert' ? (
<label className="field field--wide">
<span>Export preset</span>
<select
value={presetId}
onChange={(event) => setPresetId(event.currentTarget.value)}
>
<optgroup label="Video">
{presets
.filter((entry) => entry.kind !== 'audio')
.map((entry) => {
const option = presetOption(entry);
return (
<option
key={entry.id}
value={entry.id}
disabled={option.disabled}
>
{option.label}
</option>
);
})}
</optgroup>
<optgroup label="Audio">
{presets
.filter((entry) => entry.kind === 'audio')
.map((entry) => {
const option = presetOption(entry);
return (
<option
key={entry.id}
value={entry.id}
disabled={option.disabled}
>
{option.label}
</option>
);
})}
</optgroup>
</select>
<small>
{preset?.description ??
'A validated user-created local export preset.'}
</small>
</label>
) : (
<label className="field field--wide">
<span>Target container</span>
<select
value={remuxContainer}
onChange={(event) =>
setRemuxContainer(
event.currentTarget
.value as QuickExportConfiguration['remuxContainer']
)
}
>
{(
[
['matroska', 'Matroska (.mkv)'],
['mp4', 'MP4 (.mp4)'],
['webm', 'WebM (.webm)'],
['ogg', 'Ogg (.ogg)'],
] as const
).map(([container, label]) => {
const diagnostics = hasDetailedInformation
? validateRemuxCompatibility(
{
streams: remuxStreams,
hasChapters: Boolean(asset?.probe?.chapters.length),
},
container,
effectiveSelection
)
: [];
const missingMuxer =
engineState.status === 'ready' &&
!engineState.capabilities.muxers.has(container);
const reason = [
...(missingMuxer ? [`missing muxer ${container}.`] : []),
...diagnostics
.filter((entry) => entry.severity === 'error')
.map((entry) => entry.message),
].join(' ');
return (
<option
key={container}
value={container}
disabled={Boolean(reason)}
>
{reason ? `${label} — unavailable: ${reason}` : label}
</option>
);
})}
</select>
<small>Copies compatible streams without quality loss.</small>
</label>
)}
<fieldset className="stream-picker field--wide">
<legend>Streams</legend>
{streams.length > 0 ? (
streams.map((stream) => {
const checked = selectedStreams.has(stream.index);
const unavailableReason = streamUnavailableReasons.get(
stream.index
);
return (
<label key={stream.index}>
<input
type="checkbox"
checked={checked}
disabled={unavailableReason !== undefined}
onChange={() => {
const next = new Set(requestedStreams);
if (next.has(stream.index)) next.delete(stream.index);
else next.add(stream.index);
if (asset) {
setStreamOverride({
assetId: asset.id,
indices: next,
});
}
}}
/>
<span className="stream-kind">{stream.type}</span>
<span>
#{stream.index} · {stream.codecName ?? 'unknown codec'}
{stream.language ? ` · ${stream.language}` : ''}
{unavailableReason ? (
<small>Unavailable: {unavailableReason}</small>
) : null}
</span>
</label>
);
})
) : (
<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>
<label className="check-field">
<input
type="checkbox"
checked={removeMetadata}
onChange={(event) => setRemoveMetadata(event.currentTarget.checked)}
/>
Remove metadata
</label>
<label className="check-field">
<input
type="checkbox"
checked={removeChapters}
onChange={(event) => setRemoveChapters(event.currentTarget.checked)}
/>
Remove chapters
</label>
</div>
{availability?.status === 'unavailable' ? (
<p className="notice notice--error">
<Icon name="warning" />
This core cannot run the selected preset:{' '}
{availability.reasons.join(', ')}
</p>
) : null}
{operation === 'remux' && remuxUnavailableReason ? (
<p className="notice notice--error">
<Icon name="warning" />
{remuxUnavailableReason}
</p>
) : null}
{operation === 'convert' && presetNeedsMissingAudio ? (
<p className="notice notice--error">
<Icon name="warning" />
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" />
Select at least one output stream.
</p>
) : null}
{opusBlockedByPinnedCore ? (
<p className="notice notice--warning">
<Icon name="warning" />
Opus export is disabled until the reviewed core passes its non-empty
encode and re-probe gate in both engine modes.
</p>
) : null}
<footer className="quick-panel__footer">
<div>
<strong>
{preset && 'settingsSummary' in preset
? preset.settingsSummary[0]
: preset
? 'Validated user preset'
: 'Stream copy'}
</strong>
<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"
type="button"
disabled={!canExport}
onClick={() =>
onExport({
operation,
preset,
remuxContainer,
streamSelection: effectiveSelection,
removeMetadata,
removeChapters,
...(!hasDetailedInformation
? { selectDetectedStreams: true as const }
: {}),
})
}
>
<Icon name={busy || queueing ? 'queue' : 'sparkles'} />
{busy
? 'Preparing engine…'
: queueing
? 'Add to queue'
: operation === 'remux'
? hasDetailedInformation
? 'Remux'
: 'Inspect & remux'
: hasDetailedInformation
? 'Convert'
: 'Inspect & convert'}
</button>
</footer>
</section>
);
}