544 lines
17 KiB
TypeScript
544 lines
17 KiB
TypeScript
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<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 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 (
|
|
<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={
|
|
asset.probe?.streams.some((stream) => stream.type === 'video')
|
|
? 'video'
|
|
: 'audio'
|
|
}
|
|
/>
|
|
</span>
|
|
<div>
|
|
<strong>{asset.file.name}</strong>
|
|
<span>
|
|
{formatBytes(asset.file.size)} ·{' '}
|
|
{formatDuration(asset.probe?.durationSeconds)}
|
|
</span>
|
|
</div>
|
|
<span className={`phase phase--${asset.phase}`}>
|
|
{asset.phase === 'ready'
|
|
? 'Ready'
|
|
: asset.phase === 'error'
|
|
? 'Could not inspect'
|
|
: 'Preparing'}
|
|
</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 = 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>
|
|
);
|
|
})
|
|
) : (
|
|
<p>Streams appear after local inspection.</p>
|
|
)}
|
|
</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}
|
|
{!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>Resource estimates are checked before allocation.</span>
|
|
</div>
|
|
<button
|
|
className="button button--primary button--large"
|
|
type="button"
|
|
disabled={!canExport}
|
|
onClick={() =>
|
|
onExport({
|
|
operation,
|
|
preset,
|
|
remuxContainer,
|
|
streamSelection: effectiveSelection,
|
|
removeMetadata,
|
|
removeChapters,
|
|
})
|
|
}
|
|
>
|
|
<Icon name={busy || queueing ? 'queue' : 'sparkles'} />
|
|
{busy
|
|
? 'Preparing engine…'
|
|
: queueing
|
|
? 'Add to queue'
|
|
: operation === 'remux'
|
|
? 'Remux'
|
|
: '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;
|
|
}
|