feat: add lossless crop, rotate & aspect ratio via bitstream metadata (#643)

Implements lossless video transformation via codec bitstream filters:

- H.264: h264_metadata (crop_left/right/top/bottom, sample_aspect_ratio)
- HEVC: hevc_metadata (crop_left/right/top/bottom, sample_aspect_ratio)
- Other codecs: container-level -aspect flag fallback

Changes:
- types.ts: Add BsfCropParams, BsfAspectRatioParams interfaces and
  bsfCrop/bsfAspectRatio fields to StreamParams
- useFfmpegOperations.ts: Inject crop and SAR bitstream filters into
  the existing customParamsArgs() pipeline, with codec auto-detection
- StreamsSelector.tsx: Add lossless crop UI (4 number inputs for
  left/right/top/bottom) and aspect ratio UI (num:den) to the stream
  parameters editor, with codec compatibility detection and VLC warning

No re-encoding required - uses -codec copy with -bsf:v metadata injection.
Tested compatible with mpv, MPC-HC, Firefox, ffplay, Microsoft Photos.
Known issue: VLC has a bug interpreting H.264 crop metadata (VLC #27382).
pull/2846/head
Bala Vignesh S 4 months ago
parent 260405e796
commit 2b9654bad9

@ -128,6 +128,91 @@ function StreamParametersEditor({ stream, streamParams, updateStreamParams }: {
);
}
if (stream.codec_type === 'video' && (stream.codec_name === 'h264' || stream.codec_name === 'hevc')) {
const currentCrop = streamParams.bsfCrop ?? { left: 0, right: 0, top: 0, bottom: 0 };
const updateCrop = (field: 'left' | 'right' | 'top' | 'bottom', value: string) => {
const parsed = parseInt(value, 10);
const val = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
updateStreamParams((params) => {
// eslint-disable-next-line no-param-reassign
params.bsfCrop = { ...currentCrop, [field]: val };
});
};
ui.push(
<div key="bsfCrop" style={{ marginBottom: '.8em' }}>
<div style={{ fontWeight: 'bold', marginBottom: '.3em' }}>
{t('Lossless crop ({{codec}} metadata)', { codec: stream.codec_name.toUpperCase() })}
</div>
<div style={{ fontSize: '.85em', color: 'var(--gray-11)', marginBottom: '.4em' }}>
{t('Crop pixels from each edge without re-encoding. Works in most players except VLC (known bug).')}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '.3em' }}>
<KeyValue name={t('Left')} value={<TextInput type="number" min="0" step="2" style={{ width: '5em' }} value={String(currentCrop.left)} onChange={(e) => updateCrop('left', e.target.value)} />} />
<KeyValue name={t('Right')} value={<TextInput type="number" min="0" step="2" style={{ width: '5em' }} value={String(currentCrop.right)} onChange={(e) => updateCrop('right', e.target.value)} />} />
<KeyValue name={t('Top')} value={<TextInput type="number" min="0" step="2" style={{ width: '5em' }} value={String(currentCrop.top)} onChange={(e) => updateCrop('top', e.target.value)} />} />
<KeyValue name={t('Bottom')} value={<TextInput type="number" min="0" step="2" style={{ width: '5em' }} value={String(currentCrop.bottom)} onChange={(e) => updateCrop('bottom', e.target.value)} />} />
</div>
</div>,
);
const currentAr = streamParams.bsfAspectRatio ?? { num: 0, den: 0 };
const updateAr = (field: 'num' | 'den', value: string) => {
const parsed = parseInt(value, 10);
const val = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
updateStreamParams((params) => {
// eslint-disable-next-line no-param-reassign
params.bsfAspectRatio = { ...currentAr, [field]: val };
});
};
ui.push(
<div key="bsfAspectRatio" style={{ marginBottom: '.8em' }}>
<div style={{ fontWeight: 'bold', marginBottom: '.3em' }}>
{t('Lossless aspect ratio (SAR)')}
</div>
<div style={{ fontSize: '.85em', color: 'var(--gray-11)', marginBottom: '.4em' }}>
{t('Change the sample aspect ratio without re-encoding. Set both to 0 to keep original.')}
</div>
<div style={{ display: 'flex', gap: '.5em', alignItems: 'center' }}>
<TextInput type="number" min="0" style={{ width: '4em' }} placeholder="W" value={currentAr.num > 0 ? String(currentAr.num) : ''} onChange={(e) => updateAr('num', e.target.value)} />
<span>:</span>
<TextInput type="number" min="0" style={{ width: '4em' }} placeholder="H" value={currentAr.den > 0 ? String(currentAr.den) : ''} onChange={(e) => updateAr('den', e.target.value)} />
</div>
</div>,
);
} else if (stream.codec_type === 'video') {
// Non-H264/HEVC video streams: only container-level aspect ratio
const currentAr = streamParams.bsfAspectRatio ?? { num: 0, den: 0 };
const updateAr = (field: 'num' | 'den', value: string) => {
const parsed = parseInt(value, 10);
const val = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
updateStreamParams((params) => {
// eslint-disable-next-line no-param-reassign
params.bsfAspectRatio = { ...currentAr, [field]: val };
});
};
ui.push(
<div key="bsfAspectRatioContainer" style={{ marginBottom: '.8em' }}>
<div style={{ fontWeight: 'bold', marginBottom: '.3em' }}>
{t('Display aspect ratio (container-level)')}
</div>
<div style={{ fontSize: '.85em', color: 'var(--gray-11)', marginBottom: '.4em' }}>
{t('Change the display aspect ratio at the container level without re-encoding.')}
</div>
<div style={{ display: 'flex', gap: '.5em', alignItems: 'center' }}>
<TextInput type="number" min="0" style={{ width: '4em' }} placeholder="W" value={currentAr.num > 0 ? String(currentAr.num) : ''} onChange={(e) => updateAr('num', e.target.value)} />
<span>:</span>
<TextInput type="number" min="0" style={{ width: '4em' }} placeholder="H" value={currentAr.den > 0 ? String(currentAr.den) : ''} onChange={(e) => updateAr('den', e.target.value)} />
</div>
</div>,
);
}
if (stream.codec_type === 'video' || stream.codec_type === 'audio') {
ui.push(
<KeyValue

@ -347,6 +347,43 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
if (streamParams.bsfHevcMp4toannexb) bitstreamFilters.push('hevc_mp4toannexb');
if (streamParams.bsfHevcAudInsert) bitstreamFilters.push('hevc_metadata=aud=insert');
// Lossless crop via codec bitstream metadata (#643)
if (streamParams.bsfCrop) {
const { left, right, top, bottom } = streamParams.bsfCrop;
const hasCrop = left > 0 || right > 0 || top > 0 || bottom > 0;
if (hasCrop) {
// Look up codec_name from allFilesMeta to determine the correct bitstream filter
const fileStreams = allFilesMeta[fileId]?.streams;
const streamInfo = fileStreams?.find((s) => s.index === streamId);
const codecName = streamInfo?.codec_name;
if (codecName === 'h264') {
bitstreamFilters.push(`h264_metadata=crop_left=${left}:crop_right=${right}:crop_top=${top}:crop_bottom=${bottom}`);
} else if (codecName === 'hevc') {
bitstreamFilters.push(`hevc_metadata=crop_left=${left}:crop_right=${right}:crop_top=${top}:crop_bottom=${bottom}`);
}
}
}
// Lossless aspect ratio (SAR) via codec bitstream metadata (#643)
if (streamParams.bsfAspectRatio) {
const { num, den } = streamParams.bsfAspectRatio;
if (num > 0 && den > 0) {
const fileStreams = allFilesMeta[fileId]?.streams;
const streamInfo = fileStreams?.find((s) => s.index === streamId);
const codecName = streamInfo?.codec_name;
if (codecName === 'h264') {
bitstreamFilters.push(`h264_metadata=sample_aspect_ratio=${num}/${den}`);
} else if (codecName === 'hevc') {
bitstreamFilters.push(`hevc_metadata=sample_aspect_ratio=${num}/${den}`);
} else {
// For non-H264/HEVC codecs, use container-level -aspect flag
ret.push('-aspect', `${num}:${den}`);
}
}
}
if (bitstreamFilters.length > 0) {
ret.push(`-bsf:${outputIndex}`, bitstreamFilters.join(','));
}

@ -163,6 +163,18 @@ export type AllFilesMeta = Record<string, {
export type CustomTagsByFile = Record<string, Record<string, string>>;
export interface BsfCropParams {
left: number;
right: number;
top: number;
bottom: number;
}
export interface BsfAspectRatioParams {
num: number;
den: number;
}
export interface StreamParams {
customTags?: Record<string, string>,
disposition?: string,
@ -170,6 +182,8 @@ export interface StreamParams {
bsfHevcMp4toannexb?: boolean,
bsfHevcAudInsert?: boolean,
tag?: string | undefined,
bsfCrop?: BsfCropParams | undefined,
bsfAspectRatio?: BsfAspectRatioParams | undefined,
}
export type ParamsByStreamId = Map<string, Map<number, StreamParams>>;

Loading…
Cancel
Save