|
@@ -117,37 +117,50 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-type TranscodeOptions = {
|
|
|
+type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio'
|
|
|
+
|
|
|
+interface BaseTranscodeOptions {
|
|
|
+ type: TranscodeOptionsType
|
|
|
inputPath: string
|
|
|
outputPath: string
|
|
|
resolution: VideoResolution
|
|
|
isPortraitMode?: boolean
|
|
|
- doQuickTranscode?: Boolean
|
|
|
+}
|
|
|
|
|
|
- hlsPlaylist?: {
|
|
|
+interface HLSTranscodeOptions extends BaseTranscodeOptions {
|
|
|
+ type: 'hls'
|
|
|
+ hlsPlaylist: {
|
|
|
videoFilename: string
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+interface QuickTranscodeOptions extends BaseTranscodeOptions {
|
|
|
+ type: 'quick-transcode'
|
|
|
+}
|
|
|
+
|
|
|
+interface VideoTranscodeOptions extends BaseTranscodeOptions {
|
|
|
+ type: 'video'
|
|
|
+}
|
|
|
+
|
|
|
+interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
|
|
|
+ type: 'merge-audio'
|
|
|
+ audioPath: string
|
|
|
+}
|
|
|
+
|
|
|
+type TranscodeOptions = HLSTranscodeOptions | VideoTranscodeOptions | MergeAudioTranscodeOptions | QuickTranscodeOptions
|
|
|
+
|
|
|
function transcode (options: TranscodeOptions) {
|
|
|
return new Promise<void>(async (res, rej) => {
|
|
|
try {
|
|
|
let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING })
|
|
|
.output(options.outputPath)
|
|
|
|
|
|
- if (options.doQuickTranscode) {
|
|
|
- if (options.hlsPlaylist) {
|
|
|
- throw(Error("Quick transcode and HLS can't be used at the same time"))
|
|
|
- }
|
|
|
-
|
|
|
- command
|
|
|
- .format('mp4')
|
|
|
- .addOption('-c:v copy')
|
|
|
- .addOption('-c:a copy')
|
|
|
- .outputOption('-map_metadata -1') // strip all metadata
|
|
|
- .outputOption('-movflags faststart')
|
|
|
- } else if (options.hlsPlaylist) {
|
|
|
+ if (options.type === 'quick-transcode') {
|
|
|
+ command = await buildQuickTranscodeCommand(command)
|
|
|
+ } else if (options.type === 'hls') {
|
|
|
command = await buildHLSCommand(command, options)
|
|
|
+ } else if (options.type === 'merge-audio') {
|
|
|
+ command = await buildAudioMergeCommand(command, options)
|
|
|
} else {
|
|
|
command = await buildx264Command(command, options)
|
|
|
}
|
|
@@ -163,7 +176,7 @@ function transcode (options: TranscodeOptions) {
|
|
|
return rej(err)
|
|
|
})
|
|
|
.on('end', () => {
|
|
|
- return onTranscodingSuccess(options)
|
|
|
+ return fixHLSPlaylistIfNeeded(options)
|
|
|
.then(() => res())
|
|
|
.catch(err => rej(err))
|
|
|
})
|
|
@@ -205,6 +218,8 @@ export {
|
|
|
getVideoFileResolution,
|
|
|
getDurationFromVideoFile,
|
|
|
generateImageFromVideoFile,
|
|
|
+ TranscodeOptions,
|
|
|
+ TranscodeOptionsType,
|
|
|
transcode,
|
|
|
getVideoFileFPS,
|
|
|
computeResolutionsToTranscode,
|
|
@@ -215,7 +230,7 @@ export {
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
-async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
|
|
|
+async function buildx264Command (command: ffmpeg.FfmpegCommand, options: VideoTranscodeOptions) {
|
|
|
let fps = await getVideoFileFPS(options.inputPath)
|
|
|
// On small/medium resolutions, limit FPS
|
|
|
if (
|
|
@@ -226,7 +241,7 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco
|
|
|
fps = VIDEO_TRANSCODING_FPS.AVERAGE
|
|
|
}
|
|
|
|
|
|
- command = await presetH264(command, options.resolution, fps)
|
|
|
+ command = await presetH264(command, options.inputPath, options.resolution, fps)
|
|
|
|
|
|
if (options.resolution !== undefined) {
|
|
|
// '?x720' or '720x?' for example
|
|
@@ -245,7 +260,29 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco
|
|
|
return command
|
|
|
}
|
|
|
|
|
|
-async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
|
|
|
+async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) {
|
|
|
+ command = command.loop(undefined)
|
|
|
+
|
|
|
+ command = await presetH264VeryFast(command, options.audioPath, options.resolution)
|
|
|
+
|
|
|
+ command = command.input(options.audioPath)
|
|
|
+ .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error
|
|
|
+ .outputOption('-tune stillimage')
|
|
|
+ .outputOption('-shortest')
|
|
|
+
|
|
|
+ return command
|
|
|
+}
|
|
|
+
|
|
|
+async function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
|
|
|
+ command = await presetCopy(command)
|
|
|
+
|
|
|
+ command = command.outputOption('-map_metadata -1') // strip all metadata
|
|
|
+ .outputOption('-movflags faststart')
|
|
|
+
|
|
|
+ return command
|
|
|
+}
|
|
|
+
|
|
|
+async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
|
|
|
const videoPath = getHLSVideoPath(options)
|
|
|
|
|
|
command = await presetCopy(command)
|
|
@@ -261,19 +298,19 @@ async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: Transcod
|
|
|
return command
|
|
|
}
|
|
|
|
|
|
-function getHLSVideoPath (options: TranscodeOptions) {
|
|
|
+function getHLSVideoPath (options: HLSTranscodeOptions) {
|
|
|
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
|
|
|
}
|
|
|
|
|
|
-async function onTranscodingSuccess (options: TranscodeOptions) {
|
|
|
- if (!options.hlsPlaylist) return
|
|
|
+async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
|
|
|
+ if (options.type !== 'hls') return
|
|
|
|
|
|
- // Fix wrong mapping with some ffmpeg versions
|
|
|
const fileContent = await readFile(options.outputPath)
|
|
|
|
|
|
const videoFileName = options.hlsPlaylist.videoFilename
|
|
|
const videoFilePath = getHLSVideoPath(options)
|
|
|
|
|
|
+ // Fix wrong mapping with some ffmpeg versions
|
|
|
const newContent = fileContent.toString()
|
|
|
.replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
|
|
|
|
|
@@ -300,44 +337,27 @@ function getVideoStreamFromFile (path: string) {
|
|
|
* and quality. Superfast and ultrafast will give you better
|
|
|
* performance, but then quality is noticeably worse.
|
|
|
*/
|
|
|
-async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> {
|
|
|
- let localCommand = await presetH264(command, resolution, fps)
|
|
|
+async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
|
|
|
+ let localCommand = await presetH264(command, input, resolution, fps)
|
|
|
+
|
|
|
localCommand = localCommand.outputOption('-preset:v veryfast')
|
|
|
- .outputOption([ '--aq-mode=2', '--aq-strength=1.3' ])
|
|
|
+
|
|
|
/*
|
|
|
MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
|
|
|
Our target situation is closer to a livestream than a stream,
|
|
|
since we want to reduce as much a possible the encoding burden,
|
|
|
- altough not to the point of a livestream where there is a hard
|
|
|
+ although not to the point of a livestream where there is a hard
|
|
|
constraint on the frames per second to be encoded.
|
|
|
-
|
|
|
- why '--aq-mode=2 --aq-strength=1.3' instead of '-profile:v main'?
|
|
|
- Make up for most of the loss of grain and macroblocking
|
|
|
- with less computing power.
|
|
|
*/
|
|
|
|
|
|
return localCommand
|
|
|
}
|
|
|
|
|
|
-/**
|
|
|
- * A preset optimised for a stillimage audio video
|
|
|
- */
|
|
|
-async function presetStillImageWithAudio (
|
|
|
- command: ffmpeg.FfmpegCommand,
|
|
|
- resolution: VideoResolution,
|
|
|
- fps: number
|
|
|
-): Promise<ffmpeg.FfmpegCommand> {
|
|
|
- let localCommand = await presetH264VeryFast(command, resolution, fps)
|
|
|
- localCommand = localCommand.outputOption('-tune stillimage')
|
|
|
-
|
|
|
- return localCommand
|
|
|
-}
|
|
|
-
|
|
|
/**
|
|
|
* A toolbox to play with audio
|
|
|
*/
|
|
|
namespace audio {
|
|
|
- export const get = (option: ffmpeg.FfmpegCommand | string) => {
|
|
|
+ export const get = (option: string) => {
|
|
|
// without position, ffprobe considers the last input only
|
|
|
// we make it consider the first input only
|
|
|
// if you pass a file path to pos, then ffprobe acts on that file directly
|
|
@@ -359,11 +379,7 @@ namespace audio {
|
|
|
return res({ absolutePath: data.format.filename })
|
|
|
}
|
|
|
|
|
|
- if (typeof option === 'string') {
|
|
|
- return ffmpeg.ffprobe(option, parseFfprobe)
|
|
|
- }
|
|
|
-
|
|
|
- return option.ffprobe(parseFfprobe)
|
|
|
+ return ffmpeg.ffprobe(option, parseFfprobe)
|
|
|
})
|
|
|
}
|
|
|
|
|
@@ -405,7 +421,7 @@ namespace audio {
|
|
|
* As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
|
|
|
* See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
|
|
|
*/
|
|
|
-async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> {
|
|
|
+async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
|
|
|
let localCommand = command
|
|
|
.format('mp4')
|
|
|
.videoCodec('libx264')
|
|
@@ -416,7 +432,7 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol
|
|
|
.outputOption('-map_metadata -1') // strip all metadata
|
|
|
.outputOption('-movflags faststart')
|
|
|
|
|
|
- const parsedAudio = await audio.get(localCommand)
|
|
|
+ const parsedAudio = await audio.get(input)
|
|
|
|
|
|
if (!parsedAudio.audioStream) {
|
|
|
localCommand = localCommand.noAudio()
|
|
@@ -425,28 +441,30 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol
|
|
|
.audioCodec('libfdk_aac')
|
|
|
.audioQuality(5)
|
|
|
} else {
|
|
|
- // we try to reduce the ceiling bitrate by making rough correspondances of bitrates
|
|
|
+ // we try to reduce the ceiling bitrate by making rough matches of bitrates
|
|
|
// of course this is far from perfect, but it might save some space in the end
|
|
|
+ localCommand = localCommand.audioCodec('aac')
|
|
|
+
|
|
|
const audioCodecName = parsedAudio.audioStream[ 'codec_name' ]
|
|
|
- let bitrate: number
|
|
|
- if (audio.bitrate[ audioCodecName ]) {
|
|
|
- localCommand = localCommand.audioCodec('aac')
|
|
|
|
|
|
- bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ])
|
|
|
+ if (audio.bitrate[ audioCodecName ]) {
|
|
|
+ const bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ])
|
|
|
if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Constrained Encoding (VBV)
|
|
|
- // https://slhck.info/video/2017/03/01/rate-control.html
|
|
|
- // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
|
|
|
- const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
|
|
|
- localCommand = localCommand.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`])
|
|
|
-
|
|
|
- // Keyframe interval of 2 seconds for faster seeking and resolution switching.
|
|
|
- // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
|
|
|
- // https://superuser.com/a/908325
|
|
|
- localCommand = localCommand.outputOption(`-g ${ fps * 2 }`)
|
|
|
+ if (fps) {
|
|
|
+ // Constrained Encoding (VBV)
|
|
|
+ // https://slhck.info/video/2017/03/01/rate-control.html
|
|
|
+ // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
|
|
|
+ const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
|
|
|
+ localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ])
|
|
|
+
|
|
|
+ // Keyframe interval of 2 seconds for faster seeking and resolution switching.
|
|
|
+ // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
|
|
|
+ // https://superuser.com/a/908325
|
|
|
+ localCommand = localCommand.outputOption(`-g ${fps * 2}`)
|
|
|
+ }
|
|
|
|
|
|
return localCommand
|
|
|
}
|