2020-03-04 10:41:40 +00:00
import pMap from 'p-map' ;
import sortBy from 'lodash/sortBy' ;
2020-03-19 15:37:38 +00:00
import i18n from 'i18next' ;
2020-11-27 21:43:06 +00:00
import Timecode from 'smpte-timecode' ;
2023-02-05 11:08:28 +00:00
import minBy from 'lodash/minBy' ;
2024-03-20 08:02:34 +00:00
import invariant from 'tiny-invariant' ;
2020-03-04 10:41:40 +00:00
2022-12-31 08:20:04 +00:00
import { pcmAudioCodecs , getMapStreamsArgs , isMov } from './util/streams' ;
2023-04-08 14:51:09 +00:00
import { getSuffixedOutPath , isExecaFailure } from './util' ;
2022-12-30 08:15:22 +00:00
import { isDurationValid } from './segments' ;
2024-03-20 08:02:34 +00:00
import { Waveform } from '../types' ;
import { FFprobeProbeResult , FFprobeStream } from '../ffprobe' ;
2020-03-04 10:41:40 +00:00
2022-08-12 17:47:04 +00:00
const FileType = window . require ( 'file-type' ) ;
2022-08-12 18:54:33 +00:00
const { pathExists } = window . require ( 'fs-extra' ) ;
2020-03-04 10:41:40 +00:00
2023-04-08 14:51:09 +00:00
const remote = window . require ( '@electron/remote' ) ;
2022-08-12 18:54:33 +00:00
2024-03-20 08:02:34 +00:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ffmpeg : { renderWaveformPng : ( a : any ) = > Promise < Waveform > , mapTimesToSegments , detectSceneChanges , captureFrames , captureFrame , getFfCommandLine , runFfmpegConcat , runFfmpegWithProgress , html5ify , getDuration , abortFfmpegs , runFfmpeg , runFfprobe , getFfmpegPath , setCustomFfPath } = remote . require ( './ffmpeg' ) ;
const { renderWaveformPng , mapTimesToSegments , detectSceneChanges , captureFrames , captureFrame , getFfCommandLine , runFfmpegConcat , runFfmpegWithProgress , html5ify , getDuration , abortFfmpegs , runFfmpeg , runFfprobe , getFfmpegPath , setCustomFfPath } = ffmpeg ;
2022-08-12 18:54:33 +00:00
2016-10-30 10:57:12 +00:00
2023-04-08 14:51:09 +00:00
export { renderWaveformPng , mapTimesToSegments , detectSceneChanges , captureFrames , captureFrame , getFfCommandLine , runFfmpegConcat , runFfmpegWithProgress , html5ify , getDuration , abortFfmpegs , runFfprobe , getFfmpegPath , setCustomFfPath } ;
2020-02-22 15:44:21 +00:00
2021-11-13 11:26:32 +00:00
2024-02-20 14:48:40 +00:00
export class RefuseOverwriteError extends Error {
constructor ( ) {
super ( ) ;
this . name = 'RefuseOverwriteError' ;
}
}
2023-01-06 15:28:46 +00:00
2023-02-15 13:15:45 +00:00
export function logStdoutStderr ( { stdout , stderr } ) {
if ( stdout . length > 0 ) {
console . log ( '%cSTDOUT:' , 'color: green; font-weight: bold' ) ;
console . log ( stdout ) ;
}
if ( stderr . length > 0 ) {
console . log ( '%cSTDERR:' , 'color: blue; font-weight: bold' ) ;
console . log ( stderr ) ;
}
}
2020-03-04 10:41:40 +00:00
export function isCuttingStart ( cutFrom ) {
2020-02-18 08:43:10 +00:00
return cutFrom > 0 ;
}
2020-03-04 10:41:40 +00:00
export function isCuttingEnd ( cutTo , duration ) {
2020-11-22 22:39:39 +00:00
if ( ! isDurationValid ( duration ) ) return true ;
2020-02-18 08:43:10 +00:00
return cutTo < duration ;
}
2020-02-27 09:17:35 +00:00
function getIntervalAroundTime ( time , window ) {
return {
from : Math . max ( time - window / 2 , 0 ) ,
to : time + window / 2 ,
} ;
}
2024-02-14 13:12:16 +00:00
interface Keyframe {
time : number ,
createdAt : Date ,
}
interface Frame extends Keyframe {
keyframe : boolean
}
2022-12-29 10:22:24 +00:00
export async function readFrames ( { filePath , from , to , streamIndex } ) {
const intervalsArgs = from != null && to != null ? [ '-read_intervals' , ` ${ from } % ${ to } ` ] : [ ] ;
2022-02-27 14:54:22 +00:00
const { stdout } = await runFfprobe ( [ '-v' , 'error' , . . . intervalsArgs , '-show_packets' , '-select_streams' , streamIndex , '-show_entries' , 'packet=pts_time,flags' , '-of' , 'json' , filePath ] ) ;
2024-02-14 13:12:16 +00:00
// todo types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const packetsFiltered : Frame [ ] = ( JSON . parse ( stdout ) . packets as any [ ] )
2024-02-20 14:48:40 +00:00
. map ( ( p ) = > ( {
2022-01-15 16:02:36 +00:00
keyframe : p.flags [ 0 ] === 'K' ,
2024-02-12 16:01:43 +00:00
time : parseFloat ( p . pts_time ) ,
2022-01-15 16:02:36 +00:00
createdAt : new Date ( ) ,
} ) )
2024-02-20 14:48:40 +00:00
. filter ( ( p ) = > ! Number . isNaN ( p . time ) ) ;
2020-02-28 10:45:33 +00:00
return sortBy ( packetsFiltered , 'time' ) ;
2020-02-22 15:44:21 +00:00
}
2022-12-29 10:22:24 +00:00
export async function readFramesAroundTime ( { filePath , streamIndex , aroundTime , window } ) {
if ( aroundTime == null ) throw new Error ( 'aroundTime was nullish' ) ;
const { from , to } = getIntervalAroundTime ( aroundTime , window ) ;
return readFrames ( { filePath , from , to , streamIndex } ) ;
}
2023-02-05 11:08:28 +00:00
export async function readKeyframesAroundTime ( { filePath , streamIndex , aroundTime , window } ) {
const frames = await readFramesAroundTime ( { filePath , aroundTime , streamIndex , window } ) ;
return frames . filter ( ( frame ) = > frame . keyframe ) ;
}
2024-02-14 13:12:16 +00:00
export const findKeyframeAtExactTime = ( keyframes : Keyframe [ ] , time : number ) = > keyframes . find ( ( keyframe ) = > Math . abs ( keyframe . time - time ) < 0.000001 ) ;
export const findNextKeyframe = ( keyframes : Keyframe [ ] , time : number ) = > keyframes . find ( ( keyframe ) = > keyframe . time >= time ) ; // (assume they are already sorted)
const findPreviousKeyframe = ( keyframes : Keyframe [ ] , time : number ) = > keyframes . findLast ( ( keyframe ) = > keyframe . time <= time ) ;
const findNearestKeyframe = ( keyframes : Keyframe [ ] , time : number ) = > minBy ( keyframes , ( keyframe ) = > Math . abs ( keyframe . time - time ) ) ;
export type FindKeyframeMode = 'nearest' | 'before' | 'after' ;
2023-02-05 11:08:28 +00:00
2024-02-14 13:12:16 +00:00
function findKeyframe ( keyframes : Keyframe [ ] , time : number , mode : FindKeyframeMode ) {
2023-02-05 11:08:28 +00:00
switch ( mode ) {
2024-02-20 14:48:40 +00:00
case 'nearest' : {
return findNearestKeyframe ( keyframes , time ) ;
}
case 'before' : {
return findPreviousKeyframe ( keyframes , time ) ;
}
case 'after' : {
return findNextKeyframe ( keyframes , time ) ;
}
default : {
return undefined ;
}
2023-02-05 11:08:28 +00:00
}
}
2024-02-14 13:12:16 +00:00
export async function findKeyframeNearTime ( { filePath , streamIndex , time , mode } : { filePath : string , streamIndex : number , time : number , mode : FindKeyframeMode } ) {
2023-02-05 11:08:28 +00:00
let keyframes = await readKeyframesAroundTime ( { filePath , streamIndex , aroundTime : time , window : 10 } ) ;
let nearByKeyframe = findKeyframe ( keyframes , time , mode ) ;
if ( ! nearByKeyframe ) {
keyframes = await readKeyframesAroundTime ( { filePath , streamIndex , aroundTime : time , window : 60 } ) ;
nearByKeyframe = findKeyframe ( keyframes , time , mode ) ;
}
if ( ! nearByKeyframe ) return undefined ;
return nearByKeyframe . time ;
}
// todo this is not in use
2020-02-22 15:44:21 +00:00
// https://stackoverflow.com/questions/14005110/how-to-split-a-video-using-ffmpeg-so-that-each-chunk-starts-with-a-key-frame
// http://kicherer.org/joomla/index.php/de/blog/42-avcut-frame-accurate-video-cutting-with-only-small-quality-loss
2020-03-04 10:41:40 +00:00
export function getSafeCutTime ( frames , cutTime , nextMode ) {
2020-02-22 15:44:21 +00:00
const sigma = 0.01 ;
const isCloseTo = ( time1 , time2 ) = > Math . abs ( time1 - time2 ) < sigma ;
let index ;
2020-03-19 15:37:38 +00:00
if ( frames . length < 2 ) throw new Error ( i18n . t ( 'Less than 2 frames found' ) ) ;
2020-02-22 15:44:21 +00:00
if ( nextMode ) {
2024-02-20 14:48:40 +00:00
index = frames . findIndex ( ( f ) = > f . keyframe && f . time >= cutTime - sigma ) ;
2020-03-19 15:37:38 +00:00
if ( index === - 1 ) throw new Error ( i18n . t ( 'Failed to find next keyframe' ) ) ;
if ( index >= frames . length - 1 ) throw new Error ( i18n . t ( 'We are on the last frame' ) ) ;
2020-02-22 15:44:21 +00:00
const { time } = frames [ index ] ;
if ( isCloseTo ( time , cutTime ) ) {
return undefined ; // Already on keyframe, no need to modify cut time
}
return time ;
}
const findReverseIndex = ( arr , cb ) = > {
2024-02-20 14:48:40 +00:00
// eslint-disable-next-line unicorn/no-array-callback-reference
2020-02-22 15:44:21 +00:00
const ret = [ . . . arr ] . reverse ( ) . findIndex ( cb ) ;
if ( ret === - 1 ) return - 1 ;
return arr . length - 1 - ret ;
} ;
2024-02-20 14:48:40 +00:00
index = findReverseIndex ( frames , ( f ) = > f . time <= cutTime + sigma ) ;
2020-03-19 15:37:38 +00:00
if ( index === - 1 ) throw new Error ( i18n . t ( 'Failed to find any prev frame' ) ) ;
if ( index === 0 ) throw new Error ( i18n . t ( 'We are on the first frame' ) ) ;
2020-02-22 15:44:21 +00:00
if ( index === frames . length - 1 ) {
// Last frame of video, no need to modify cut time
return undefined ;
}
if ( frames [ index + 1 ] . keyframe ) {
// Already on frame before keyframe, no need to modify cut time
return undefined ;
}
// We are not on a frame before keyframe, look for preceding keyframe instead
2024-02-20 14:48:40 +00:00
index = findReverseIndex ( frames , ( f ) = > f . keyframe && f . time <= cutTime + sigma ) ;
2020-03-19 15:37:38 +00:00
if ( index === - 1 ) throw new Error ( i18n . t ( 'Failed to find any prev keyframe' ) ) ;
if ( index === 0 ) throw new Error ( i18n . t ( 'We are on the first keyframe' ) ) ;
2020-02-22 15:44:21 +00:00
// Use frame before the found keyframe
return frames [ index - 1 ] . time ;
}
2020-03-04 10:41:40 +00:00
export function findNearestKeyFrameTime ( { frames , time , direction , fps } ) {
2020-02-29 02:18:43 +00:00
const sigma = fps ? ( 1 / fps ) : 0.1 ;
2024-02-20 14:48:40 +00:00
const keyframes = frames . filter ( ( f ) = > f . keyframe && ( direction > 0 ? f . time > time + sigma : f.time < time - sigma ) ) ;
2020-02-29 02:18:43 +00:00
if ( keyframes . length === 0 ) return undefined ;
2024-02-20 14:48:40 +00:00
const nearestKeyFrame = sortBy ( keyframes , ( keyframe ) = > ( direction > 0 ? keyframe . time - time : time - keyframe . time ) ) [ 0 ] ;
2022-02-18 09:42:35 +00:00
if ( ! nearestKeyFrame ) return undefined ;
return nearestKeyFrame . time ;
2020-02-29 02:18:43 +00:00
}
2022-02-13 05:01:13 +00:00
export async function tryMapChaptersToEdl ( chapters ) {
2020-11-19 21:51:17 +00:00
try {
2022-02-13 05:01:13 +00:00
return chapters . map ( ( chapter ) = > {
2020-12-08 22:00:47 +00:00
const start = parseFloat ( chapter . start_time ) ;
const end = parseFloat ( chapter . end_time ) ;
2020-11-19 21:51:17 +00:00
if ( Number . isNaN ( start ) || Number . isNaN ( end ) ) return undefined ;
const name = chapter . tags && typeof chapter . tags . title === 'string' ? chapter.tags.title : undefined ;
return {
start ,
end ,
name ,
} ;
2024-02-20 14:48:40 +00:00
} ) . filter ( Boolean ) ;
2020-11-19 21:51:17 +00:00
} catch ( err ) {
console . error ( 'Failed to read chapters from file' , err ) ;
return [ ] ;
}
}
2024-03-15 13:45:33 +00:00
export async function createChaptersFromSegments ( { segmentPaths , chapterNames } : { segmentPaths : string [ ] , chapterNames? : string [ ] } ) {
2022-03-04 17:16:02 +00:00
if ( ! chapterNames ) return undefined ;
try {
const durations = await pMap ( segmentPaths , ( segmentPath ) = > getDuration ( segmentPath ) , { concurrency : 3 } ) ;
let timeAt = 0 ;
return durations . map ( ( duration , i ) = > {
const ret = { start : timeAt , end : timeAt + duration , name : chapterNames [ i ] } ;
timeAt += duration ;
return ret ;
} ) ;
} catch ( err ) {
console . error ( 'Failed to create chapters from segments' , err ) ;
return undefined ;
2020-12-08 22:59:41 +00:00
}
}
2017-03-19 16:25:25 +00:00
/ * *
* ffmpeg only supports encoding certain formats , and some of the detected input
2023-01-03 09:45:58 +00:00
* formats are not the same as the muxer name used for encoding .
* Therefore we have to map between detected input format and encode format
2017-03-19 16:25:25 +00:00
* See also ffmpeg - formats
* /
2022-02-24 09:34:51 +00:00
function mapDefaultFormat ( { streams , requestedFormat } ) {
if ( requestedFormat === 'mp4' ) {
2023-01-03 09:45:58 +00:00
// Only MOV supports these codecs, so default to MOV instead https://github.com/mifi/lossless-cut/issues/948
2024-02-20 14:48:40 +00:00
// eslint-disable-next-line unicorn/no-lonely-if
2022-02-24 09:58:16 +00:00
if ( streams . some ( ( stream ) = > pcmAudioCodecs . includes ( stream . codec_name ) ) ) {
2022-02-24 09:34:51 +00:00
return 'mov' ;
}
}
2023-01-03 09:45:58 +00:00
// see sample.aac
if ( requestedFormat === 'aac' ) return 'adts' ;
return requestedFormat ;
}
async function determineOutputFormat ( ffprobeFormatsStr , filePath ) {
2024-02-20 14:48:40 +00:00
const ffprobeFormats = ( ffprobeFormatsStr || '' ) . split ( ',' ) . map ( ( str ) = > str . trim ( ) ) . filter ( Boolean ) ;
2023-01-03 09:45:58 +00:00
if ( ffprobeFormats . length === 0 ) {
console . warn ( 'ffprobe returned unknown formats' , ffprobeFormatsStr ) ;
return undefined ;
}
const [ firstFfprobeFormat ] = ffprobeFormats ;
if ( ffprobeFormats . length === 1 ) return firstFfprobeFormat ;
// If ffprobe returned a list of formats, try to be a bit smarter about it.
// This should only be the case for matroska and mov. See `ffmpeg -formats`
if ( ! [ 'matroska' , 'mov' ] . includes ( firstFfprobeFormat ) ) {
console . warn ( 'Unknown ffprobe format list' , ffprobeFormats ) ;
return firstFfprobeFormat ;
}
const fileTypeResponse = await FileType . fromFile ( filePath ) ;
if ( fileTypeResponse == null ) {
console . warn ( 'file-type failed to detect format, defaulting to first' , ffprobeFormats ) ;
return firstFfprobeFormat ;
}
// https://github.com/sindresorhus/file-type/blob/main/core.js
// https://www.ftyps.com/
// https://exiftool.org/TagNames/QuickTime.html
switch ( fileTypeResponse . mime ) {
2024-02-20 14:48:40 +00:00
case 'video/x-matroska' : {
return 'matroska' ;
}
case 'video/webm' : {
return 'webm' ;
}
case 'video/quicktime' : {
return 'mov' ;
}
case 'video/3gpp2' : {
return '3g2' ;
}
case 'video/3gpp' : {
return '3gp' ;
}
2023-01-03 09:45:58 +00:00
2017-03-19 16:25:25 +00:00
// These two cmds produce identical output, so we assume that encoding "ipod" means encoding m4a
// ffmpeg -i example.aac -c copy OutputFile2.m4a
// ffmpeg -i example.aac -c copy -f ipod OutputFile.m4a
// See also https://github.com/mifi/lossless-cut/issues/28
2023-01-03 09:45:58 +00:00
case 'audio/x-m4a' :
2024-02-20 14:48:40 +00:00
case 'audio/mp4' : {
2023-01-03 09:45:58 +00:00
return 'ipod' ;
2024-02-20 14:48:40 +00:00
}
2023-01-03 09:45:58 +00:00
case 'image/avif' :
case 'image/heif' :
case 'image/heif-sequence' :
case 'image/heic' :
case 'image/heic-sequence' :
case 'video/x-m4v' :
case 'video/mp4' :
2024-02-20 14:48:40 +00:00
case 'image/x-canon-cr3' : {
2023-01-03 09:45:58 +00:00
return 'mp4' ;
2024-02-20 14:48:40 +00:00
}
2023-01-03 09:45:58 +00:00
default : {
console . warn ( 'file-type returned unknown format' , ffprobeFormats , fileTypeResponse . mime ) ;
return firstFfprobeFormat ;
}
2017-03-19 16:25:25 +00:00
}
}
2022-02-24 09:34:51 +00:00
export async function getSmarterOutFormat ( { filePath , fileMeta : { format , streams } } ) {
const formatsStr = format . format_name ;
2023-01-03 09:45:58 +00:00
const assumedFormat = await determineOutputFormat ( formatsStr , filePath ) ;
2021-08-25 08:33:04 +00:00
2022-02-24 09:34:51 +00:00
return mapDefaultFormat ( { streams , requestedFormat : assumedFormat } ) ;
2019-01-28 02:23:05 +00:00
}
2021-08-28 07:21:09 +00:00
export async function readFileMeta ( filePath ) {
try {
2022-02-13 05:01:13 +00:00
const { stdout } = await runFfprobe ( [
'-of' , 'json' , '-show_chapters' , '-show_format' , '-show_entries' , 'stream' , '-i' , filePath , '-hide_banner' ,
2021-08-28 14:47:55 +00:00
] ) ;
2022-02-13 05:01:13 +00:00
2024-03-20 08:02:34 +00:00
let parsedJson : FFprobeProbeResult ;
2022-10-19 15:39:27 +00:00
try {
// https://github.com/mifi/lossless-cut/issues/1342
parsedJson = JSON . parse ( stdout ) ;
2024-02-20 14:48:40 +00:00
} catch {
2022-10-19 15:39:27 +00:00
console . log ( 'ffprobe stdout' , stdout ) ;
throw new Error ( 'ffprobe returned malformed data' ) ;
}
2024-03-20 08:02:34 +00:00
const { streams = [ ] , format , chapters = [ ] } = parsedJson ;
invariant ( format != null ) ;
2022-02-23 14:13:07 +00:00
return { format , streams , chapters } ;
2021-08-28 07:21:09 +00:00
} catch ( err ) {
// Windows will throw error with code ENOENT if format detection fails.
2023-02-03 09:27:11 +00:00
if ( isExecaFailure ( err ) ) {
2024-02-12 16:01:43 +00:00
throw Object . assign ( new Error ( ` Unsupported file: ${ err . message } ` ) , { code : 'LLC_FFPROBE_UNSUPPORTED_FILE' } ) ;
2021-08-28 07:21:09 +00:00
}
throw err ;
}
}
2023-02-04 05:51:19 +00:00
function getPreferredCodecFormat ( stream ) {
2019-01-28 11:49:57 +00:00
const map = {
2023-02-04 05:51:19 +00:00
mp3 : { format : 'mp3' , ext : 'mp3' } ,
opus : { format : 'opus' , ext : 'opus' } ,
vorbis : { format : 'ogg' , ext : 'ogg' } ,
h264 : { format : 'mp4' , ext : 'mp4' } ,
hevc : { format : 'mp4' , ext : 'mp4' } ,
eac3 : { format : 'eac3' , ext : 'eac3' } ,
2019-01-28 11:49:57 +00:00
2023-02-04 05:51:19 +00:00
subrip : { format : 'srt' , ext : 'srt' } ,
2023-03-04 12:19:13 +00:00
mov_text : { format : 'mp4' , ext : 'mp4' } ,
2019-01-28 11:49:57 +00:00
2023-02-04 05:51:19 +00:00
m4a : { format : 'ipod' , ext : 'm4a' } ,
aac : { format : 'adts' , ext : 'aac' } ,
jpeg : { format : 'image2' , ext : 'jpeg' } ,
png : { format : 'image2' , ext : 'png' } ,
2019-01-28 11:49:57 +00:00
// TODO add more
// TODO allow user to change?
} ;
2023-02-04 05:51:19 +00:00
const match = map [ stream . codec_name ] ;
if ( match ) return match ;
// default fallbacks:
if ( stream . codec_type === 'video' ) return { ext : 'mkv' , format : 'matroska' } ;
if ( stream . codec_type === 'audio' ) return { ext : 'mka' , format : 'matroska' } ;
if ( stream . codec_type === 'subtitle' ) return { ext : 'mks' , format : 'matroska' } ;
if ( stream . codec_type === 'data' ) return { ext : 'bin' , format : 'data' } ; // https://superuser.com/questions/1243257/save-data-stream
2020-03-26 13:54:00 +00:00
2019-01-28 11:49:57 +00:00
return undefined ;
}
2024-02-12 16:01:43 +00:00
async function extractNonAttachmentStreams ( { customOutDir , filePath , streams , enableOverwriteOutput } : {
2024-03-20 08:02:34 +00:00
customOutDir? : string , filePath : string , streams : FFprobeStream [ ] , enableOverwriteOutput? : boolean ,
2024-02-12 16:01:43 +00:00
} ) {
2022-11-22 15:55:14 +00:00
if ( streams . length === 0 ) return [ ] ;
2021-08-28 07:21:52 +00:00
2023-03-04 12:19:13 +00:00
const outStreams = streams . map ( ( s ) = > ( {
index : s.index ,
codec : s.codec_name || s . codec_tag_string || s . codec_type ,
type : s . codec_type ,
format : getPreferredCodecFormat ( s ) ,
} ) )
. filter ( ( { format , index } ) = > format != null && index != null ) ;
// console.log(outStreams);
2021-08-28 07:21:52 +00:00
2024-02-12 16:01:43 +00:00
let streamArgs : string [ ] = [ ] ;
2023-03-04 12:19:13 +00:00
const outPaths = await pMap ( outStreams , async ( { index , codec , type , format : { format , ext } } ) = > {
2022-09-11 20:53:00 +00:00
const outPath = getSuffixedOutPath ( { customOutDir , filePath , nameSuffix : ` stream- ${ index } - ${ type } - ${ codec } . ${ ext } ` } ) ;
2022-08-12 18:54:33 +00:00
if ( ! enableOverwriteOutput && await pathExists ( outPath ) ) throw new RefuseOverwriteError ( ) ;
streamArgs = [
. . . streamArgs ,
'-map' , ` 0: ${ index } ` , '-c' , 'copy' , '-f' , format , '-y' , outPath ,
] ;
2022-11-22 15:55:14 +00:00
return outPath ;
2022-08-12 18:54:33 +00:00
} , { concurrency : 1 } ) ;
2019-01-28 02:23:05 +00:00
const ffmpegArgs = [
2020-03-23 12:39:41 +00:00
'-hide_banner' ,
2019-01-28 02:23:05 +00:00
'-i' , filePath ,
. . . streamArgs ,
] ;
2019-11-03 16:19:36 +00:00
const { stdout } = await runFfmpeg ( ffmpegArgs ) ;
console . log ( stdout ) ;
2022-11-22 15:55:14 +00:00
return outPaths ;
2019-11-03 16:19:36 +00:00
}
2024-02-12 16:01:43 +00:00
async function extractAttachmentStreams ( { customOutDir , filePath , streams , enableOverwriteOutput } : {
2024-03-20 08:02:34 +00:00
customOutDir? : string , filePath : string , streams : FFprobeStream [ ] , enableOverwriteOutput? : boolean ,
2024-02-12 16:01:43 +00:00
} ) {
2022-11-22 15:55:14 +00:00
if ( streams . length === 0 ) return [ ] ;
2021-08-27 16:10:15 +00:00
console . log ( 'Extracting' , streams . length , 'attachment streams' ) ;
2024-02-12 16:01:43 +00:00
let streamArgs : string [ ] = [ ] ;
2022-11-22 15:55:14 +00:00
const outPaths = await pMap ( streams , async ( { index , codec_name : codec , codec_type : type } ) = > {
2021-08-27 16:10:15 +00:00
const ext = codec || 'bin' ;
2022-09-11 20:53:00 +00:00
const outPath = getSuffixedOutPath ( { customOutDir , filePath , nameSuffix : ` stream- ${ index } - ${ type } - ${ codec } . ${ ext } ` } ) ;
2024-03-02 17:12:35 +00:00
if ( outPath == null ) throw new Error ( ) ;
2022-08-12 18:54:33 +00:00
if ( ! enableOverwriteOutput && await pathExists ( outPath ) ) throw new RefuseOverwriteError ( ) ;
streamArgs = [
. . . streamArgs ,
` -dump_attachment: ${ index } ` , outPath ,
2021-08-27 16:10:15 +00:00
] ;
2022-11-22 15:55:14 +00:00
return outPath ;
2022-08-12 18:54:33 +00:00
} , { concurrency : 1 } ) ;
2021-08-27 16:10:15 +00:00
const ffmpegArgs = [
'-y' ,
'-hide_banner' ,
'-loglevel' , 'error' ,
. . . streamArgs ,
'-i' , filePath ,
] ;
try {
const { stdout } = await runFfmpeg ( ffmpegArgs ) ;
console . log ( stdout ) ;
} catch ( err ) {
// Unfortunately ffmpeg will exit with code 1 even though it's a success
// Note: This is kind of hacky:
2024-02-12 16:01:43 +00:00
if ( err instanceof Error && 'exitCode' in err && 'stderr' in err && err . exitCode === 1 && typeof err . stderr === 'string' && err . stderr . includes ( 'At least one output file must be specified' ) ) return outPaths ;
2021-08-27 16:10:15 +00:00
throw err ;
}
2022-11-22 15:55:14 +00:00
return outPaths ;
2021-08-27 16:10:15 +00:00
}
// https://stackoverflow.com/questions/32922226/extract-every-audio-and-subtitles-from-a-video-with-ffmpeg
2022-08-12 18:54:33 +00:00
export async function extractStreams ( { filePath , customOutDir , streams , enableOverwriteOutput } ) {
2021-08-27 16:10:15 +00:00
const attachmentStreams = streams . filter ( ( s ) = > s . codec_type === 'attachment' ) ;
const nonAttachmentStreams = streams . filter ( ( s ) = > s . codec_type !== 'attachment' ) ;
// TODO progress
// Attachment streams are handled differently from normal streams
2022-11-22 15:55:14 +00:00
return [
2023-03-04 12:19:13 +00:00
. . . ( await extractNonAttachmentStreams ( { customOutDir , filePath , streams : nonAttachmentStreams , enableOverwriteOutput } ) ) ,
2022-11-22 15:55:14 +00:00
. . . ( await extractAttachmentStreams ( { customOutDir , filePath , streams : attachmentStreams , enableOverwriteOutput } ) ) ,
] ;
2021-08-27 16:10:15 +00:00
}
2020-02-27 14:59:37 +00:00
async function renderThumbnail ( filePath , timestamp ) {
const args = [
'-ss' , timestamp ,
'-i' , filePath ,
'-vf' , 'scale=-2:200' ,
'-f' , 'image2' ,
'-vframes' , '1' ,
'-q:v' , '10' ,
'-' ,
] ;
2023-01-06 12:10:57 +00:00
const { stdout } = await runFfmpeg ( args , { encoding : null } ) ;
2020-02-27 14:59:37 +00:00
const blob = new Blob ( [ stdout ] , { type : 'image/jpeg' } ) ;
return URL . createObjectURL ( blob ) ;
}
2021-08-28 07:21:09 +00:00
export async function extractSubtitleTrack ( filePath , streamId ) {
const args = [
'-hide_banner' ,
'-i' , filePath ,
'-map' , ` 0: ${ streamId } ` ,
'-f' , 'webvtt' ,
'-' ,
] ;
2023-01-06 12:10:57 +00:00
const { stdout } = await runFfmpeg ( args , { encoding : null } ) ;
2021-08-28 07:21:09 +00:00
const blob = new Blob ( [ stdout ] , { type : 'text/vtt' } ) ;
return URL . createObjectURL ( blob ) ;
}
2020-03-04 10:41:40 +00:00
export async function renderThumbnails ( { filePath , from , duration , onThumbnail } ) {
2020-02-29 06:41:57 +00:00
// Time first render to determine how many to render
2024-02-20 14:48:40 +00:00
const startTime = Date . now ( ) / 1000 ;
2020-02-29 06:41:57 +00:00
let url = await renderThumbnail ( filePath , from ) ;
2024-02-20 14:48:40 +00:00
const endTime = Date . now ( ) / 1000 ;
2020-02-29 06:41:57 +00:00
onThumbnail ( { time : from , url } ) ;
// Aim for max 3 sec to render all
const numThumbs = Math . floor ( Math . min ( Math . max ( 3 / ( endTime - startTime ) , 3 ) , 10 ) ) ;
2020-03-05 10:48:51 +00:00
// console.log(numThumbs);
2020-02-29 06:41:57 +00:00
2024-02-20 14:48:40 +00:00
const thumbTimes = Array . from ( { length : numThumbs - 1 } ) . fill ( undefined ) . map ( ( _unused , i ) = > ( from + ( ( duration * ( i + 1 ) ) / ( numThumbs ) ) ) ) ;
2020-03-05 10:48:51 +00:00
// console.log(thumbTimes);
2020-02-27 14:59:37 +00:00
await pMap ( thumbTimes , async ( time ) = > {
2020-02-29 06:41:57 +00:00
url = await renderThumbnail ( filePath , time ) ;
2020-02-27 14:59:37 +00:00
onThumbnail ( { time , url } ) ;
} , { concurrency : 2 } ) ;
}
2020-03-30 10:49:15 +00:00
export async function extractWaveform ( { filePath , outPath } ) {
const numSegs = 10 ;
const duration = 60 * 60 ;
const maxLen = 0.1 ;
2024-02-20 14:48:40 +00:00
const segments = Array . from ( { length : numSegs } ) . fill ( undefined ) . map ( ( _unused , i ) = > [ i * ( duration / numSegs ) , Math . min ( duration / numSegs , maxLen ) ] as const ) ;
2020-03-30 10:49:15 +00:00
// https://superuser.com/questions/681885/how-can-i-remove-multiple-segments-from-a-video-using-ffmpeg
let filter = segments . map ( ( [ from , len ] , i ) = > ` [0:a]atrim=start= ${ from } :end= ${ from + len } ,asetpts=PTS-STARTPTS[a ${ i } ] ` ) . join ( ';' ) ;
filter += ';' ;
2024-02-12 16:01:43 +00:00
filter += segments . map ( ( _arr , i ) = > ` [a ${ i } ] ` ) . join ( '' ) ;
2020-03-30 10:49:15 +00:00
filter += ` concat=n= ${ segments . length } :v=0:a=1[out] ` ;
console . time ( 'ffmpeg' ) ;
await runFfmpeg ( [
'-i' ,
filePath ,
'-filter_complex' ,
filter ,
'-map' ,
'[out]' ,
'-f' , 'wav' ,
'-y' ,
outPath ,
] ) ;
console . timeEnd ( 'ffmpeg' ) ;
}
2020-04-27 15:33:35 +00:00
export function isIphoneHevc ( format , streams ) {
if ( ! streams . some ( ( s ) = > s . codec_name === 'hevc' ) ) return false ;
const makeTag = format . tags && format . tags [ 'com.apple.quicktime.make' ] ;
const modelTag = format . tags && format . tags [ 'com.apple.quicktime.model' ] ;
return ( makeTag === 'Apple' && modelTag . startsWith ( 'iPhone' ) ) ;
}
2022-12-31 08:20:04 +00:00
export function isProblematicAvc1 ( outFormat , streams ) {
2023-01-03 10:12:38 +00:00
// it seems like this only happens for files that are also 4.2.2 10bit (yuv422p10le)
// https://trac.ffmpeg.org/wiki/Chroma%20Subsampling
2023-01-03 09:44:03 +00:00
return isMov ( outFormat ) && streams . some ( ( s ) = > s . codec_name === 'h264' && s . codec_tag === '0x31637661' && s . codec_tag_string === 'avc1' && s . pix_fmt === 'yuv422p10le' ) ;
2022-12-31 08:20:04 +00:00
}
2023-12-02 16:00:09 +00:00
function parseFfprobeFps ( stream ) {
2024-02-20 14:48:40 +00:00
const match = typeof stream . avg_frame_rate === 'string' && stream . avg_frame_rate . match ( /^(\d+)\/(\d+)$/ ) ;
2023-12-02 16:00:09 +00:00
if ( ! match ) return undefined ;
const num = parseInt ( match [ 1 ] , 10 ) ;
const den = parseInt ( match [ 2 ] , 10 ) ;
if ( den > 0 ) return num / den ;
return undefined ;
}
export function getStreamFps ( stream ) {
if ( stream . codec_type === 'video' ) {
const fps = parseFfprobeFps ( stream ) ;
return fps ;
}
if ( stream . codec_type === 'audio' ) {
2024-02-20 14:48:40 +00:00
// eslint-disable-next-line unicorn/no-lonely-if
2023-12-02 16:00:09 +00:00
if ( typeof stream . sample_rate === 'string' ) {
const sampleRate = parseInt ( stream . sample_rate , 10 ) ;
if ( ! Number . isNaN ( sampleRate ) && sampleRate > 0 ) {
if ( stream . codec_name === 'mp3' ) {
// https://github.com/mifi/lossless-cut/issues/1754#issuecomment-1774107468
const frameSize = 1152 ;
return sampleRate / frameSize ;
}
if ( stream . codec_name === 'aac' ) {
// https://stackoverflow.com/questions/59173435/aac-packet-size
const frameSize = 1024 ;
return sampleRate / frameSize ;
}
}
}
2020-02-16 04:33:38 +00:00
}
return undefined ;
}
2020-04-26 15:22:44 +00:00
2020-11-22 22:53:04 +00:00
2020-11-27 21:43:06 +00:00
function parseTimecode ( str , frameRate ) {
// console.log(str, frameRate);
const t = Timecode ( str , frameRate ? parseFloat ( frameRate . toFixed ( 3 ) ) : undefined ) ;
if ( ! t ) return undefined ;
const seconds = ( ( t . hours * 60 ) + t . minutes ) * 60 + t . seconds + ( t . frames / t . frameRate ) ;
return Number . isFinite ( seconds ) ? seconds : undefined ;
}
export function getTimecodeFromStreams ( streams ) {
console . log ( 'Trying to load timecode' ) ;
let foundTimecode ;
streams . find ( ( stream ) = > {
try {
if ( stream . tags && stream . tags . timecode ) {
const fps = getStreamFps ( stream ) ;
foundTimecode = parseTimecode ( stream . tags . timecode , fps ) ;
console . log ( 'Loaded timecode' , stream . tags . timecode , 'from stream' , stream . index ) ;
return true ;
}
return undefined ;
2024-02-20 14:48:40 +00:00
} catch {
2020-11-27 21:43:06 +00:00
// console.warn('Failed to parse timecode from file streams', err);
return undefined ;
}
} ) ;
return foundTimecode ;
}
2021-11-13 11:27:02 +00:00
2023-01-02 09:17:41 +00:00
export async function runFfmpegStartupCheck() {
// will throw if exit code != 0
2021-11-13 11:27:02 +00:00
await runFfmpeg ( [ '-hide_banner' , '-f' , 'lavfi' , '-i' , 'nullsrc=s=256x256:d=1' , '-f' , 'null' , '-' ] ) ;
}
2022-02-27 15:39:31 +00:00
2022-02-28 07:07:51 +00:00
// https://superuser.com/questions/543589/information-about-ffmpeg-command-line-options
2024-02-12 16:01:43 +00:00
export const getExperimentalArgs = ( ffmpegExperimental : boolean ) = > ( ffmpegExperimental ? [ '-strict' , 'experimental' ] : [ ] ) ;
2022-02-28 07:07:51 +00:00
2024-03-15 13:45:33 +00:00
export const getVideoTimescaleArgs = ( videoTimebase : number | undefined ) = > ( videoTimebase != null ? [ '-video_track_timescale' , String ( videoTimebase ) ] : [ ] ) ;
2022-02-28 07:07:51 +00:00
// inspired by https://gist.github.com/fernandoherreradelasheras/5eca67f4200f1a7cc8281747da08496e
2024-02-12 16:01:43 +00:00
export async function cutEncodeSmartPart ( { filePath , cutFrom , cutTo , outPath , outFormat , videoCodec , videoBitrate , videoTimebase , allFilesMeta , copyFileStreams , videoStreamIndex , ffmpegExperimental } : {
filePath : string , cutFrom : number , cutTo : number , outPath : string , outFormat : string , videoCodec : string , videoBitrate : number , videoTimebase : number , allFilesMeta , copyFileStreams , videoStreamIndex : number , ffmpegExperimental : boolean ,
} ) {
function getVideoArgs ( { streamIndex , outputIndex } : { streamIndex : number , outputIndex : number } ) {
2022-02-28 07:07:51 +00:00
if ( streamIndex !== videoStreamIndex ) return undefined ;
2023-02-08 08:16:07 +00:00
const args = [
2022-02-28 07:07:51 +00:00
` -c: ${ outputIndex } ` , videoCodec ,
2024-02-12 16:01:43 +00:00
` -b: ${ outputIndex } ` , String ( videoBitrate ) ,
2022-02-28 07:07:51 +00:00
] ;
2023-02-08 08:16:07 +00:00
// seems like ffmpeg handles this itself well when encoding same source file
// if (videoLevel != null) args.push(`-level:${outputIndex}`, videoLevel);
// if (videoProfile != null) args.push(`-profile:${outputIndex}`, videoProfile);
return args ;
2022-02-28 07:07:51 +00:00
}
const mapStreamsArgs = getMapStreamsArgs ( {
allFilesMeta ,
copyFileStreams ,
outFormat ,
getVideoArgs ,
} ) ;
const ffmpegArgs = [
'-hide_banner' ,
// No progress if we set loglevel warning :(
// '-loglevel', 'warning',
2022-05-24 06:12:21 +00:00
'-ss' , cutFrom . toFixed ( 5 ) , // if we don't -ss before -i, seeking will be slow for long files, see https://github.com/mifi/lossless-cut/issues/126#issuecomment-1135451043
2022-02-28 07:07:51 +00:00
'-i' , filePath ,
2022-05-24 06:12:21 +00:00
'-ss' , '0' , // If we don't do this, the output seems to start with an empty black after merging with the encoded part
2022-02-28 07:07:51 +00:00
'-t' , ( cutTo - cutFrom ) . toFixed ( 5 ) ,
. . . mapStreamsArgs ,
// See https://github.com/mifi/lossless-cut/issues/170
'-ignore_unknown' ,
. . . getVideoTimescaleArgs ( videoTimebase ) ,
. . . getExperimentalArgs ( ffmpegExperimental ) ,
'-f' , outFormat , '-y' , outPath ,
] ;
2023-01-06 12:10:57 +00:00
await runFfmpeg ( ffmpegArgs ) ;
2022-02-28 07:07:51 +00:00
}