import { Job } from 'bull'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { readFile, writeFile } from 'fs-extra'
import { dirname } from 'path'
import { pick } from '@shared/core-utils'
import { AvailableEncoders, VideoResolution } from '@shared/models'
import { logger, loggerTagsFactory } from '../logger'
import { getFFmpeg, runCommand } from './ffmpeg-commons'
import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets'
import { computeFPS, getVideoStreamFPS } from './ffprobe-utils'
import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
const lTags = loggerTagsFactory('ffmpeg')
// ---------------------------------------------------------------------------
type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
interface BaseTranscodeVODOptions {
type: TranscodeVODOptionsType
inputPath: string
outputPath: string
availableEncoders: AvailableEncoders
profile: string
resolution: number
isPortraitMode?: boolean
job?: Job
}
interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
type: 'hls'
copyCodecs: boolean
hlsPlaylist: {
videoFilename: string
}
}
interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
type: 'hls-from-ts'
isAAC: boolean
hlsPlaylist: {
videoFilename: string
}
}
interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
type: 'quick-transcode'
}
interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
type: 'video'
}
interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
type: 'merge-audio'
audioPath: string
}
interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
type: 'only-audio'
}
type TranscodeVODOptions =
HLSTranscodeOptions
| HLSFromTSTranscodeOptions
| VideoTranscodeOptions
| MergeAudioTranscodeOptions
| OnlyAudioTranscodeOptions
| QuickTranscodeOptions
// ---------------------------------------------------------------------------
const builders: {
[ type in TranscodeVODOptionsType ]: (c: FfmpegCommand, o?: TranscodeVODOptions) => Promise<FfmpegCommand> | FfmpegCommand
} = {
'quick-transcode': buildQuickTranscodeCommand,
'hls': buildHLSVODCommand,
'hls-from-ts': buildHLSVODFromTSCommand,
'merge-audio': buildAudioMergeCommand,
'only-audio': buildOnlyAudioCommand,
'video': buildVODCommand
}
async function transcodeVOD (options: TranscodeVODOptions) {
logger.debug('Will run transcode.', { options, ...lTags() })
let command = getFFmpeg(options.inputPath, 'vod')
.output(options.outputPath)
command = await builders[options.type](command, options)
await runCommand({ command, job: options.job })
await fixHLSPlaylistIfNeeded(options)
}
// ---------------------------------------------------------------------------
export {
transcodeVOD,
buildVODCommand,
TranscodeVODOptions,
TranscodeVODOptionsType
}
// ---------------------------------------------------------------------------
async function buildVODCommand (command: FfmpegCommand, options: TranscodeVODOptions) {
let fps = await getVideoStreamFPS(options.inputPath)
fps = computeFPS(fps, options.resolution)
let scaleFilterValue: string
if (options.resolution !== undefined) {
scaleFilterValue = options.isPortraitMode === true
? `w=${options.resolution}:h=-2`
: `w=-2:h=${options.resolution}`
}
command = await presetVOD({
...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
command,
input: options.inputPath,
canCopyAudio: true,
canCopyVideo: true,
fps,
scaleFilterValue
})
return command
}
function buildQuickTranscodeCommand (command: FfmpegCommand) {
command = presetCopy(command)
command = command.outputOption('-map_metadata -1') // strip all metadata
.outputOption('-movflags faststart')
return command
}
// ---------------------------------------------------------------------------
// Audio transcoding
// ---------------------------------------------------------------------------
async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
command = command.loop(undefined)
const scaleFilterValue = getMergeAudioScaleFilterValue()
command = await presetVOD({
...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
command,
input: options.audioPath,
canCopyAudio: true,
canCopyVideo: true,
fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
scaleFilterValue
})
command.outputOption('-preset:v veryfast')
command = command.input(options.audioPath)
.outputOption('-tune stillimage')
.outputOption('-shortest')
return command
}
function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
command = presetOnlyAudio(command)
return command
}
// ---------------------------------------------------------------------------
// HLS transcoding
// ---------------------------------------------------------------------------
async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
const videoPath = getHLSVideoPath(options)
if (options.copyCodecs) command = presetCopy(command)
else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
else command = await buildVODCommand(command, options)
addCommonHLSVODCommandOptions(command, videoPath)
return command
}
function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
const videoPath = getHLSVideoPath(options)
command.outputOption('-c copy')
if (options.isAAC) {
// Required for example when copying an AAC stream from an MPEG-TS
// Since it's a bitstream filter, we don't need to reencode the audio
command.outputOption('-bsf:a aac_adtstoasc')
}
addCommonHLSVODCommandOptions(command, videoPath)
return command
}
function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
return command.outputOption('-hls_time 4')
.outputOption('-hls_list_size 0')
.outputOption('-hls_playlist_type vod')
.outputOption('-hls_segment_filename ' + outputPath)
.outputOption('-hls_segment_type fmp4')
.outputOption('-f hls')
.outputOption('-hls_flags single_file')
}
async function fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
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}",`)
await writeFile(options.outputPath, newContent)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
}
// Avoid "height not divisible by 2" error
function getMergeAudioScaleFilterValue () {
return 'trunc(iw/2)*2:trunc(ih/2)*2'
}