kopia lustrzana https://github.com/mifi/lossless-cut
362 wiersze
13 KiB
TypeScript
362 wiersze
13 KiB
TypeScript
// https://www.ffmpeg.org/doxygen/3.2/libavutil_2utils_8c_source.html#l00079
|
|
const defaultProcessedCodecTypes = new Set([
|
|
'video',
|
|
'audio',
|
|
'subtitle',
|
|
'attachment',
|
|
]);
|
|
|
|
const unprocessableCodecs = new Set([
|
|
'dvb_teletext', // ffmpeg doesn't seem to support this https://github.com/mifi/lossless-cut/issues/1343
|
|
]);
|
|
|
|
// taken from `ffmpeg -codecs`
|
|
export const pcmAudioCodecs = [
|
|
'adpcm_4xm',
|
|
'adpcm_adx',
|
|
'adpcm_afc',
|
|
'adpcm_agm',
|
|
'adpcm_aica',
|
|
'adpcm_argo',
|
|
'adpcm_ct',
|
|
'adpcm_dtk',
|
|
'adpcm_ea',
|
|
'adpcm_ea_maxis_xa',
|
|
'adpcm_ea_r1',
|
|
'adpcm_ea_r2',
|
|
'adpcm_ea_r3',
|
|
'adpcm_ea_xas',
|
|
'adpcm_g722',
|
|
'adpcm_g726',
|
|
'adpcm_g726le',
|
|
'adpcm_ima_alp',
|
|
'adpcm_ima_amv',
|
|
'adpcm_ima_apc',
|
|
'adpcm_ima_apm',
|
|
'adpcm_ima_cunning',
|
|
'adpcm_ima_dat4',
|
|
'adpcm_ima_dk3',
|
|
'adpcm_ima_dk4',
|
|
'adpcm_ima_ea_eacs',
|
|
'adpcm_ima_ea_sead',
|
|
'adpcm_ima_iss',
|
|
'adpcm_ima_moflex',
|
|
'adpcm_ima_mtf',
|
|
'adpcm_ima_oki',
|
|
'adpcm_ima_qt',
|
|
'adpcm_ima_rad',
|
|
'adpcm_ima_smjpeg',
|
|
'adpcm_ima_ssi',
|
|
'adpcm_ima_wav',
|
|
'adpcm_ima_ws',
|
|
'adpcm_ms',
|
|
'adpcm_mtaf',
|
|
'adpcm_psx',
|
|
'adpcm_sbpro_2',
|
|
'adpcm_sbpro_3',
|
|
'adpcm_sbpro_4',
|
|
'adpcm_swf',
|
|
'adpcm_thp',
|
|
'adpcm_thp_le',
|
|
'adpcm_vima',
|
|
'adpcm_xa',
|
|
'adpcm_yamaha',
|
|
'adpcm_zork',
|
|
'pcm_alaw',
|
|
'pcm_bluray',
|
|
'pcm_dvd',
|
|
'pcm_f16le',
|
|
'pcm_f24le',
|
|
'pcm_f32be',
|
|
'pcm_f32le',
|
|
'pcm_f64be',
|
|
'pcm_f64le',
|
|
'pcm_lxf',
|
|
'pcm_mulaw',
|
|
'pcm_s16be',
|
|
'pcm_s16be_planar',
|
|
'pcm_s16le',
|
|
'pcm_s16le_planar',
|
|
'pcm_s24be',
|
|
'pcm_s24daud',
|
|
'pcm_s24le',
|
|
'pcm_s24le_planar',
|
|
'pcm_s32be',
|
|
'pcm_s32le',
|
|
'pcm_s32le_planar',
|
|
'pcm_s64be',
|
|
'pcm_s64le',
|
|
'pcm_s8',
|
|
'pcm_s8_planar',
|
|
'pcm_sga',
|
|
'pcm_u16be',
|
|
'pcm_u16le',
|
|
'pcm_u24be',
|
|
'pcm_u24le',
|
|
'pcm_u32be',
|
|
'pcm_u32le',
|
|
'pcm_u8',
|
|
'pcm_vidc',
|
|
];
|
|
|
|
export function getActiveDisposition(disposition) {
|
|
if (disposition == null) return undefined;
|
|
const existingActiveDispositionEntry = Object.entries(disposition).find(([, value]) => value === 1);
|
|
if (!existingActiveDispositionEntry) return undefined;
|
|
return existingActiveDispositionEntry[0]; // return the key
|
|
}
|
|
|
|
export const isMov = (format: string) => ['ismv', 'ipod', 'mp4', 'mov'].includes(format);
|
|
|
|
type GetVideoArgsFn = (a: { streamIndex: number, outputIndex: number }) => string[] | undefined;
|
|
|
|
function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition = false, getVideoArgs = () => undefined }: {
|
|
stream, outputIndex: number, outFormat: string, manuallyCopyDisposition?: boolean, getVideoArgs?: GetVideoArgsFn
|
|
}) {
|
|
let args: string[] = [];
|
|
|
|
function addArgs(...newArgs) {
|
|
args.push(...newArgs);
|
|
}
|
|
function addCodecArgs(codec) {
|
|
addArgs(`-c:${outputIndex}`, codec);
|
|
}
|
|
|
|
// eslint-disable-next-line unicorn/prefer-switch
|
|
if (stream.codec_type === 'subtitle') {
|
|
// mp4/mov only supports mov_text, so convert it https://stackoverflow.com/a/17584272/6519037
|
|
// https://github.com/mifi/lossless-cut/issues/418
|
|
if (isMov(outFormat) && stream.codec_name !== 'mov_text') {
|
|
addCodecArgs('mov_text');
|
|
} else if (outFormat === 'matroska' && stream.codec_name === 'mov_text') {
|
|
// matroska doesn't support mov_text, so convert it to SRT (popular codec)
|
|
// https://github.com/mifi/lossless-cut/issues/418
|
|
// https://www.reddit.com/r/PleX/comments/bcfvev/can_someone_eli5_subtitles/
|
|
addCodecArgs('srt');
|
|
} else if (outFormat === 'webm' && stream.codec_name === 'mov_text') {
|
|
// Only WebVTT subtitles are supported for WebM.
|
|
addCodecArgs('webvtt');
|
|
// eslint-disable-next-line unicorn/prefer-switch
|
|
} else if (outFormat === 'srt') { // not technically lossless but why not
|
|
addCodecArgs('srt');
|
|
} else if (outFormat === 'ass') { // not technically lossless but why not
|
|
addCodecArgs('ass');
|
|
} else if (outFormat === 'webvtt') { // not technically lossless but why not
|
|
addCodecArgs('webvtt');
|
|
} else {
|
|
addCodecArgs('copy');
|
|
}
|
|
} else if (stream.codec_type === 'audio') {
|
|
// pcm_bluray should only ever be put in Blu-ray-style m2ts files, Matroska has no format mapping for it anyway.
|
|
// Use normal PCM (ie. pcm_s16le or pcm_s24le depending on bitdepth).
|
|
// https://forum.doom9.org/showthread.php?t=174718
|
|
// https://github.com/mifi/lossless-cut/issues/476
|
|
// ffmpeg cannot encode pcm_bluray
|
|
if (outFormat !== 'mpegts' && stream.codec_name === 'pcm_bluray') {
|
|
addCodecArgs('pcm_s24le');
|
|
} else if (outFormat === 'dv' && stream.codec_name === 'pcm_s16le' && stream.sample_rate !== '48000') {
|
|
// DV seems to require 48kHz output
|
|
// https://trac.ffmpeg.org/ticket/8352
|
|
// I think DV format only supports PCM_S16LE https://github.com/FFmpeg/FFmpeg/blob/b92028346c35dad837dd1160930435d88bd838b5/libavformat/dvenc.c#L450
|
|
addCodecArgs('pcm_s16le');
|
|
addArgs(`-ar:${outputIndex}`, '48000'); // maybe technically not lossless?
|
|
} else {
|
|
addCodecArgs('copy');
|
|
}
|
|
} else if (stream.codec_type === 'video') {
|
|
const videoArgs = getVideoArgs({ streamIndex: stream.index, outputIndex });
|
|
if (videoArgs) {
|
|
args = [...videoArgs];
|
|
} else {
|
|
addCodecArgs('copy');
|
|
}
|
|
|
|
if (isMov(outFormat)) {
|
|
// 0x31766568 see https://github.com/mifi/lossless-cut/issues/1444
|
|
// eslint-disable-next-line unicorn/prefer-switch, unicorn/no-lonely-if
|
|
if (['0x0000', '0x31637668', '0x31766568'].includes(stream.codec_tag) && stream.codec_name === 'hevc') {
|
|
addArgs(`-tag:${outputIndex}`, 'hvc1');
|
|
}
|
|
}
|
|
} else { // other stream types
|
|
addCodecArgs('copy');
|
|
}
|
|
|
|
// when concat'ing, disposition doesn't seem to get automatically transferred by ffmpeg, so we must do it manually
|
|
if (manuallyCopyDisposition && stream.disposition != null) {
|
|
const activeDisposition = getActiveDisposition(stream.disposition);
|
|
if (activeDisposition != null) {
|
|
addArgs(`-disposition:${outputIndex}`, String(activeDisposition));
|
|
}
|
|
}
|
|
|
|
args = [...args];
|
|
|
|
return args;
|
|
}
|
|
|
|
export function getMapStreamsArgs({ startIndex = 0, outFormat, allFilesMeta, copyFileStreams, manuallyCopyDisposition, getVideoArgs }: {
|
|
startIndex?: number, outFormat: string, allFilesMeta, copyFileStreams: { streamIds: number[], path: string }[], manuallyCopyDisposition?: boolean, getVideoArgs?: GetVideoArgsFn,
|
|
}) {
|
|
let args: string[] = [];
|
|
let outputIndex = startIndex;
|
|
|
|
copyFileStreams.forEach(({ streamIds, path }, fileIndex) => {
|
|
streamIds.forEach((streamId) => {
|
|
const { streams } = allFilesMeta[path];
|
|
const stream = streams.find((s) => s.index === streamId);
|
|
args = [
|
|
...args,
|
|
'-map', `${fileIndex}:${streamId}`,
|
|
...getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition, getVideoArgs }),
|
|
];
|
|
outputIndex += 1;
|
|
});
|
|
});
|
|
return args;
|
|
}
|
|
|
|
export function shouldCopyStreamByDefault(stream) {
|
|
if (!defaultProcessedCodecTypes.has(stream.codec_type)) return false;
|
|
if (unprocessableCodecs.has(stream.codec_name)) return false;
|
|
return true;
|
|
}
|
|
|
|
export const attachedPicDisposition = 'attached_pic';
|
|
|
|
export function isStreamThumbnail(stream) {
|
|
return stream && stream.codec_type === 'video' && stream.disposition?.[attachedPicDisposition] === 1;
|
|
}
|
|
|
|
export const getAudioStreams = (streams) => streams.filter((stream) => stream.codec_type === 'audio');
|
|
export const getRealVideoStreams = (streams) => streams.filter((stream) => stream.codec_type === 'video' && !isStreamThumbnail(stream));
|
|
export const getSubtitleStreams = (streams) => streams.filter((stream) => stream.codec_type === 'subtitle');
|
|
|
|
// videoTracks/audioTracks seems to be 1-indexed, while ffmpeg is 0-indexes
|
|
const getHtml5TrackId = (ffmpegTrackIndex: number) => String(ffmpegTrackIndex + 1);
|
|
|
|
const getHtml5VideoTracks = (video) => [...(video.videoTracks ?? [])];
|
|
const getHtml5AudioTracks = (video) => [...(video.audioTracks ?? [])];
|
|
|
|
export const getVideoTrackForStreamIndex = (video: HTMLVideoElement, index) => getHtml5VideoTracks(video).find((videoTrack) => videoTrack.id === getHtml5TrackId(index));
|
|
export const getAudioTrackForStreamIndex = (video: HTMLVideoElement, index) => getHtml5AudioTracks(video).find((audioTrack) => audioTrack.id === getHtml5TrackId(index));
|
|
|
|
function resetVideoTrack(video) {
|
|
console.log('Resetting video track');
|
|
getHtml5VideoTracks(video).forEach((track, index) => {
|
|
// eslint-disable-next-line no-param-reassign
|
|
track.selected = index === 0;
|
|
});
|
|
}
|
|
|
|
function resetAudioTrack(video) {
|
|
console.log('Resetting audio track');
|
|
getHtml5AudioTracks(video).forEach((track, index) => {
|
|
// eslint-disable-next-line no-param-reassign
|
|
track.enabled = index === 0;
|
|
});
|
|
}
|
|
|
|
// https://github.com/mifi/lossless-cut/issues/256
|
|
export function enableVideoTrack(video, index) {
|
|
if (index == null) {
|
|
resetVideoTrack(video);
|
|
return;
|
|
}
|
|
console.log('Enabling video track', index);
|
|
getHtml5VideoTracks(video).forEach((track) => {
|
|
// eslint-disable-next-line no-param-reassign
|
|
track.selected = track.id === getHtml5TrackId(index);
|
|
});
|
|
}
|
|
|
|
export function enableAudioTrack(video, index) {
|
|
if (index == null) {
|
|
resetAudioTrack(video);
|
|
return;
|
|
}
|
|
console.log('Enabling audio track', index);
|
|
getHtml5AudioTracks(video).forEach((track) => {
|
|
// eslint-disable-next-line no-param-reassign
|
|
track.enabled = track.id === getHtml5TrackId(index);
|
|
});
|
|
}
|
|
|
|
export function getStreamIdsToCopy({ streams, includeAllStreams }) {
|
|
if (includeAllStreams) {
|
|
return {
|
|
streamIdsToCopy: streams.map((stream) => stream.index),
|
|
excludedStreamIds: [],
|
|
};
|
|
}
|
|
|
|
// If preserveMetadataOnMerge option is enabled, we MUST explicitly map all streams even if includeAllStreams=false.
|
|
// We cannot use the ffmpeg's automatic stream selection or else ffmpeg might use the metadata source input (index 1)
|
|
// instead of the concat input (index 0)
|
|
// https://ffmpeg.org/ffmpeg.html#Automatic-stream-selection
|
|
const streamIdsToCopy: number[] = [];
|
|
// TODO try to mimic ffmpeg default mapping https://ffmpeg.org/ffmpeg.html#Automatic-stream-selection
|
|
const videoStreams = getRealVideoStreams(streams);
|
|
const audioStreams = getAudioStreams(streams);
|
|
const subtitleStreams = getSubtitleStreams(streams);
|
|
if (videoStreams.length > 0) streamIdsToCopy.push(videoStreams[0].index);
|
|
if (audioStreams.length > 0) streamIdsToCopy.push(audioStreams[0].index);
|
|
if (subtitleStreams.length > 0) streamIdsToCopy.push(subtitleStreams[0].index);
|
|
|
|
const excludedStreamIds = streams.filter((s) => !streamIdsToCopy.includes(s.index)).map((s) => s.index);
|
|
return { streamIdsToCopy, excludedStreamIds };
|
|
}
|
|
|
|
// this is just a rough check, could be improved
|
|
// todo check more accurately based on actual video stream
|
|
// https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding#how-to-verify-certain-profile-or-resolution-is-supported
|
|
// https://github.com/mifi/lossless-cut/issues/88#issuecomment-1363828563
|
|
export async function doesPlayerSupportHevcPlayback() {
|
|
const { supported } = await navigator.mediaCapabilities.decodingInfo({
|
|
type: 'file',
|
|
video: {
|
|
contentType: 'video/mp4; codecs="hev1.1.6.L93.B0"', // Main
|
|
width: 1920,
|
|
height: 1080,
|
|
bitrate: 10000,
|
|
framerate: 30,
|
|
},
|
|
});
|
|
return supported;
|
|
}
|
|
|
|
// With some codecs, the player will not give a playback error, but instead only play audio,
|
|
// so we will detect these codecs and convert to dummy
|
|
// "properly handle" here means either play it back or give a playback error if the video codec is not supported
|
|
// todo maybe improve https://github.com/mifi/lossless-cut/issues/88#issuecomment-1363828563
|
|
export function willPlayerProperlyHandleVideo({ streams, hevcPlaybackSupported }) {
|
|
const realVideoStreams = getRealVideoStreams(streams);
|
|
// If audio-only format, assume all is OK
|
|
if (realVideoStreams.length === 0) return true;
|
|
// If we have at least one video that is NOT of the unsupported formats, assume the player will be able to play it natively
|
|
// But cover art / thumbnail streams don't count e.g. hevc with a png stream (disposition.attached_pic=1)
|
|
// https://github.com/mifi/lossless-cut/issues/595
|
|
// https://github.com/mifi/lossless-cut/issues/975
|
|
// https://github.com/mifi/lossless-cut/issues/1407
|
|
// https://github.com/mifi/lossless-cut/issues/1505 https://samples.ffmpeg.org/archive/video/mjpeg/mov+mjpeg+pcm_u8++MPlayerRC1PlaybackCrash_david@pastornet.net.au.mov
|
|
const chromiumSilentlyFailCodecs = ['prores', 'mpeg4', 'mpeg2video', 'tscc2', 'dvvideo', 'mjpeg'];
|
|
if (!hevcPlaybackSupported) chromiumSilentlyFailCodecs.push('hevc');
|
|
return realVideoStreams.some((stream) => !chromiumSilentlyFailCodecs.includes(stream.codec_name));
|
|
}
|
|
|
|
export function isAudioDefinitelyNotSupported(streams) {
|
|
const audioStreams = getAudioStreams(streams);
|
|
if (audioStreams.length === 0) return false;
|
|
// TODO this could be improved
|
|
return audioStreams.every((stream) => ['ac3', 'eac3'].includes(stream.codec_name));
|
|
}
|
|
|
|
export function getVideoTimebase(videoStream) {
|
|
const timebaseMatch = videoStream.time_base && videoStream.time_base.split('/');
|
|
if (timebaseMatch) {
|
|
const timebaseParsed = parseInt(timebaseMatch[1], 10);
|
|
if (!Number.isNaN(timebaseParsed)) return timebaseParsed;
|
|
}
|
|
return undefined;
|
|
}
|