import pMap from 'p-map' ;
import sortBy from 'lodash/sortBy' ;
import moment from 'moment' ;
import i18n from 'i18next' ;
import Timecode from 'smpte-timecode' ;
import { pcmAudioCodecs , getMapStreamsArgs } from './util/streams' ;
import { getSuffixedOutPath , isDurationValid , getExtensionForFormat , isWindows , isMac , platform , arch } from './util' ;
import isDev from './isDev' ;
const execa = window . require ( 'execa' ) ;
const { join } = window . require ( 'path' ) ;
const FileType = window . require ( 'file-type' ) ;
const readline = window . require ( 'readline' ) ;
const { pathExists } = window . require ( 'fs-extra' ) ;
let customFfPath ;
export class RefuseOverwriteError extends Error { }
// Note that this does not work on MAS because of sandbox restrictions
export function setCustomFfPath ( path ) {
customFfPath = path ;
}
export function getFfCommandLine ( cmd , args ) {
const mapArg = arg => ( /[^0-9a-zA-Z-_]/ . test ( arg ) ? ` ' ${ arg } ' ` : arg ) ;
return ` ${ cmd } ${ args . map ( mapArg ) . join ( ' ' ) } ` ;
}
function getFfPath ( cmd ) {
const exeName = isWindows ? ` ${ cmd } .exe ` : cmd ;
if ( customFfPath ) return join ( customFfPath , exeName ) ;
if ( isDev ) return join ( 'ffmpeg' , ` ${ platform } - ${ arch } ` , exeName ) ;
return join ( window . process . resourcesPath , exeName ) ;
}
export const getFfmpegPath = ( ) => getFfPath ( 'ffmpeg' ) ;
export const getFfprobePath = ( ) => getFfPath ( 'ffprobe' ) ;
export async function runFfprobe ( args , { timeout = isDev ? 10000 : 30000 } = { } ) {
const ffprobePath = getFfprobePath ( ) ;
console . log ( getFfCommandLine ( 'ffprobe' , args ) ) ;
const ps = execa ( ffprobePath , args ) ;
const timer = setTimeout ( ( ) => {
console . warn ( 'killing timed out ffprobe' ) ;
ps . kill ( ) ;
} , timeout ) ;
try {
return await ps ;
} finally {
clearTimeout ( timer ) ;
}
}
export function runFfmpeg ( args ) {
const ffmpegPath = getFfmpegPath ( ) ;
console . log ( getFfCommandLine ( 'ffmpeg' , args ) ) ;
return execa ( ffmpegPath , args ) ;
}
export function handleProgress ( process , cutDuration , onProgress , customMatcher = ( ) => { } ) {
if ( ! onProgress ) return ;
onProgress ( 0 ) ;
const rl = readline . createInterface ( { input : process . stderr } ) ;
rl . on ( 'line' , ( line ) => {
// console.log('progress', line);
try {
let match = line . match ( /frame=\s*[^\s]+\s+fps=\s*[^\s]+\s+q=\s*[^\s]+\s+(?:size|Lsize)=\s*[^\s]+\s+time=\s*([^\s]+)\s+/ ) ;
// Audio only looks like this: "line size= 233422kB time=01:45:50.68 bitrate= 301.1kbits/s speed= 353x "
if ( ! match ) match = line . match ( /(?:size|Lsize)=\s*[^\s]+\s+time=\s*([^\s]+)\s+/ ) ;
if ( ! match ) {
customMatcher ( line ) ;
return ;
}
const str = match [ 1 ] ;
// console.log(str);
const progressTime = Math . max ( 0 , moment . duration ( str ) . asSeconds ( ) ) ;
// console.log(progressTime);
const progress = cutDuration ? progressTime / cutDuration : 0 ;
onProgress ( progress ) ;
} catch ( err ) {
console . log ( 'Failed to parse ffmpeg progress line' , err ) ;
}
} ) ;
}
export function isCuttingStart ( cutFrom ) {
return cutFrom > 0 ;
}
export function isCuttingEnd ( cutTo , duration ) {
if ( ! isDurationValid ( duration ) ) return true ;
return cutTo < duration ;
}
function getIntervalAroundTime ( time , window ) {
return {
from : Math . max ( time - window / 2 , 0 ) ,
to : time + window / 2 ,
} ;
}
export async function readFrames ( { filePath , aroundTime , window , streamIndex } ) {
let intervalsArgs = [ ] ;
if ( aroundTime != null ) {
const { from , to } = getIntervalAroundTime ( aroundTime , window ) ;
intervalsArgs = [ '-read_intervals' , ` ${ from } % ${ to } ` ] ;
}
const { stdout } = await runFfprobe ( [ '-v' , 'error' , ... intervalsArgs , '-show_packets' , '-select_streams' , streamIndex , '-show_entries' , 'packet=pts_time,flags' , '-of' , 'json' , filePath ] ) ;
const packetsFiltered = JSON . parse ( stdout ) . packets
. map ( p => ( {
keyframe : p . flags [ 0 ] === 'K' ,
time : parseFloat ( p . pts _time , 10 ) ,
createdAt : new Date ( ) ,
} ) )
. filter ( p => ! Number . isNaN ( p . time ) ) ;
return sortBy ( packetsFiltered , 'time' ) ;
}
// 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
export function getSafeCutTime ( frames , cutTime , nextMode ) {
const sigma = 0.01 ;
const isCloseTo = ( time1 , time2 ) => Math . abs ( time1 - time2 ) < sigma ;
let index ;
if ( frames . length < 2 ) throw new Error ( i18n . t ( 'Less than 2 frames found' ) ) ;
if ( nextMode ) {
index = frames . findIndex ( f => f . keyframe && f . time >= cutTime - sigma ) ;
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' ) ) ;
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 ) => {
const ret = [ ... arr ] . reverse ( ) . findIndex ( cb ) ;
if ( ret === - 1 ) return - 1 ;
return arr . length - 1 - ret ;
} ;
index = findReverseIndex ( frames , f => f . time <= cutTime + sigma ) ;
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' ) ) ;
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
index = findReverseIndex ( frames , f => f . keyframe && f . time <= cutTime + sigma ) ;
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' ) ) ;
// Use frame before the found keyframe
return frames [ index - 1 ] . time ;
}
export function findNearestKeyFrameTime ( { frames , time , direction , fps } ) {
const sigma = fps ? ( 1 / fps ) : 0.1 ;
const keyframes = frames . filter ( f => f . keyframe && ( direction > 0 ? f . time > time + sigma : f . time < time - sigma ) ) ;
if ( keyframes . length === 0 ) return undefined ;
const nearestKeyFrame = sortBy ( keyframes , keyframe => ( direction > 0 ? keyframe . time - time : time - keyframe . time ) ) [ 0 ] ;
if ( ! nearestKeyFrame ) return undefined ;
return nearestKeyFrame . time ;
}
export async function tryMapChaptersToEdl ( chapters ) {
try {
return chapters . map ( ( chapter ) => {
const start = parseFloat ( chapter . start _time ) ;
const end = parseFloat ( chapter . end _time ) ;
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 ,
} ;
} ) . filter ( ( it ) => it ) ;
} catch ( err ) {
console . error ( 'Failed to read chapters from file' , err ) ;
return [ ] ;
}
}
async function readFormatData ( filePath ) {
console . log ( 'readFormatData' , filePath ) ;
const { stdout } = await runFfprobe ( [
'-of' , 'json' , '-show_format' , '-i' , filePath , '-hide_banner' ,
] ) ;
return JSON . parse ( stdout ) . format ;
}
export async function getDuration ( filePath ) {
return parseFloat ( ( await readFormatData ( filePath ) ) . duration ) ;
}
export async function createChaptersFromSegments ( { segmentPaths , chapterNames } ) {
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 ;
}
}
/ * *
* ffmpeg only supports encoding certain formats , and some of the detected input
* formats are not the same as the names used for encoding .
* Therefore we have to map between detected format and encode format
* See also ffmpeg - formats
* /
function mapDefaultFormat ( { streams , requestedFormat } ) {
if ( requestedFormat === 'mp4' ) {
// Only MOV supports these, so switch to MOV https://github.com/mifi/lossless-cut/issues/948
if ( streams . some ( ( stream ) => pcmAudioCodecs . includes ( stream . codec _name ) ) ) {
return 'mov' ;
}
}
switch ( requestedFormat ) {
// 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
case 'm4a' :
case 'aac' : return 'ipod' ;
default : return requestedFormat ;
}
}
async function determineOutputFormat ( ffprobeFormats , filePath ) {
if ( ffprobeFormats . length === 1 ) return ffprobeFormats [ 0 ] ;
// if ffprobe returned a list of formats, try to be a bit smarter about it.
const fileTypeResponse = await FileType . fromFile ( filePath ) || { } ;
console . log ( ` fileType detected format ${ JSON . stringify ( fileTypeResponse ) } ` ) ;
if ( ffprobeFormats . includes ( fileTypeResponse . ext ) ) return fileTypeResponse . ext ;
return ffprobeFormats [ 0 ] || undefined ;
}
export async function getSmarterOutFormat ( { filePath , fileMeta : { format , streams } } ) {
const formatsStr = format . format _name ;
console . log ( 'formats' , formatsStr ) ;
const formats = ( formatsStr || '' ) . split ( ',' ) ;
const assumedFormat = await determineOutputFormat ( formats , filePath ) ;
return mapDefaultFormat ( { streams , requestedFormat : assumedFormat } ) ;
}
export async function readFileMeta ( filePath ) {
try {
const { stdout } = await runFfprobe ( [
'-of' , 'json' , '-show_chapters' , '-show_format' , '-show_entries' , 'stream' , '-i' , filePath , '-hide_banner' ,
] ) ;
let parsedJson ;
try {
// https://github.com/mifi/lossless-cut/issues/1342
parsedJson = JSON . parse ( stdout ) ;
} catch ( err ) {
console . log ( 'ffprobe stdout' , stdout ) ;
throw new Error ( 'ffprobe returned malformed data' ) ;
}
const { streams = [ ] , format = { } , chapters = [ ] } = parsedJson ;
return { format , streams , chapters } ;
} catch ( err ) {
// Windows will throw error with code ENOENT if format detection fails.
if ( err . exitCode === 1 || ( isWindows && err . code === 'ENOENT' ) ) {
const err2 = new Error ( ` Unsupported file: ${ err . message } ` ) ;
err2 . code = 'LLC_FFPROBE_UNSUPPORTED_FILE' ;
throw err2 ;
}
throw err ;
}
}
function getPreferredCodecFormat ( { codec _name : codec , codec _type : type } ) {
const map = {
mp3 : 'mp3' ,
opus : 'opus' ,
vorbis : 'ogg' ,
h264 : 'mp4' ,
hevc : 'mp4' ,
eac3 : 'eac3' ,
subrip : 'srt' ,
// See mapFormat
m4a : 'ipod' ,
aac : 'ipod' ,
// TODO add more
// TODO allow user to change?
} ;
const format = map [ codec ] ;
if ( format ) return { ext : getExtensionForFormat ( format ) , format } ;
if ( type === 'video' ) return { ext : 'mkv' , format : 'matroska' } ;
if ( type === 'audio' ) return { ext : 'mka' , format : 'matroska' } ;
if ( type === 'subtitle' ) return { ext : 'mks' , format : 'matroska' } ;
if ( type === 'data' ) return { ext : 'bin' , format : 'data' } ; // https://superuser.com/questions/1243257/save-data-stream
return undefined ;
}
async function extractNonAttachmentStreams ( { customOutDir , filePath , streams , enableOverwriteOutput } ) {
if ( streams . length === 0 ) return ;
console . log ( 'Extracting' , streams . length , 'normal streams' ) ;
let streamArgs = [ ] ;
await pMap ( streams , async ( { index , codec , type , format : { format , ext } } ) => {
const outPath = getSuffixedOutPath ( { customOutDir , filePath , nameSuffix : ` stream- ${ index } - ${ type } - ${ codec } . ${ ext } ` } ) ;
if ( ! enableOverwriteOutput && await pathExists ( outPath ) ) throw new RefuseOverwriteError ( ) ;
streamArgs = [
... streamArgs ,
'-map' , ` 0: ${ index } ` , '-c' , 'copy' , '-f' , format , '-y' , outPath ,
] ;
} , { concurrency : 1 } ) ;
const ffmpegArgs = [
'-hide_banner' ,
'-i' , filePath ,
... streamArgs ,
] ;
const { stdout } = await runFfmpeg ( ffmpegArgs ) ;
console . log ( stdout ) ;
}
async function extractAttachmentStreams ( { customOutDir , filePath , streams , enableOverwriteOutput } ) {
if ( streams . length === 0 ) return ;
console . log ( 'Extracting' , streams . length , 'attachment streams' ) ;
let streamArgs = [ ] ;
await pMap ( streams , async ( { index , codec _name : codec , codec _type : type } ) => {
const ext = codec || 'bin' ;
const outPath = getSuffixedOutPath ( { customOutDir , filePath , nameSuffix : ` stream- ${ index } - ${ type } - ${ codec } . ${ ext } ` } ) ;
if ( ! enableOverwriteOutput && await pathExists ( outPath ) ) throw new RefuseOverwriteError ( ) ;
streamArgs = [
... streamArgs ,
` -dump_attachment: ${ index } ` , outPath ,
] ;
} , { concurrency : 1 } ) ;
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:
if ( err . exitCode === 1 && typeof err . stderr === 'string' && err . stderr . includes ( 'At least one output file must be specified' ) ) return ;
throw err ;
}
}
// https://stackoverflow.com/questions/32922226/extract-every-audio-and-subtitles-from-a-video-with-ffmpeg
export async function extractStreams ( { filePath , customOutDir , streams , enableOverwriteOutput } ) {
const attachmentStreams = streams . filter ( ( s ) => s . codec _type === 'attachment' ) ;
const nonAttachmentStreams = streams . filter ( ( s ) => s . codec _type !== 'attachment' ) ;
const outStreams = nonAttachmentStreams . map ( ( s ) => ( {
index : s . index ,
codec : s . codec _name || s . codec _tag _string || s . codec _type ,
type : s . codec _type ,
format : getPreferredCodecFormat ( s ) ,
} ) )
. filter ( it => it && it . format && it . index != null ) ;
// console.log(outStreams);
// TODO progress
// Attachment streams are handled differently from normal streams
await extractNonAttachmentStreams ( { customOutDir , filePath , streams : outStreams , enableOverwriteOutput } ) ;
await extractAttachmentStreams ( { customOutDir , filePath , streams : attachmentStreams , enableOverwriteOutput } ) ;
}
async function renderThumbnail ( filePath , timestamp ) {
const args = [
'-ss' , timestamp ,
'-i' , filePath ,
'-vf' , 'scale=-2:200' ,
'-f' , 'image2' ,
'-vframes' , '1' ,
'-q:v' , '10' ,
'-' ,
] ;
const ffmpegPath = await getFfmpegPath ( ) ;
const { stdout } = await execa ( ffmpegPath , args , { encoding : null } ) ;
const blob = new Blob ( [ stdout ] , { type : 'image/jpeg' } ) ;
return URL . createObjectURL ( blob ) ;
}
export async function extractSubtitleTrack ( filePath , streamId ) {
const args = [
'-hide_banner' ,
'-i' , filePath ,
'-map' , ` 0: ${ streamId } ` ,
'-f' , 'webvtt' ,
'-' ,
] ;
const ffmpegPath = await getFfmpegPath ( ) ;
const { stdout } = await execa ( ffmpegPath , args , { encoding : null } ) ;
const blob = new Blob ( [ stdout ] , { type : 'text/vtt' } ) ;
return URL . createObjectURL ( blob ) ;
}
export async function renderThumbnails ( { filePath , from , duration , onThumbnail } ) {
// Time first render to determine how many to render
const startTime = new Date ( ) . getTime ( ) / 1000 ;
let url = await renderThumbnail ( filePath , from ) ;
const endTime = new Date ( ) . getTime ( ) / 1000 ;
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 ) ) ;
// console.log(numThumbs);
const thumbTimes = Array ( numThumbs - 1 ) . fill ( ) . map ( ( unused , i ) => ( from + ( ( duration * ( i + 1 ) ) / ( numThumbs ) ) ) ) ;
// console.log(thumbTimes);
await pMap ( thumbTimes , async ( time ) => {
url = await renderThumbnail ( filePath , time ) ;
onThumbnail ( { time , url } ) ;
} , { concurrency : 2 } ) ;
}
export async function renderWaveformPng ( { filePath , aroundTime , window , color } ) {
const { from , to } = getIntervalAroundTime ( aroundTime , window ) ;
const args1 = [
'-hide_banner' ,
'-i' , filePath ,
'-ss' , from ,
'-t' , to - from ,
'-c' , 'copy' ,
'-vn' ,
'-map' , 'a:0' ,
'-f' , 'matroska' , // mpegts doesn't support vorbis etc
'-' ,
] ;
const args2 = [
'-hide_banner' ,
'-i' , '-' ,
'-filter_complex' , ` aformat=channel_layouts=mono,showwavespic=s=640x120:scale=sqrt:colors= ${ color } ` ,
'-frames:v' , '1' ,
'-vcodec' , 'png' ,
'-f' , 'image2' ,
'-' ,
] ;
console . log ( getFfCommandLine ( 'ffmpeg1' , args1 ) ) ;
console . log ( getFfCommandLine ( 'ffmpeg2' , args2 ) ) ;
let ps1 ;
let ps2 ;
try {
const ffmpegPath = getFfmpegPath ( ) ;
ps1 = execa ( ffmpegPath , args1 , { encoding : null , buffer : false } ) ;
ps2 = execa ( ffmpegPath , args2 , { encoding : null } ) ;
ps1 . stdout . pipe ( ps2 . stdin ) ;
const timer = setTimeout ( ( ) => {
ps1 . kill ( ) ;
ps2 . kill ( ) ;
} , 10000 ) ;
let stdout ;
try {
( { stdout } = await ps2 ) ;
} finally {
clearTimeout ( timer ) ;
}
const blob = new Blob ( [ stdout ] , { type : 'image/png' } ) ;
return {
url : URL . createObjectURL ( blob ) ,
from ,
aroundTime ,
to ,
createdAt : new Date ( ) ,
} ;
} catch ( err ) {
if ( ps1 ) ps1 . kill ( ) ;
if ( ps2 ) ps2 . kill ( ) ;
throw err ;
}
}
export async function blackDetect ( { filePath , duration , minInterval = 0.05 , onProgress , from , to } ) {
const args = [
'-hide_banner' ,
... ( from != null ? [ '-ss' , from . toFixed ( 5 ) ] : [ ] ) ,
'-i' , filePath ,
... ( to != null ? [ '-t' , ( to - from ) . toFixed ( 5 ) ] : [ ] ) ,
'-vf' , ` blackdetect=d= ${ minInterval } ` , '-an' , '-f' , 'null' , '-' ,
] ;
const process = execa ( getFfmpegPath ( ) , args , { encoding : null , buffer : false } ) ;
const blackSegments = [ ] ;
function customMatcher ( line ) {
const match = line . match ( /^[blackdetect @ 0x[0-9a-f]+] black_start:([\d\\.]+) black_end:([\d\\.]+) black_duration:[\d\\.]+/ ) ;
if ( ! match ) return ;
const blackStart = parseFloat ( match [ 1 ] ) ;
const blackEnd = parseFloat ( match [ 2 ] ) ;
if ( Number . isNaN ( blackStart ) || Number . isNaN ( blackEnd ) ) return ;
blackSegments . push ( { blackStart , blackEnd } ) ;
}
handleProgress ( process , duration , onProgress , customMatcher ) ;
await process ;
const offset = from != null ? from : 0 ;
return blackSegments . map ( ( { blackStart , blackEnd } ) => ( { blackStart : blackStart + offset , blackEnd : blackEnd + offset } ) ) ;
}
export async function extractWaveform ( { filePath , outPath } ) {
const numSegs = 10 ;
const duration = 60 * 60 ;
const maxLen = 0.1 ;
const segments = Array ( numSegs ) . fill ( ) . map ( ( unused , i ) => [ i * ( duration / numSegs ) , Math . min ( duration / numSegs , maxLen ) ] ) ;
// 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 += ';' ;
filter += segments . map ( ( arr , i ) => ` [a ${ i } ] ` ) . join ( '' ) ;
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' ) ;
}
const imageCaptureQuality = 3 ;
// See also capture-frame.js
export async function captureFrame ( { timestamp , videoPath , outPath , numFrames } ) {
await runFfmpeg ( [
'-ss' , timestamp ,
'-i' , videoPath ,
'-vframes' , numFrames ,
'-q:v' , imageCaptureQuality ,
'-y' , outPath ,
] ) ;
}
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' ) ) ;
}
export function getStreamFps ( stream ) {
const match = typeof stream . avg _frame _rate === 'string' && stream . avg _frame _rate . match ( /^([0-9]+)\/([0-9]+)$/ ) ;
if ( stream . codec _type === 'video' && match ) {
const num = parseInt ( match [ 1 ] , 10 ) ;
const den = parseInt ( match [ 2 ] , 10 ) ;
if ( den > 0 ) return num / den ;
}
return undefined ;
}
function createRawFfmpeg ( { fps = 25 , path , inWidth , inHeight , seekTo , oneFrameOnly , execaOpts , streamIndex , outSize = 320 } ) {
// const fps = 25; // TODO
const aspectRatio = inWidth / inHeight ;
let newWidth ;
let newHeight ;
if ( inWidth > inHeight ) {
newWidth = outSize ;
newHeight = Math . floor ( newWidth / aspectRatio ) ;
} else {
newHeight = outSize ;
newWidth = Math . floor ( newHeight * aspectRatio ) ;
}
const args = [
'-hide_banner' , '-loglevel' , 'panic' ,
'-re' ,
'-ss' , seekTo ,
'-noautorotate' ,
'-i' , path ,
'-vf' , ` fps= ${ fps } ,scale= ${ newWidth } : ${ newHeight } :flags=lanczos ` ,
'-map' , ` 0: ${ streamIndex } ` ,
'-vcodec' , 'rawvideo' ,
'-pix_fmt' , 'rgba' ,
... ( oneFrameOnly ? [ '-frames:v' , '1' ] : [ ] ) ,
'-f' , 'image2pipe' ,
'-' ,
] ;
// console.log(args);
return {
process : execa ( getFfmpegPath ( ) , args , execaOpts ) ,
width : newWidth ,
height : newHeight ,
channels : 4 ,
} ;
}
export function getOneRawFrame ( { path , inWidth , inHeight , seekTo , streamIndex , outSize } ) {
const { process , width , height , channels } = createRawFfmpeg ( { path , inWidth , inHeight , seekTo , streamIndex , oneFrameOnly : true , execaOpts : { encoding : null } , outSize } ) ;
return { process , width , height , channels } ;
}
export function encodeLiveRawStream ( { path , inWidth , inHeight , seekTo , streamIndex } ) {
const { process , width , height , channels } = createRawFfmpeg ( { path , inWidth , inHeight , seekTo , streamIndex , execaOpts : { encoding : null , buffer : false } } ) ;
return {
process ,
width ,
height ,
channels ,
} ;
}
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 ;
} catch ( err ) {
// console.warn('Failed to parse timecode from file streams', err);
return undefined ;
}
} ) ;
return foundTimecode ;
}
export async function runStartupCheck ( ) {
await runFfmpeg ( [ '-hide_banner' , '-f' , 'lavfi' , '-i' , 'nullsrc=s=256x256:d=1' , '-f' , 'null' , '-' ] ) ;
}
export async function html5ify ( { outPath , filePath : filePathArg , speed , hasAudio , hasVideo , onProgress } ) {
let audio ;
if ( hasAudio ) {
if ( speed === 'slowest' ) audio = 'hq' ;
else if ( [ 'slow-audio' , 'fast-audio' , 'fastest-audio' ] . includes ( speed ) ) audio = 'lq' ;
else if ( [ 'fast-audio-remux' , 'fastest-audio-remux' ] . includes ( speed ) ) audio = 'copy' ;
}
let video ;
if ( hasVideo ) {
if ( speed === 'slowest' ) video = 'hq' ;
else if ( [ 'slow-audio' , 'slow' ] . includes ( speed ) ) video = 'lq' ;
else video = 'copy' ;
}
console . log ( 'Making HTML5 friendly version' , { filePathArg , outPath , video , audio } ) ;
let videoArgs ;
let audioArgs ;
// h264/aac_at: No licensing when using HW encoder (Video/Audio Toolbox on Mac)
// https://github.com/mifi/lossless-cut/issues/372#issuecomment-810766512
const targetHeight = 400 ;
switch ( video ) {
case 'hq' : {
if ( isMac ) {
videoArgs = [ '-vf' , 'format=yuv420p' , '-allow_sw' , '1' , '-vcodec' , 'h264' , '-b:v' , '15M' ] ;
} else {
// AV1 is very slow
// videoArgs = ['-vf', 'format=yuv420p', '-sws_flags', 'neighbor', '-vcodec', 'libaom-av1', '-crf', '30', '-cpu-used', '8'];
// Theora is a bit faster but not that much
// videoArgs = ['-vf', '-c:v', 'libtheora', '-qscale:v', '1'];
// videoArgs = ['-vf', 'format=yuv420p', '-c:v', 'libvpx-vp9', '-crf', '30', '-b:v', '0', '-row-mt', '1'];
// x264 can only be used in GPL projects
videoArgs = [ '-vf' , 'format=yuv420p' , '-c:v' , 'libx264' , '-profile:v' , 'high' , '-preset:v' , 'slow' , '-crf' , '17' ] ;
}
break ;
}
case 'lq' : {
if ( isMac ) {
videoArgs = [ '-vf' , ` scale=-2: ${ targetHeight } ,format=yuv420p ` , '-allow_sw' , '1' , '-sws_flags' , 'lanczos' , '-vcodec' , 'h264' , '-b:v' , '1500k' ] ;
} else {
// videoArgs = ['-vf', `scale=-2:${targetHeight},format=yuv420p`, '-sws_flags', 'neighbor', '-c:v', 'libtheora', '-qscale:v', '1'];
// x264 can only be used in GPL projects
videoArgs = [ '-vf' , ` scale=-2: ${ targetHeight } ,format=yuv420p ` , '-sws_flags' , 'neighbor' , '-c:v' , 'libx264' , '-profile:v' , 'baseline' , '-x264opts' , 'level=3.0' , '-preset:v' , 'ultrafast' , '-crf' , '28' ] ;
}
break ;
}
case 'copy' : {
videoArgs = [ '-vcodec' , 'copy' ] ;
break ;
}
default : {
videoArgs = [ '-vn' ] ;
}
}
switch ( audio ) {
case 'hq' : {
if ( isMac ) {
audioArgs = [ '-acodec' , 'aac_at' , '-b:a' , '192k' ] ;
} else {
audioArgs = [ '-acodec' , 'flac' ] ;
}
break ;
}
case 'lq' : {
if ( isMac ) {
audioArgs = [ '-acodec' , 'aac_at' , '-ar' , '44100' , '-ac' , '2' , '-b:a' , '96k' ] ;
} else {
audioArgs = [ '-acodec' , 'flac' , '-ar' , '11025' , '-ac' , '2' ] ;
}
break ;
}
case 'copy' : {
audioArgs = [ '-acodec' , 'copy' ] ;
break ;
}
default : {
audioArgs = [ '-an' ] ;
}
}
const ffmpegArgs = [
'-hide_banner' ,
'-i' , filePathArg ,
... videoArgs ,
... audioArgs ,
'-sn' ,
'-y' , outPath ,
] ;
const duration = await getDuration ( filePathArg ) ;
const process = runFfmpeg ( ffmpegArgs ) ;
if ( duration ) handleProgress ( process , duration , onProgress ) ;
const { stdout } = await process ;
console . log ( stdout ) ;
}
// https://superuser.com/questions/543589/information-about-ffmpeg-command-line-options
export const getExperimentalArgs = ( ffmpegExperimental ) => ( ffmpegExperimental ? [ '-strict' , 'experimental' ] : [ ] ) ;
export const getVideoTimescaleArgs = ( videoTimebase ) => ( videoTimebase != null ? [ '-video_track_timescale' , videoTimebase ] : [ ] ) ;
// inspired by https://gist.github.com/fernandoherreradelasheras/5eca67f4200f1a7cc8281747da08496e
export async function cutEncodeSmartPart ( { filePath , cutFrom , cutTo , outPath , outFormat , videoCodec , videoBitrate , videoTimebase , allFilesMeta , copyFileStreams , videoStreamIndex , ffmpegExperimental } ) {
function getVideoArgs ( { streamIndex , outputIndex } ) {
if ( streamIndex !== videoStreamIndex ) return undefined ;
return [
` -c: ${ outputIndex } ` , videoCodec ,
` -b: ${ outputIndex } ` , videoBitrate ,
] ;
}
const mapStreamsArgs = getMapStreamsArgs ( {
allFilesMeta ,
copyFileStreams ,
outFormat ,
getVideoArgs ,
} ) ;
const ffmpegArgs = [
'-hide_banner' ,
// No progress if we set loglevel warning :(
// '-loglevel', 'warning',
'-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
'-i' , filePath ,
'-ss' , '0' , // If we don't do this, the output seems to start with an empty black after merging with the encoded part
'-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 ,
] ;
const ffmpegCommandLine = getFfCommandLine ( 'ffmpeg' , ffmpegArgs ) ;
console . log ( ffmpegCommandLine ) ;
await execa ( getFfmpegPath ( ) , ffmpegArgs ) ;
}