はじまりの大地
このコミットが含まれているのは:
@@ -0,0 +1,257 @@
|
||||
import { pick, promisify0 } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
AvailableEncoders,
|
||||
EncoderOptionsBuilder,
|
||||
EncoderOptionsBuilderParams,
|
||||
EncoderProfile,
|
||||
SimpleLogger
|
||||
} from '@peertube/peertube-models'
|
||||
import { MutexInterface } from 'async-mutex'
|
||||
import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
|
||||
|
||||
export interface FFmpegCommandWrapperOptions {
|
||||
availableEncoders?: AvailableEncoders
|
||||
profile?: string
|
||||
|
||||
niceness: number
|
||||
tmpDirectory: string
|
||||
threads: number
|
||||
|
||||
logger: SimpleLogger
|
||||
lTags?: { tags: string[] }
|
||||
|
||||
updateJobProgress?: (progress?: number) => void
|
||||
onEnd?: () => void
|
||||
onError?: (err: Error) => void
|
||||
}
|
||||
|
||||
export class FFmpegCommandWrapper {
|
||||
private static supportedEncoders: Map<string, boolean>
|
||||
|
||||
private readonly availableEncoders: AvailableEncoders
|
||||
private readonly profile: string
|
||||
|
||||
private readonly niceness: number
|
||||
private readonly tmpDirectory: string
|
||||
private readonly threads: number
|
||||
|
||||
private readonly logger: SimpleLogger
|
||||
private readonly lTags: { tags: string[] }
|
||||
|
||||
private readonly updateJobProgress: (progress?: number) => void
|
||||
private readonly onEnd?: () => void
|
||||
private readonly onError?: (err: Error) => void
|
||||
|
||||
private command: FfmpegCommand
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.availableEncoders = options.availableEncoders
|
||||
this.profile = options.profile
|
||||
this.niceness = options.niceness
|
||||
this.tmpDirectory = options.tmpDirectory
|
||||
this.threads = options.threads
|
||||
this.logger = options.logger
|
||||
this.lTags = options.lTags || { tags: [] }
|
||||
|
||||
this.updateJobProgress = options.updateJobProgress
|
||||
|
||||
this.onEnd = options.onEnd
|
||||
this.onError = options.onError
|
||||
}
|
||||
|
||||
getAvailableEncoders () {
|
||||
return this.availableEncoders
|
||||
}
|
||||
|
||||
getProfile () {
|
||||
return this.profile
|
||||
}
|
||||
|
||||
getCommand () {
|
||||
return this.command
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
debugLog (msg: string, meta: any = {}) {
|
||||
this.logger.debug(msg, { ...meta, ...this.lTags })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
resetCommand () {
|
||||
this.command = undefined
|
||||
}
|
||||
|
||||
buildCommand (input: string, inputFileMutexReleaser?: MutexInterface.Releaser) {
|
||||
if (this.command) throw new Error('Command is already built')
|
||||
|
||||
// We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
|
||||
this.command = ffmpeg(input, {
|
||||
niceness: this.niceness,
|
||||
cwd: this.tmpDirectory
|
||||
})
|
||||
|
||||
if (this.threads > 0) {
|
||||
// If we don't set any threads ffmpeg will chose automatically
|
||||
this.command.outputOption('-threads ' + this.threads)
|
||||
}
|
||||
|
||||
if (inputFileMutexReleaser) {
|
||||
this.command.on('start', () => {
|
||||
setTimeout(() => inputFileMutexReleaser(), 1000)
|
||||
})
|
||||
}
|
||||
|
||||
return this.command
|
||||
}
|
||||
|
||||
async runCommand (options: {
|
||||
silent?: boolean // false by default
|
||||
} = {}) {
|
||||
const { silent = false } = options
|
||||
|
||||
return new Promise<void>((res, rej) => {
|
||||
let shellCommand: string
|
||||
|
||||
this.command.on('start', cmdline => { shellCommand = cmdline })
|
||||
|
||||
this.command.on('error', (err, stdout, stderr) => {
|
||||
if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags })
|
||||
|
||||
if (this.onError) this.onError(err)
|
||||
|
||||
rej(err)
|
||||
})
|
||||
|
||||
this.command.on('end', (stdout, stderr) => {
|
||||
this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags })
|
||||
|
||||
if (this.onEnd) this.onEnd()
|
||||
|
||||
res()
|
||||
})
|
||||
|
||||
if (this.updateJobProgress) {
|
||||
this.command.on('progress', progress => {
|
||||
if (!progress.percent) return
|
||||
|
||||
// Sometimes ffmpeg returns an invalid progress
|
||||
let percent = Math.round(progress.percent)
|
||||
if (percent < 0) percent = 0
|
||||
if (percent > 100) percent = 100
|
||||
|
||||
this.updateJobProgress(percent)
|
||||
})
|
||||
}
|
||||
|
||||
this.command.run()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static resetSupportedEncoders () {
|
||||
FFmpegCommandWrapper.supportedEncoders = undefined
|
||||
}
|
||||
|
||||
// Run encoder builder depending on available encoders
|
||||
// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
|
||||
// If the default one does not exist, check the next encoder
|
||||
async getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
|
||||
streamType: 'video' | 'audio'
|
||||
input: string
|
||||
|
||||
videoType: 'vod' | 'live'
|
||||
}) {
|
||||
if (!this.availableEncoders) {
|
||||
throw new Error('There is no available encoders')
|
||||
}
|
||||
|
||||
const { streamType, videoType } = options
|
||||
|
||||
const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType]
|
||||
const encoders = this.availableEncoders.available[videoType]
|
||||
|
||||
for (const encoder of encodersToTry) {
|
||||
if (!(await this.checkFFmpegEncoders(this.availableEncoders)).get(encoder)) {
|
||||
this.logger.debug(`Encoder ${encoder} not available in ffmpeg, skipping.`, this.lTags)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!encoders[encoder]) {
|
||||
this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags)
|
||||
continue
|
||||
}
|
||||
|
||||
// An object containing available profiles for this encoder
|
||||
const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
|
||||
let builder = builderProfiles[this.profile]
|
||||
|
||||
if (!builder) {
|
||||
this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags)
|
||||
builder = builderProfiles.default
|
||||
|
||||
if (!builder) {
|
||||
this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const result = await builder(
|
||||
pick(options, [
|
||||
'input',
|
||||
'canCopyAudio',
|
||||
'canCopyVideo',
|
||||
'resolution',
|
||||
'inputBitrate',
|
||||
'inputProbe',
|
||||
'fps',
|
||||
'inputRatio',
|
||||
'streamNum'
|
||||
])
|
||||
)
|
||||
|
||||
return {
|
||||
result,
|
||||
|
||||
// If we don't have output options, then copy the input stream
|
||||
encoder: result.copy === true
|
||||
? 'copy'
|
||||
: encoder
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Detect supported encoders by ffmpeg
|
||||
private async checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
|
||||
if (FFmpegCommandWrapper.supportedEncoders !== undefined) {
|
||||
return FFmpegCommandWrapper.supportedEncoders
|
||||
}
|
||||
|
||||
const getAvailableEncodersPromise = promisify0(ffmpeg.getAvailableEncoders)
|
||||
const availableFFmpegEncoders = await getAvailableEncodersPromise()
|
||||
|
||||
const searchEncoders = new Set<string>()
|
||||
for (const type of [ 'live', 'vod' ]) {
|
||||
for (const streamType of [ 'audio', 'video' ]) {
|
||||
for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
|
||||
searchEncoders.add(encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const supportedEncoders = new Map<string, boolean>()
|
||||
|
||||
for (const searchEncoder of searchEncoders) {
|
||||
supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
|
||||
}
|
||||
|
||||
this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags })
|
||||
|
||||
FFmpegCommandWrapper.supportedEncoders = supportedEncoders
|
||||
return supportedEncoders
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, getMinTheoreticalBitrate } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
buildStreamSuffix,
|
||||
getAudioStream,
|
||||
getMaxAudioBitrate,
|
||||
getVideoStream,
|
||||
getVideoStreamBitrate,
|
||||
getVideoStreamDimensionsInfo,
|
||||
getVideoStreamFPS
|
||||
} from '@peertube/peertube-ffmpeg'
|
||||
import { EncoderOptionsBuilder, EncoderOptionsBuilderParams } from '@peertube/peertube-models'
|
||||
import { FfprobeData } from 'fluent-ffmpeg'
|
||||
|
||||
const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
|
||||
const { fps, inputRatio, inputBitrate, resolution } = options
|
||||
|
||||
const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
|
||||
|
||||
return {
|
||||
outputOptions: [
|
||||
...getCommonOutputOptions(targetBitrate),
|
||||
|
||||
`-r ${fps}`
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
|
||||
const { streamNum, fps, inputBitrate, inputRatio, resolution } = options
|
||||
|
||||
const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
|
||||
|
||||
return {
|
||||
outputOptions: [
|
||||
...getCommonOutputOptions(targetBitrate, streamNum),
|
||||
|
||||
`${buildStreamSuffix('-r:v', streamNum)} ${fps}`,
|
||||
`${buildStreamSuffix('-b:v', streamNum)} ${targetBitrate}`
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum, canCopyAudio, inputProbe }) => {
|
||||
if (canCopyAudio && await canDoQuickAudioTranscode(input, inputProbe)) {
|
||||
return { copy: true, outputOptions: [ ] }
|
||||
}
|
||||
|
||||
const parsedAudio = await getAudioStream(input, inputProbe)
|
||||
|
||||
// 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
|
||||
|
||||
const audioCodecName = parsedAudio.audioStream['codec_name']
|
||||
|
||||
const bitrate = getMaxAudioBitrate(audioCodecName, parsedAudio.bitrate)
|
||||
|
||||
// Force stereo as it causes some issues with HLS playback in Chrome
|
||||
const base = [ '-channel_layout', 'stereo' ]
|
||||
|
||||
if (bitrate !== -1) {
|
||||
return { outputOptions: base.concat([ buildStreamSuffix('-b:a', streamNum), bitrate + 'k' ]) }
|
||||
}
|
||||
|
||||
return { outputOptions: base }
|
||||
}
|
||||
|
||||
const defaultLibFDKAACVODOptionsBuilder: EncoderOptionsBuilder = ({ streamNum }) => {
|
||||
return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] }
|
||||
}
|
||||
|
||||
export function getDefaultAvailableEncoders () {
|
||||
return {
|
||||
vod: {
|
||||
libx264: {
|
||||
default: defaultX264VODOptionsBuilder
|
||||
},
|
||||
aac: {
|
||||
default: defaultAACOptionsBuilder
|
||||
},
|
||||
libfdk_aac: {
|
||||
default: defaultLibFDKAACVODOptionsBuilder
|
||||
}
|
||||
},
|
||||
live: {
|
||||
libx264: {
|
||||
default: defaultX264LiveOptionsBuilder
|
||||
},
|
||||
aac: {
|
||||
default: defaultAACOptionsBuilder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultEncodersToTry () {
|
||||
return {
|
||||
vod: {
|
||||
video: [ 'libx264' ],
|
||||
audio: [ 'libfdk_aac', 'aac' ]
|
||||
},
|
||||
|
||||
live: {
|
||||
video: [ 'libx264' ],
|
||||
audio: [ 'libfdk_aac', 'aac' ]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
|
||||
const parsedAudio = await getAudioStream(path, probe)
|
||||
|
||||
if (!parsedAudio.audioStream) return true
|
||||
|
||||
if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
|
||||
|
||||
const audioBitrate = parsedAudio.bitrate
|
||||
if (!audioBitrate) return false
|
||||
|
||||
const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
|
||||
if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
|
||||
|
||||
const channelLayout = parsedAudio.audioStream['channel_layout']
|
||||
// Causes playback issues with Chrome
|
||||
if (!channelLayout || channelLayout === 'unknown' || channelLayout === 'quad') return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
|
||||
const videoStream = await getVideoStream(path, probe)
|
||||
const fps = await getVideoStreamFPS(path, probe)
|
||||
const bitRate = await getVideoStreamBitrate(path, probe)
|
||||
const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
|
||||
|
||||
// If ffprobe did not manage to guess the bitrate
|
||||
if (!bitRate) return false
|
||||
|
||||
// check video params
|
||||
if (!videoStream) return false
|
||||
if (videoStream['codec_name'] !== 'h264') return false
|
||||
if (videoStream['pix_fmt'] !== 'yuv420p') return false
|
||||
if (fps < 2 || fps > 65) return false
|
||||
if (bitRate > getMaxTheoreticalBitrate({ ...resolutionData, fps })) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTargetBitrate (options: {
|
||||
inputBitrate: number
|
||||
resolution: number
|
||||
ratio: number
|
||||
fps: number
|
||||
}) {
|
||||
const { inputBitrate, resolution, ratio, fps } = options
|
||||
|
||||
const capped = capBitrate(inputBitrate, getAverageTheoreticalBitrate({ resolution, fps, ratio }))
|
||||
const limit = getMinTheoreticalBitrate({ resolution, fps, ratio })
|
||||
|
||||
return Math.max(limit, capped)
|
||||
}
|
||||
|
||||
function capBitrate (inputBitrate: number, targetBitrate: number) {
|
||||
if (!inputBitrate) return targetBitrate
|
||||
|
||||
// Add 30% margin to input bitrate
|
||||
const inputBitrateWithMargin = inputBitrate + (inputBitrate * 0.3)
|
||||
|
||||
return Math.min(targetBitrate, inputBitrateWithMargin)
|
||||
}
|
||||
|
||||
function getCommonOutputOptions (targetBitrate: number, streamNum?: number) {
|
||||
return [
|
||||
`-preset veryfast`,
|
||||
`${buildStreamSuffix('-maxrate:v', streamNum)} ${targetBitrate}`,
|
||||
`${buildStreamSuffix('-bufsize:v', streamNum)} ${targetBitrate * 2}`,
|
||||
|
||||
// NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
|
||||
`-b_strategy 1`,
|
||||
// NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
|
||||
`-bf 16`
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { FilterSpecification } from 'fluent-ffmpeg'
|
||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||
import { presetVOD } from './shared/presets.js'
|
||||
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js'
|
||||
|
||||
export class FFmpegEdition {
|
||||
private readonly commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||
}
|
||||
|
||||
async cutVideo (options: {
|
||||
inputPath: string
|
||||
outputPath: string
|
||||
start?: number
|
||||
end?: number
|
||||
}) {
|
||||
const { inputPath, outputPath } = options
|
||||
|
||||
const mainProbe = await ffprobePromise(inputPath)
|
||||
const fps = await getVideoStreamFPS(inputPath, mainProbe)
|
||||
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
|
||||
|
||||
const command = this.commandWrapper.buildCommand(inputPath)
|
||||
.output(outputPath)
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
input: inputPath,
|
||||
resolution,
|
||||
fps,
|
||||
canCopyAudio: false,
|
||||
canCopyVideo: false
|
||||
})
|
||||
|
||||
if (options.start) {
|
||||
command.outputOption('-ss ' + options.start)
|
||||
}
|
||||
|
||||
if (options.end) {
|
||||
command.outputOption('-to ' + options.end)
|
||||
}
|
||||
|
||||
await this.commandWrapper.runCommand()
|
||||
}
|
||||
|
||||
async addWatermark (options: {
|
||||
inputPath: string
|
||||
watermarkPath: string
|
||||
outputPath: string
|
||||
|
||||
videoFilters: {
|
||||
watermarkSizeRatio: number
|
||||
horitonzalMarginRatio: number
|
||||
verticalMarginRatio: number
|
||||
}
|
||||
}) {
|
||||
const { watermarkPath, inputPath, outputPath, videoFilters } = options
|
||||
|
||||
const videoProbe = await ffprobePromise(inputPath)
|
||||
const fps = await getVideoStreamFPS(inputPath, videoProbe)
|
||||
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
|
||||
|
||||
const command = this.commandWrapper.buildCommand(inputPath)
|
||||
.output(outputPath)
|
||||
|
||||
command.input(watermarkPath)
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
input: inputPath,
|
||||
resolution,
|
||||
fps,
|
||||
canCopyAudio: true,
|
||||
canCopyVideo: false
|
||||
})
|
||||
|
||||
const complexFilter: FilterSpecification[] = [
|
||||
// Scale watermark
|
||||
{
|
||||
inputs: [ '[1]', '[0]' ],
|
||||
filter: 'scale2ref',
|
||||
options: {
|
||||
w: 'oh*mdar',
|
||||
h: `ih*${videoFilters.watermarkSizeRatio}`
|
||||
},
|
||||
outputs: [ '[watermark]', '[video]' ]
|
||||
},
|
||||
|
||||
{
|
||||
inputs: [ '[video]', '[watermark]' ],
|
||||
filter: 'overlay',
|
||||
options: {
|
||||
x: `main_w - overlay_w - (main_h * ${videoFilters.horitonzalMarginRatio})`,
|
||||
y: `main_h * ${videoFilters.verticalMarginRatio}`
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
command.complexFilter(complexFilter)
|
||||
|
||||
await this.commandWrapper.runCommand()
|
||||
}
|
||||
|
||||
async addIntroOutro (options: {
|
||||
inputPath: string
|
||||
introOutroPath: string
|
||||
outputPath: string
|
||||
type: 'intro' | 'outro'
|
||||
}) {
|
||||
const { introOutroPath, inputPath, outputPath, type } = options
|
||||
|
||||
const mainProbe = await ffprobePromise(inputPath)
|
||||
const fps = await getVideoStreamFPS(inputPath, mainProbe)
|
||||
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
|
||||
const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
|
||||
|
||||
const introOutroProbe = await ffprobePromise(introOutroPath)
|
||||
const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
|
||||
|
||||
const command = this.commandWrapper.buildCommand(inputPath)
|
||||
.output(outputPath)
|
||||
|
||||
command.input(introOutroPath)
|
||||
|
||||
if (!introOutroHasAudio && mainHasAudio) {
|
||||
const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
|
||||
|
||||
command.input('anullsrc')
|
||||
command.withInputFormat('lavfi')
|
||||
command.withInputOption('-t ' + duration)
|
||||
}
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
input: inputPath,
|
||||
resolution,
|
||||
fps,
|
||||
canCopyAudio: false,
|
||||
canCopyVideo: false
|
||||
})
|
||||
|
||||
// Add black background to correctly scale intro/outro with padding
|
||||
const complexFilter: FilterSpecification[] = [
|
||||
{
|
||||
inputs: [ '1', '0' ],
|
||||
filter: 'scale2ref',
|
||||
options: {
|
||||
w: 'iw',
|
||||
h: `ih`
|
||||
},
|
||||
outputs: [ 'intro-outro', 'main' ]
|
||||
},
|
||||
{
|
||||
inputs: [ 'intro-outro', 'main' ],
|
||||
filter: 'scale2ref',
|
||||
options: {
|
||||
w: 'iw',
|
||||
h: `ih`
|
||||
},
|
||||
outputs: [ 'to-scale', 'main' ]
|
||||
},
|
||||
{
|
||||
inputs: 'to-scale',
|
||||
filter: 'drawbox',
|
||||
options: {
|
||||
t: 'fill'
|
||||
},
|
||||
outputs: [ 'to-scale-bg' ]
|
||||
},
|
||||
{
|
||||
inputs: [ '1', 'to-scale-bg' ],
|
||||
filter: 'scale2ref',
|
||||
options: {
|
||||
w: 'iw',
|
||||
h: 'ih',
|
||||
force_original_aspect_ratio: 'decrease',
|
||||
flags: 'spline'
|
||||
},
|
||||
outputs: [ 'to-scale', 'to-scale-bg' ]
|
||||
},
|
||||
{
|
||||
inputs: [ 'to-scale-bg', 'to-scale' ],
|
||||
filter: 'overlay',
|
||||
options: {
|
||||
x: '(main_w - overlay_w)/2',
|
||||
y: '(main_h - overlay_h)/2'
|
||||
},
|
||||
outputs: 'intro-outro-resized'
|
||||
}
|
||||
]
|
||||
|
||||
const concatFilter = {
|
||||
inputs: [],
|
||||
filter: 'concat',
|
||||
options: {
|
||||
n: 2,
|
||||
v: 1,
|
||||
unsafe: 1
|
||||
},
|
||||
outputs: [ 'v' ]
|
||||
}
|
||||
|
||||
const introOutroFilterInputs = [ 'intro-outro-resized' ]
|
||||
const mainFilterInputs = [ 'main' ]
|
||||
|
||||
if (mainHasAudio) {
|
||||
mainFilterInputs.push('0:a')
|
||||
|
||||
if (introOutroHasAudio) {
|
||||
introOutroFilterInputs.push('1:a')
|
||||
} else {
|
||||
// Silent input
|
||||
introOutroFilterInputs.push('2:a')
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'intro') {
|
||||
concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ]
|
||||
} else {
|
||||
concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ]
|
||||
}
|
||||
|
||||
if (mainHasAudio) {
|
||||
concatFilter.options['a'] = 1
|
||||
concatFilter.outputs.push('a')
|
||||
|
||||
command.outputOption('-map [a]')
|
||||
}
|
||||
|
||||
command.outputOption('-map [v]')
|
||||
|
||||
complexFilter.push(concatFilter)
|
||||
command.complexFilter(complexFilter)
|
||||
|
||||
await this.commandWrapper.runCommand()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { MutexInterface } from 'async-mutex'
|
||||
import { FfprobeData } from 'fluent-ffmpeg'
|
||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||
import { getVideoStreamDuration } from './ffprobe.js'
|
||||
|
||||
export class FFmpegImage {
|
||||
private readonly commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||
}
|
||||
|
||||
convertWebPToJPG (options: {
|
||||
path: string
|
||||
destination: string
|
||||
}): Promise<void> {
|
||||
const { path, destination } = options
|
||||
|
||||
this.commandWrapper.buildCommand(path)
|
||||
.output(destination)
|
||||
|
||||
return this.commandWrapper.runCommand({ silent: true })
|
||||
}
|
||||
|
||||
processGIF (options: {
|
||||
path: string
|
||||
destination: string
|
||||
newSize: { width: number, height: number }
|
||||
}): Promise<void> {
|
||||
const { path, destination, newSize } = options
|
||||
|
||||
this.commandWrapper.buildCommand(path)
|
||||
.fps(20)
|
||||
.size(`${newSize.width}x${newSize.height}`)
|
||||
.output(destination)
|
||||
|
||||
return this.commandWrapper.runCommand()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async generateThumbnailFromVideo (options: {
|
||||
fromPath: string
|
||||
output: string
|
||||
framesToAnalyze: number
|
||||
scale?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
ffprobe?: FfprobeData
|
||||
}) {
|
||||
const { fromPath, ffprobe } = options
|
||||
|
||||
let duration = await getVideoStreamDuration(fromPath, ffprobe)
|
||||
if (isNaN(duration)) duration = 0
|
||||
|
||||
this.buildGenerateThumbnailFromVideo(options)
|
||||
.seekInput(duration / 2)
|
||||
|
||||
try {
|
||||
return await this.commandWrapper.runCommand()
|
||||
} catch (err) {
|
||||
this.commandWrapper.debugLog('Cannot generate thumbnail from video using seek input, fallback to no seek', { err })
|
||||
|
||||
this.commandWrapper.resetCommand()
|
||||
|
||||
this.buildGenerateThumbnailFromVideo(options)
|
||||
|
||||
return this.commandWrapper.runCommand()
|
||||
}
|
||||
}
|
||||
|
||||
private buildGenerateThumbnailFromVideo (options: {
|
||||
fromPath: string
|
||||
output: string
|
||||
framesToAnalyze: number
|
||||
scale?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const { fromPath, output, framesToAnalyze, scale } = options
|
||||
|
||||
const command = this.commandWrapper.buildCommand(fromPath)
|
||||
.videoFilter('thumbnail=' + framesToAnalyze)
|
||||
.outputOption('-frames:v 1')
|
||||
.outputOption('-q:v 5')
|
||||
.outputOption('-abort_on empty_output')
|
||||
.output(output)
|
||||
|
||||
if (scale) {
|
||||
command.videoFilter(`scale=${scale.width}x${scale.height}:force_original_aspect_ratio=decrease`)
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async generateStoryboardFromVideo (options: {
|
||||
path: string
|
||||
destination: string
|
||||
|
||||
// Will be released after the ffmpeg started
|
||||
inputFileMutexReleaser: MutexInterface.Releaser
|
||||
|
||||
sprites: {
|
||||
size: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
count: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
duration: number
|
||||
}
|
||||
}) {
|
||||
const { path, destination, sprites } = options
|
||||
|
||||
const command = this.commandWrapper.buildCommand(path)
|
||||
|
||||
const filter = [
|
||||
// Fix "t" variable with some videos
|
||||
`setpts='N/FRAME_RATE/TB'`,
|
||||
// First frame or the time difference between the last and the current frame is enough for our sprite interval
|
||||
`select='isnan(prev_selected_t)+gte(t-prev_selected_t,${options.sprites.duration})'`,
|
||||
`scale=${sprites.size.width}:${sprites.size.height}`,
|
||||
`tile=layout=${sprites.count.width}x${sprites.count.height}`
|
||||
].join(',')
|
||||
|
||||
command.outputOption('-filter_complex', filter)
|
||||
command.outputOption('-frames:v', '1')
|
||||
command.outputOption('-q:v', '2')
|
||||
command.output(destination)
|
||||
|
||||
return this.commandWrapper.runCommand()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { FfprobeData, FilterSpecification } from 'fluent-ffmpeg'
|
||||
import { join } from 'path'
|
||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||
import { StreamType, buildStreamSuffix, getScaleFilter } from './ffmpeg-utils.js'
|
||||
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared/index.js'
|
||||
|
||||
export class FFmpegLive {
|
||||
private readonly commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||
}
|
||||
|
||||
async getLiveTranscodingCommand (options: {
|
||||
inputUrl: string
|
||||
|
||||
outPath: string
|
||||
masterPlaylistName: string
|
||||
|
||||
toTranscode: {
|
||||
resolution: number
|
||||
fps: number
|
||||
}[]
|
||||
|
||||
// Input information
|
||||
bitrate: number
|
||||
ratio: number
|
||||
hasAudio: boolean
|
||||
probe: FfprobeData
|
||||
|
||||
segmentListSize: number
|
||||
segmentDuration: number
|
||||
}) {
|
||||
const {
|
||||
inputUrl,
|
||||
outPath,
|
||||
toTranscode,
|
||||
bitrate,
|
||||
masterPlaylistName,
|
||||
ratio,
|
||||
hasAudio,
|
||||
probe
|
||||
} = options
|
||||
const command = this.commandWrapper.buildCommand(inputUrl)
|
||||
|
||||
const varStreamMap: string[] = []
|
||||
|
||||
const complexFilter: FilterSpecification[] = [
|
||||
{
|
||||
inputs: '[v:0]',
|
||||
filter: 'split',
|
||||
options: toTranscode.length,
|
||||
outputs: toTranscode.map(t => `vtemp${t.resolution}`)
|
||||
}
|
||||
]
|
||||
|
||||
command.outputOption('-sc_threshold 0')
|
||||
|
||||
addDefaultEncoderGlobalParams(command)
|
||||
|
||||
for (let i = 0; i < toTranscode.length; i++) {
|
||||
const streamMap: string[] = []
|
||||
const { resolution, fps } = toTranscode[i]
|
||||
|
||||
const baseEncoderBuilderParams = {
|
||||
input: inputUrl,
|
||||
|
||||
canCopyAudio: true,
|
||||
canCopyVideo: true,
|
||||
|
||||
inputBitrate: bitrate,
|
||||
inputRatio: ratio,
|
||||
inputProbe: probe,
|
||||
|
||||
resolution,
|
||||
fps,
|
||||
|
||||
streamNum: i,
|
||||
videoType: 'live' as 'live'
|
||||
}
|
||||
|
||||
{
|
||||
const streamType: StreamType = 'video'
|
||||
|
||||
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
||||
if (!builderResult) {
|
||||
throw new Error('No available live video encoder found')
|
||||
}
|
||||
|
||||
command.outputOption(`-map [vout${resolution}]`)
|
||||
|
||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
|
||||
|
||||
this.commandWrapper.debugLog(
|
||||
`Apply ffmpeg live video params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
|
||||
{ builderResult, fps, toTranscode }
|
||||
)
|
||||
|
||||
command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
|
||||
applyEncoderOptions(command, builderResult.result)
|
||||
|
||||
complexFilter.push({
|
||||
inputs: `vtemp${resolution}`,
|
||||
filter: getScaleFilter(builderResult.result),
|
||||
options: `w=-2:h=${resolution}`,
|
||||
outputs: `vout${resolution}`
|
||||
})
|
||||
|
||||
streamMap.push(`v:${i}`)
|
||||
}
|
||||
|
||||
if (hasAudio) {
|
||||
const streamType: StreamType = 'audio'
|
||||
|
||||
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
||||
if (!builderResult) {
|
||||
throw new Error('No available live audio encoder found')
|
||||
}
|
||||
|
||||
command.outputOption('-map a:0')
|
||||
|
||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
|
||||
|
||||
this.commandWrapper.debugLog(
|
||||
`Apply ffmpeg live audio params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
|
||||
{ builderResult, fps, resolution }
|
||||
)
|
||||
|
||||
command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
|
||||
applyEncoderOptions(command, builderResult.result)
|
||||
|
||||
streamMap.push(`a:${i}`)
|
||||
}
|
||||
|
||||
varStreamMap.push(streamMap.join(','))
|
||||
}
|
||||
|
||||
command.complexFilter(complexFilter)
|
||||
|
||||
this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
|
||||
|
||||
command.outputOption('-var_stream_map', varStreamMap.join(' '))
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
getLiveMuxingCommand (options: {
|
||||
inputUrl: string
|
||||
outPath: string
|
||||
masterPlaylistName: string
|
||||
|
||||
segmentListSize: number
|
||||
segmentDuration: number
|
||||
}) {
|
||||
const { inputUrl, outPath, masterPlaylistName } = options
|
||||
|
||||
const command = this.commandWrapper.buildCommand(inputUrl)
|
||||
|
||||
command.outputOption('-c:v copy')
|
||||
command.outputOption('-c:a copy')
|
||||
command.outputOption('-map 0:a?')
|
||||
command.outputOption('-map 0:v?')
|
||||
|
||||
this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
private addDefaultLiveHLSParams (options: {
|
||||
outPath: string
|
||||
masterPlaylistName: string
|
||||
segmentListSize: number
|
||||
segmentDuration: number
|
||||
}) {
|
||||
const { outPath, masterPlaylistName, segmentListSize, segmentDuration } = options
|
||||
|
||||
const command = this.commandWrapper.getCommand()
|
||||
|
||||
command.outputOption('-hls_time ' + segmentDuration)
|
||||
command.outputOption('-hls_list_size ' + segmentListSize)
|
||||
command.outputOption('-hls_flags delete_segments+independent_segments+program_date_time')
|
||||
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
|
||||
command.outputOption('-master_pl_name ' + masterPlaylistName)
|
||||
command.outputOption(`-f hls`)
|
||||
|
||||
command.output(join(outPath, '%v.m3u8'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { EncoderOptions } from '@peertube/peertube-models'
|
||||
|
||||
export type StreamType = 'audio' | 'video'
|
||||
|
||||
export function buildStreamSuffix (base: string, streamNum?: number) {
|
||||
if (streamNum !== undefined) {
|
||||
return `${base}:${streamNum}`
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
export function getScaleFilter (options: EncoderOptions): string {
|
||||
if (options.scaleFilter) return options.scaleFilter.name
|
||||
|
||||
return 'scale'
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { exec } from 'child_process'
|
||||
import ffmpeg from 'fluent-ffmpeg'
|
||||
|
||||
/**
|
||||
* @returns FFmpeg version string. Usually a semver string, but may vary when depending on installation method.
|
||||
*/
|
||||
export function getFFmpegVersion () {
|
||||
return new Promise<string>((res, rej) => {
|
||||
(ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
|
||||
if (err) return rej(err)
|
||||
if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
|
||||
|
||||
return exec(`${ffmpegPath} -version`, (err, stdout) => {
|
||||
if (err) return rej(err)
|
||||
|
||||
const parsed = stdout.match(/(?<=ffmpeg version )[a-zA-Z\d.-]+/)
|
||||
if (!parsed) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
|
||||
|
||||
res(parsed[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { VideoResolution } from '@peertube/peertube-models'
|
||||
import { MutexInterface } from 'async-mutex'
|
||||
import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { dirname } from 'path'
|
||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||
import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js'
|
||||
import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets.js'
|
||||
|
||||
export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio'
|
||||
|
||||
export interface BaseTranscodeVODOptions {
|
||||
type: TranscodeVODOptionsType
|
||||
|
||||
inputPath: string
|
||||
outputPath: string
|
||||
|
||||
// Will be released after the ffmpeg started
|
||||
// To prevent a bug where the input file does not exist anymore when running ffmpeg
|
||||
inputFileMutexReleaser: MutexInterface.Releaser
|
||||
|
||||
resolution: number
|
||||
fps: number
|
||||
}
|
||||
|
||||
export interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
|
||||
type: 'hls'
|
||||
|
||||
copyCodecs: boolean
|
||||
|
||||
hlsPlaylist: {
|
||||
videoFilename: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
|
||||
type: 'hls-from-ts'
|
||||
|
||||
isAAC: boolean
|
||||
|
||||
hlsPlaylist: {
|
||||
videoFilename: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
|
||||
type: 'quick-transcode'
|
||||
}
|
||||
|
||||
export interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
|
||||
type: 'video'
|
||||
}
|
||||
|
||||
export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
|
||||
type: 'merge-audio'
|
||||
audioPath: string
|
||||
}
|
||||
|
||||
export type TranscodeVODOptions =
|
||||
HLSTranscodeOptions
|
||||
| HLSFromTSTranscodeOptions
|
||||
| VideoTranscodeOptions
|
||||
| MergeAudioTranscodeOptions
|
||||
| QuickTranscodeOptions
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class FFmpegVOD {
|
||||
private readonly commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
private ended = false
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||
}
|
||||
|
||||
async transcode (options: TranscodeVODOptions) {
|
||||
const builders: {
|
||||
[ type in TranscodeVODOptionsType ]: (options: TranscodeVODOptions) => Promise<void> | void
|
||||
} = {
|
||||
'quick-transcode': this.buildQuickTranscodeCommand.bind(this),
|
||||
'hls': this.buildHLSVODCommand.bind(this),
|
||||
'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this),
|
||||
'merge-audio': this.buildAudioMergeCommand.bind(this),
|
||||
'video': this.buildWebVideoCommand.bind(this)
|
||||
}
|
||||
|
||||
this.commandWrapper.debugLog('Will run transcode.', { options })
|
||||
|
||||
this.commandWrapper.buildCommand(options.inputPath, options.inputFileMutexReleaser)
|
||||
.output(options.outputPath)
|
||||
|
||||
await builders[options.type](options)
|
||||
|
||||
await this.commandWrapper.runCommand()
|
||||
|
||||
await this.fixHLSPlaylistIfNeeded(options)
|
||||
|
||||
this.ended = true
|
||||
}
|
||||
|
||||
isEnded () {
|
||||
return this.ended
|
||||
}
|
||||
|
||||
private async buildWebVideoCommand (options: TranscodeVODOptions & { canCopyAudio?: boolean, canCopyVideo?: boolean }) {
|
||||
const { resolution, fps, inputPath, canCopyAudio = true, canCopyVideo = true } = options
|
||||
|
||||
if (resolution === VideoResolution.H_NOVIDEO) {
|
||||
presetOnlyAudio(this.commandWrapper)
|
||||
return
|
||||
}
|
||||
|
||||
let scaleFilterValue: string
|
||||
|
||||
if (resolution !== undefined) {
|
||||
const probe = await ffprobePromise(inputPath)
|
||||
const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe)
|
||||
|
||||
scaleFilterValue = videoStreamInfo?.isPortraitMode === true
|
||||
? `w=${resolution}:h=-2`
|
||||
: `w=-2:h=${resolution}`
|
||||
}
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
|
||||
resolution,
|
||||
input: inputPath,
|
||||
canCopyAudio,
|
||||
canCopyVideo,
|
||||
fps,
|
||||
scaleFilterValue
|
||||
})
|
||||
}
|
||||
|
||||
private buildQuickTranscodeCommand (_options: TranscodeVODOptions) {
|
||||
const command = this.commandWrapper.getCommand()
|
||||
|
||||
presetCopy(this.commandWrapper)
|
||||
|
||||
command.outputOption('-map_metadata -1') // strip all metadata
|
||||
.outputOption('-movflags faststart')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio transcoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async buildAudioMergeCommand (options: MergeAudioTranscodeOptions) {
|
||||
const command = this.commandWrapper.getCommand()
|
||||
|
||||
command.loop(undefined)
|
||||
|
||||
await presetVOD({
|
||||
...pick(options, [ 'resolution' ]),
|
||||
|
||||
commandWrapper: this.commandWrapper,
|
||||
input: options.audioPath,
|
||||
canCopyAudio: true,
|
||||
canCopyVideo: true,
|
||||
fps: options.fps,
|
||||
scaleFilterValue: this.getMergeAudioScaleFilterValue()
|
||||
})
|
||||
|
||||
command.outputOption('-preset:v veryfast')
|
||||
|
||||
command.input(options.audioPath)
|
||||
.outputOption('-tune stillimage')
|
||||
.outputOption('-shortest')
|
||||
}
|
||||
|
||||
// Avoid "height not divisible by 2" error
|
||||
private getMergeAudioScaleFilterValue () {
|
||||
return 'trunc(iw/2)*2:trunc(ih/2)*2'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HLS transcoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async buildHLSVODCommand (options: HLSTranscodeOptions) {
|
||||
const command = this.commandWrapper.getCommand()
|
||||
|
||||
const videoPath = this.getHLSVideoPath(options)
|
||||
|
||||
if (options.copyCodecs) {
|
||||
presetCopy(this.commandWrapper)
|
||||
} else if (options.resolution === VideoResolution.H_NOVIDEO) {
|
||||
presetOnlyAudio(this.commandWrapper)
|
||||
} else {
|
||||
// If we cannot copy codecs, we do not copy them at all to prevent issues like audio desync
|
||||
// See for example https://github.com/Chocobozzz/PeerTube/issues/6438
|
||||
await this.buildWebVideoCommand({ ...options, canCopyAudio: false, canCopyVideo: false })
|
||||
}
|
||||
|
||||
this.addCommonHLSVODCommandOptions(command, videoPath)
|
||||
}
|
||||
|
||||
private buildHLSVODFromTSCommand (options: HLSFromTSTranscodeOptions) {
|
||||
const command = this.commandWrapper.getCommand()
|
||||
|
||||
const videoPath = this.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')
|
||||
}
|
||||
|
||||
this.addCommonHLSVODCommandOptions(command, videoPath)
|
||||
}
|
||||
|
||||
private 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')
|
||||
}
|
||||
|
||||
private async 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 = this.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)
|
||||
}
|
||||
|
||||
private getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
|
||||
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'
|
||||
import { buildAspectRatio, forceNumber } from '@peertube/peertube-core-utils'
|
||||
import { VideoResolution } from '@peertube/peertube-models'
|
||||
|
||||
/**
|
||||
*
|
||||
* Helpers to run ffprobe and extract data from the JSON output
|
||||
*
|
||||
*/
|
||||
|
||||
function ffprobePromise (path: string) {
|
||||
return new Promise<FfprobeData>((res, rej) => {
|
||||
ffmpeg.ffprobe(path, [ '-show_chapters' ], (err, data) => {
|
||||
if (err) return rej(err)
|
||||
|
||||
return res(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const imageCodecs = new Set([
|
||||
'ansi', 'apng', 'bintext', 'bmp', 'brender_pix', 'dpx', 'exr', 'fits', 'gem', 'gif', 'jpeg2000', 'jpgls', 'mjpeg', 'mjpegb', 'msp2',
|
||||
'pam', 'pbm', 'pcx', 'pfm', 'pgm', 'pgmyuv', 'pgx', 'photocd', 'pictor', 'png', 'ppm', 'psd', 'sgi', 'sunrast', 'svg', 'targa', 'tiff',
|
||||
'txd', 'webp', 'xbin', 'xbm', 'xface', 'xpm', 'xwd'
|
||||
])
|
||||
|
||||
async function isAudioFile (path: string, existingProbe?: FfprobeData) {
|
||||
const videoStream = await getVideoStream(path, existingProbe)
|
||||
if (!videoStream) return true
|
||||
|
||||
if (imageCodecs.has(videoStream.codec_name)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function hasAudioStream (path: string, existingProbe?: FfprobeData) {
|
||||
const { audioStream } = await getAudioStream(path, existingProbe)
|
||||
|
||||
return !!audioStream
|
||||
}
|
||||
|
||||
async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) {
|
||||
// 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
|
||||
const data = existingProbe || await ffprobePromise(videoPath)
|
||||
|
||||
if (Array.isArray(data.streams)) {
|
||||
const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio')
|
||||
|
||||
if (audioStream) {
|
||||
return {
|
||||
absolutePath: data.format.filename,
|
||||
audioStream,
|
||||
bitrate: forceNumber(audioStream['bit_rate'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { absolutePath: data.format.filename }
|
||||
}
|
||||
|
||||
function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) {
|
||||
const maxKBitrate = 384
|
||||
const kToBits = (kbits: number) => kbits * 1000
|
||||
|
||||
// If we did not manage to get the bitrate, use an average value
|
||||
if (!bitrate) return 256
|
||||
|
||||
if (type === 'aac') {
|
||||
switch (true) {
|
||||
case bitrate > kToBits(maxKBitrate):
|
||||
return maxKBitrate
|
||||
|
||||
default:
|
||||
return -1 // we interpret it as a signal to copy the audio stream as is
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac.
|
||||
That's why, when using aac, we can go to lower kbit/sec. The equivalences
|
||||
made here are not made to be accurate, especially with good mp3 encoders.
|
||||
*/
|
||||
switch (true) {
|
||||
case bitrate <= kToBits(192):
|
||||
return 128
|
||||
|
||||
case bitrate <= kToBits(384):
|
||||
return 256
|
||||
|
||||
default:
|
||||
return maxKBitrate
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getVideoStreamDimensionsInfo (path: string, existingProbe?: FfprobeData) {
|
||||
const videoStream = await getVideoStream(path, existingProbe)
|
||||
if (!videoStream) {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
ratio: 0,
|
||||
resolution: VideoResolution.H_NOVIDEO,
|
||||
isPortraitMode: false
|
||||
}
|
||||
}
|
||||
|
||||
if (videoStream.rotation === '90' || videoStream.rotation === '-90') {
|
||||
const width = videoStream.width
|
||||
videoStream.width = videoStream.height
|
||||
videoStream.height = width
|
||||
}
|
||||
|
||||
return {
|
||||
width: videoStream.width,
|
||||
height: videoStream.height,
|
||||
ratio: buildAspectRatio({ width: videoStream.width, height: videoStream.height }),
|
||||
resolution: Math.min(videoStream.height, videoStream.width),
|
||||
isPortraitMode: videoStream.height > videoStream.width
|
||||
}
|
||||
}
|
||||
|
||||
async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) {
|
||||
const videoStream = await getVideoStream(path, existingProbe)
|
||||
if (!videoStream) return 0
|
||||
|
||||
for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
|
||||
const valuesText: string = videoStream[key]
|
||||
if (!valuesText) continue
|
||||
|
||||
const [ frames, seconds ] = valuesText.split('/')
|
||||
if (!frames || !seconds) continue
|
||||
|
||||
const result = parseInt(frames, 10) / parseInt(seconds, 10)
|
||||
if (result > 0) return Math.round(result)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise<number> {
|
||||
const metadata = existingProbe || await ffprobePromise(path)
|
||||
|
||||
let bitrate = metadata.format.bit_rate
|
||||
if (bitrate && !isNaN(bitrate)) return bitrate
|
||||
|
||||
const videoStream = await getVideoStream(path, existingProbe)
|
||||
if (!videoStream) return undefined
|
||||
|
||||
bitrate = forceNumber(videoStream?.bit_rate)
|
||||
if (bitrate && !isNaN(bitrate)) return bitrate
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) {
|
||||
const metadata = existingProbe || await ffprobePromise(path)
|
||||
|
||||
return Math.round(metadata.format.duration)
|
||||
}
|
||||
|
||||
async function getVideoStream (path: string, existingProbe?: FfprobeData) {
|
||||
const metadata = existingProbe || await ffprobePromise(path)
|
||||
|
||||
return metadata.streams.find(s => s.codec_type === 'video')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getChaptersFromContainer (options: {
|
||||
path: string
|
||||
maxTitleLength: number
|
||||
ffprobe?: FfprobeData
|
||||
}) {
|
||||
const { path, maxTitleLength, ffprobe } = options
|
||||
|
||||
const metadata = ffprobe || await ffprobePromise(path)
|
||||
|
||||
if (!Array.isArray(metadata?.chapters)) return []
|
||||
|
||||
return metadata.chapters
|
||||
.map(c => ({
|
||||
timecode: Math.round(c.start_time),
|
||||
title: (c['TAG:title'] || '').slice(0, maxTitleLength)
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
getVideoStreamDimensionsInfo,
|
||||
getChaptersFromContainer,
|
||||
getMaxAudioBitrate,
|
||||
getVideoStream,
|
||||
getVideoStreamDuration,
|
||||
getAudioStream,
|
||||
getVideoStreamFPS,
|
||||
isAudioFile,
|
||||
ffprobePromise,
|
||||
getVideoStreamBitrate,
|
||||
hasAudioStream
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './ffmpeg-command-wrapper.js'
|
||||
export * from './ffmpeg-default-transcoding-profile.js'
|
||||
export * from './ffmpeg-edition.js'
|
||||
export * from './ffmpeg-images.js'
|
||||
export * from './ffmpeg-live.js'
|
||||
export * from './ffmpeg-utils.js'
|
||||
export * from './ffmpeg-version.js'
|
||||
export * from './ffmpeg-vod.js'
|
||||
export * from './ffprobe.js'
|
||||
@@ -0,0 +1,36 @@
|
||||
import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||
import { EncoderOptions } from '@peertube/peertube-models'
|
||||
import { buildStreamSuffix } from '../ffmpeg-utils.js'
|
||||
|
||||
export function addDefaultEncoderGlobalParams (command: FfmpegCommand) {
|
||||
// avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
|
||||
command.outputOption('-max_muxing_queue_size 1024')
|
||||
// strip all metadata
|
||||
.outputOption('-map_metadata -1')
|
||||
// allows import of source material with incompatible pixel formats (e.g. MJPEG video)
|
||||
.outputOption('-pix_fmt yuv420p')
|
||||
}
|
||||
|
||||
export function addDefaultEncoderParams (options: {
|
||||
command: FfmpegCommand
|
||||
encoder: 'libx264' | string
|
||||
fps: number
|
||||
|
||||
streamNum?: number
|
||||
}) {
|
||||
const { command, encoder, fps, streamNum } = options
|
||||
|
||||
if (encoder === 'libx264') {
|
||||
if (fps) {
|
||||
// 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
|
||||
command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions) {
|
||||
command.inputOptions(options.inputOptions ?? [])
|
||||
.outputOptions(options.outputOptions ?? [])
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './encoder-options.js'
|
||||
export * from './presets.js'
|
||||
@@ -0,0 +1,94 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { FFmpegCommandWrapper } from '../ffmpeg-command-wrapper.js'
|
||||
import { getScaleFilter, StreamType } from '../ffmpeg-utils.js'
|
||||
import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '../ffprobe.js'
|
||||
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './encoder-options.js'
|
||||
|
||||
export async function presetVOD (options: {
|
||||
commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
input: string
|
||||
|
||||
canCopyAudio: boolean
|
||||
canCopyVideo: boolean
|
||||
|
||||
resolution: number
|
||||
fps: number
|
||||
|
||||
scaleFilterValue?: string
|
||||
}) {
|
||||
const { commandWrapper, input, resolution, fps, scaleFilterValue } = options
|
||||
const command = commandWrapper.getCommand()
|
||||
|
||||
command.format('mp4')
|
||||
.outputOption('-movflags faststart')
|
||||
|
||||
addDefaultEncoderGlobalParams(command)
|
||||
|
||||
const probe = await ffprobePromise(input)
|
||||
|
||||
// Audio encoder
|
||||
const bitrate = await getVideoStreamBitrate(input, probe)
|
||||
const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
|
||||
|
||||
let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
|
||||
|
||||
if (!await hasAudioStream(input, probe)) {
|
||||
command.noAudio()
|
||||
streamsToProcess = [ 'video' ]
|
||||
}
|
||||
|
||||
for (const streamType of streamsToProcess) {
|
||||
const builderResult = await commandWrapper.getEncoderBuilderResult({
|
||||
...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]),
|
||||
|
||||
input,
|
||||
inputBitrate: bitrate,
|
||||
inputRatio: videoStreamDimensions?.ratio || 0,
|
||||
inputProbe: probe,
|
||||
|
||||
resolution,
|
||||
fps,
|
||||
streamType,
|
||||
|
||||
videoType: 'vod' as 'vod'
|
||||
})
|
||||
|
||||
if (!builderResult) {
|
||||
throw new Error('No available encoder found for stream ' + streamType)
|
||||
}
|
||||
|
||||
commandWrapper.debugLog(
|
||||
`Apply ffmpeg params from ${builderResult.encoder} for ${streamType} ` +
|
||||
`stream of input ${input} using ${commandWrapper.getProfile()} profile.`,
|
||||
{ builderResult, resolution, fps }
|
||||
)
|
||||
|
||||
if (streamType === 'video') {
|
||||
command.videoCodec(builderResult.encoder)
|
||||
|
||||
if (scaleFilterValue) {
|
||||
command.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
|
||||
}
|
||||
} else if (streamType === 'audio') {
|
||||
command.audioCodec(builderResult.encoder)
|
||||
}
|
||||
|
||||
applyEncoderOptions(command, builderResult.result)
|
||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps })
|
||||
}
|
||||
}
|
||||
|
||||
export function presetCopy (commandWrapper: FFmpegCommandWrapper) {
|
||||
commandWrapper.getCommand()
|
||||
.format('mp4')
|
||||
.videoCodec('copy')
|
||||
.audioCodec('copy')
|
||||
}
|
||||
|
||||
export function presetOnlyAudio (commandWrapper: FFmpegCommandWrapper) {
|
||||
commandWrapper.getCommand()
|
||||
.format('mp4')
|
||||
.audioCodec('copy')
|
||||
.noVideo()
|
||||
}
|
||||
新しい課題から参照
ユーザをブロックする