feat: publish av-tools 0.1.0
This commit is contained in:
541
src/components/QuickConvert.tsx
Normal file
541
src/components/QuickConvert.tsx
Normal file
@@ -0,0 +1,541 @@
|
||||
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 { 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 opusNeedsRegressionTest = preset?.audio?.codec === 'libopus';
|
||||
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' &&
|
||||
!opusNeedsRegressionTest &&
|
||||
!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'
|
||||
? 'Opus awaits a pinned-core regression test'
|
||||
: 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}
|
||||
{opusNeedsRegressionTest ? (
|
||||
<p className="notice notice--warning">
|
||||
<Icon name="warning" />
|
||||
Opus export is held back until the pinned core passes its browser
|
||||
regression test.
|
||||
</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;
|
||||
}
|
||||
Reference in New Issue
Block a user