Init commit
This commit is contained in:
19
packages/ffmpeg/package.json
Normal file
19
packages/ffmpeg/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@peertube/peertube-ffmpeg",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.js",
|
||||
"files": [ "dist" ],
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"peertube:tsx": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
269
packages/ffmpeg/src/ffmpeg-command-wrapper.ts
Normal file
269
packages/ffmpeg/src/ffmpeg-command-wrapper.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { arrayify, 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'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
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 (inputs: (string | Readable)[] | string | Readable, 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({
|
||||
niceness: this.niceness,
|
||||
cwd: this.tmpDirectory
|
||||
})
|
||||
|
||||
for (const input of arrayify(inputs)) {
|
||||
this.command.input(input)
|
||||
}
|
||||
|
||||
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: Error & { stdout?: string, stderr?: string }, stdout, stderr) => {
|
||||
if (silent !== true) this.logger.error('Error in ffmpeg.', { err, stdout, stderr, shellCommand, ...this.lTags })
|
||||
|
||||
err.stdout = stdout
|
||||
err.stderr = stderr
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
45
packages/ffmpeg/src/ffmpeg-container.ts
Normal file
45
packages/ffmpeg/src/ffmpeg-container.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Readable, Writable } from 'stream'
|
||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||
|
||||
export class FFmpegContainer {
|
||||
private readonly commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||
}
|
||||
|
||||
mergeInputs (options: {
|
||||
inputs: (Readable | string)[]
|
||||
output: Writable
|
||||
logError: boolean
|
||||
|
||||
coverPath?: string
|
||||
}) {
|
||||
const { inputs, output, logError, coverPath } = options
|
||||
|
||||
const command = this.commandWrapper.buildCommand(inputs)
|
||||
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
command.outputOption('-map ' + i)
|
||||
}
|
||||
|
||||
if (coverPath) {
|
||||
command.addInput(coverPath)
|
||||
command.outputOption('-map ' + inputs.length)
|
||||
}
|
||||
|
||||
command.outputOption('-c copy')
|
||||
.outputOption('-movflags frag_every_frame+empty_moov')
|
||||
.outputOption('-min_frag_duration 5M') // 5 seconds
|
||||
.format('mp4')
|
||||
.output(output)
|
||||
|
||||
return this.commandWrapper.runCommand({ silent: !logError })
|
||||
}
|
||||
|
||||
forceKill () {
|
||||
if (!this.commandWrapper) return
|
||||
|
||||
this.commandWrapper.getCommand().kill('SIGKILL')
|
||||
}
|
||||
}
|
||||
206
packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts
Normal file
206
packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, getMinTheoreticalBitrate } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
buildStreamSuffix,
|
||||
ffprobePromise,
|
||||
getAudioStream,
|
||||
getMaxAudioKBitrate,
|
||||
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 = getMaxAudioKBitrate(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 = getMaxAudioKBitrate('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, maxFPS: number, probe?: FfprobeData): Promise<boolean> {
|
||||
const videoStream = await getVideoStream(path, probe)
|
||||
if (!videoStream) return true
|
||||
|
||||
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 > maxFPS) return false
|
||||
if (bitRate > getMaxTheoreticalBitrate({ ...resolutionData, fps })) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Copy codecs if the input file can be quick transcoded (appropriate bitrate, codecs, etc.)
|
||||
// And if the input resolution/fps are the same as the output resolution/fps
|
||||
export async function canCopyForHLS (options: {
|
||||
path: string
|
||||
fps: number
|
||||
resolution: number
|
||||
}, probe?: FfprobeData): Promise<boolean> {
|
||||
const { path, fps, resolution } = options
|
||||
|
||||
const inputProbe = probe ?? await ffprobePromise(path)
|
||||
const { resolution: inputResolution } = await getVideoStreamDimensionsInfo(path, inputProbe)
|
||||
const inputFPS = await getVideoStreamFPS(path, inputProbe)
|
||||
|
||||
return await canDoQuickAudioTranscode(path, probe) &&
|
||||
await canDoQuickVideoTranscode(path, fps, probe) &&
|
||||
resolution === inputResolution &&
|
||||
(!inputResolution || fps === inputFPS)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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`
|
||||
]
|
||||
}
|
||||
279
packages/ffmpeg/src/ffmpeg-edition.ts
Normal file
279
packages/ffmpeg/src/ffmpeg-edition.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { MutexInterface } from 'async-mutex'
|
||||
import { FilterSpecification } from 'fluent-ffmpeg'
|
||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js'
|
||||
import { presetVOD } from './shared/presets.js'
|
||||
|
||||
type BaseStudioOptions = {
|
||||
videoInputPath: string
|
||||
separatedAudioInputPath?: 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
|
||||
}
|
||||
|
||||
export class FFmpegEdition {
|
||||
private readonly commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||
}
|
||||
|
||||
async cutVideo (
|
||||
options: BaseStudioOptions & {
|
||||
start?: number
|
||||
end?: number
|
||||
}
|
||||
) {
|
||||
const { videoInputPath, separatedAudioInputPath, outputPath, inputFileMutexReleaser } = options
|
||||
|
||||
const mainProbe = await ffprobePromise(videoInputPath)
|
||||
const fps = await getVideoStreamFPS(videoInputPath, mainProbe)
|
||||
const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, mainProbe)
|
||||
|
||||
const command = this.commandWrapper.buildCommand(this.buildInputs(options), inputFileMutexReleaser)
|
||||
.output(outputPath)
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
|
||||
videoInputPath,
|
||||
separatedAudioInputPath,
|
||||
|
||||
resolution,
|
||||
videoStreamOnly: false,
|
||||
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: BaseStudioOptions & {
|
||||
watermarkPath: string
|
||||
|
||||
videoFilters: {
|
||||
watermarkSizeRatio: number
|
||||
horitonzalMarginRatio: number
|
||||
verticalMarginRatio: number
|
||||
}
|
||||
}
|
||||
) {
|
||||
const { watermarkPath, videoInputPath, separatedAudioInputPath, outputPath, videoFilters, inputFileMutexReleaser } = options
|
||||
|
||||
const videoProbe = await ffprobePromise(videoInputPath)
|
||||
const fps = await getVideoStreamFPS(videoInputPath, videoProbe)
|
||||
const { resolution, height } = await getVideoStreamDimensionsInfo(videoInputPath, videoProbe)
|
||||
|
||||
const command = this.commandWrapper.buildCommand([ ...this.buildInputs(options), watermarkPath ], inputFileMutexReleaser)
|
||||
.output(outputPath)
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
|
||||
videoInputPath,
|
||||
separatedAudioInputPath,
|
||||
|
||||
resolution,
|
||||
videoStreamOnly: false,
|
||||
fps,
|
||||
canCopyAudio: true,
|
||||
canCopyVideo: false
|
||||
})
|
||||
|
||||
const videoInput = 0
|
||||
const imageInput = separatedAudioInputPath
|
||||
? 2
|
||||
: 1
|
||||
|
||||
const complexFilter: FilterSpecification[] = [
|
||||
// Scale watermark
|
||||
{
|
||||
inputs: [ `[${imageInput}]` ],
|
||||
filter: 'scale',
|
||||
options: {
|
||||
w: '-1',
|
||||
h: `${height * videoFilters.watermarkSizeRatio}`
|
||||
},
|
||||
outputs: [ '[watermark]' ]
|
||||
},
|
||||
|
||||
{
|
||||
inputs: [ `[${videoInput}]`, '[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: BaseStudioOptions & {
|
||||
introOutroPath: string
|
||||
|
||||
type: 'intro' | 'outro'
|
||||
}
|
||||
) {
|
||||
const { introOutroPath, videoInputPath, separatedAudioInputPath, outputPath, type, inputFileMutexReleaser } = options
|
||||
|
||||
const mainProbe = await ffprobePromise(videoInputPath)
|
||||
const fps = await getVideoStreamFPS(videoInputPath, mainProbe)
|
||||
const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, mainProbe)
|
||||
|
||||
const mainHasAudio = await hasAudioStream(separatedAudioInputPath || videoInputPath)
|
||||
|
||||
const introOutroProbe = await ffprobePromise(introOutroPath)
|
||||
const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
|
||||
|
||||
const command = this.commandWrapper.buildCommand([ ...this.buildInputs(options), introOutroPath ], inputFileMutexReleaser)
|
||||
.output(outputPath)
|
||||
|
||||
const videoInput = 0
|
||||
|
||||
const audioInput = separatedAudioInputPath
|
||||
? videoInput + 1
|
||||
: videoInput
|
||||
|
||||
const introInput = audioInput + 1
|
||||
let introAudio = introInput
|
||||
|
||||
if (!introOutroHasAudio && mainHasAudio) {
|
||||
const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
|
||||
|
||||
command.input('anullsrc')
|
||||
command.withInputFormat('lavfi')
|
||||
command.withInputOption('-t ' + duration)
|
||||
|
||||
introAudio++
|
||||
}
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
|
||||
videoInputPath,
|
||||
separatedAudioInputPath,
|
||||
|
||||
resolution,
|
||||
videoStreamOnly: false,
|
||||
fps,
|
||||
canCopyAudio: false,
|
||||
canCopyVideo: false
|
||||
})
|
||||
|
||||
// Add black background to correctly scale intro/outro with padding
|
||||
const complexFilter: FilterSpecification[] = [
|
||||
{
|
||||
inputs: [ `${introInput}`, `${videoInput}` ],
|
||||
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: [ `${introInput}`, '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(`${audioInput}:a`)
|
||||
|
||||
introOutroFilterInputs.push(`${introAudio}: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()
|
||||
}
|
||||
|
||||
private buildInputs (options: {
|
||||
videoInputPath: string
|
||||
separatedAudioInputPath?: string
|
||||
}) {
|
||||
return [ options.videoInputPath, options.separatedAudioInputPath ].filter(i => !!i)
|
||||
}
|
||||
}
|
||||
130
packages/ffmpeg/src/ffmpeg-images.ts
Normal file
130
packages/ffmpeg/src/ffmpeg-images.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
||||
|
||||
processImage (options: {
|
||||
path: string
|
||||
destination: string
|
||||
newSize?: { width: number, height: number }
|
||||
}): Promise<void> {
|
||||
const { path, destination, newSize } = options
|
||||
|
||||
const command = this.commandWrapper.buildCommand(path)
|
||||
|
||||
if (newSize) command.size(`${newSize.width ?? '?'}x${newSize.height ?? '?'}`)
|
||||
|
||||
command.output(destination)
|
||||
|
||||
return this.commandWrapper.runCommand({ silent: true })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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, inputFileMutexReleaser, sprites } = options
|
||||
|
||||
const command = this.commandWrapper.buildCommand(path, inputFileMutexReleaser)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
263
packages/ffmpeg/src/ffmpeg-live.ts
Normal file
263
packages/ffmpeg/src/ffmpeg-live.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { VideoResolution } from '@peertube/peertube-models'
|
||||
import { FfmpegCommand, 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'
|
||||
|
||||
type LiveTranscodingOptions = {
|
||||
inputUrl: string
|
||||
|
||||
outPath: string
|
||||
masterPlaylistName: string
|
||||
|
||||
toTranscode: {
|
||||
resolution: number
|
||||
fps: number
|
||||
}[]
|
||||
|
||||
// Input information
|
||||
bitrate: number
|
||||
ratio: number
|
||||
hasAudio: boolean
|
||||
hasVideo: boolean
|
||||
probe: FfprobeData
|
||||
|
||||
segmentListSize: number
|
||||
segmentDuration: number
|
||||
|
||||
splitAudioAndVideo: boolean
|
||||
}
|
||||
|
||||
export class FFmpegLive {
|
||||
private readonly commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||
}
|
||||
|
||||
async getLiveTranscodingCommand (options: LiveTranscodingOptions) {
|
||||
this.commandWrapper.debugLog('Building live transcoding command', options)
|
||||
|
||||
const {
|
||||
inputUrl,
|
||||
outPath,
|
||||
toTranscode,
|
||||
masterPlaylistName,
|
||||
hasAudio,
|
||||
splitAudioAndVideo
|
||||
} = options
|
||||
|
||||
const command = this.commandWrapper.buildCommand(inputUrl)
|
||||
|
||||
let varStreamMap: string[] = []
|
||||
|
||||
command.outputOption('-sc_threshold 0')
|
||||
|
||||
addDefaultEncoderGlobalParams(command)
|
||||
|
||||
// Audio input only or audio output only
|
||||
if (this.isAudioInputOrOutputOnly(options)) {
|
||||
const result = await this.buildTranscodingStream({
|
||||
...options,
|
||||
|
||||
command,
|
||||
resolution: toTranscode[0].resolution,
|
||||
fps: toTranscode[0].fps,
|
||||
streamNum: 0,
|
||||
// No need to add complexity to the m3u8 playlist, we just provide 1 audio variant stream
|
||||
splitAudioAndVideo: false,
|
||||
streamType: 'audio'
|
||||
})
|
||||
|
||||
varStreamMap = varStreamMap.concat(result.varStreamMap)
|
||||
varStreamMap.push(result.streamMap.join(','))
|
||||
} else {
|
||||
// Do not mix video with audio only playlist
|
||||
// Audio only input/output is already taken into account above
|
||||
const toTranscodeWithoutAudioOnly = toTranscode.filter(t => t.resolution !== VideoResolution.H_NOVIDEO)
|
||||
|
||||
let complexFilter: FilterSpecification[] = [
|
||||
{
|
||||
inputs: '[v:0]',
|
||||
filter: 'split',
|
||||
options: toTranscodeWithoutAudioOnly.length,
|
||||
outputs: toTranscodeWithoutAudioOnly.map(t => `vtemp${t.resolution}`)
|
||||
}
|
||||
]
|
||||
|
||||
let alreadyProcessedAudio = false
|
||||
|
||||
for (let i = 0; i < toTranscodeWithoutAudioOnly.length; i++) {
|
||||
let streamMap: string[] = []
|
||||
|
||||
const { resolution, fps } = toTranscodeWithoutAudioOnly[i]
|
||||
|
||||
for (const streamType of [ 'audio' as 'audio', 'video' as 'video' ]) {
|
||||
if (streamType === 'audio') {
|
||||
if (!hasAudio || (splitAudioAndVideo && alreadyProcessedAudio)) continue
|
||||
|
||||
alreadyProcessedAudio = true
|
||||
}
|
||||
|
||||
const result = await this.buildTranscodingStream({ ...options, command, resolution, fps, streamNum: i, streamType })
|
||||
varStreamMap = varStreamMap.concat(result.varStreamMap)
|
||||
streamMap = streamMap.concat(result.streamMap)
|
||||
complexFilter = complexFilter.concat(result.complexFilter)
|
||||
}
|
||||
|
||||
if (streamMap.length !== 0) {
|
||||
varStreamMap.push(streamMap.join(','))
|
||||
}
|
||||
}
|
||||
|
||||
command.complexFilter(complexFilter)
|
||||
}
|
||||
|
||||
this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
|
||||
|
||||
command.outputOption('-var_stream_map', varStreamMap.join(' '))
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
private isAudioInputOrOutputOnly (options: Pick<LiveTranscodingOptions, 'hasAudio' | 'hasVideo' | 'toTranscode'>) {
|
||||
const { hasAudio, hasVideo, toTranscode } = options
|
||||
|
||||
if (hasAudio && !hasVideo) return true
|
||||
if (toTranscode.length === 1 && toTranscode[0].resolution === VideoResolution.H_NOVIDEO) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private async buildTranscodingStream (
|
||||
options: Pick<LiveTranscodingOptions, 'inputUrl' | 'bitrate' | 'ratio' | 'probe' | 'hasAudio' | 'splitAudioAndVideo'> & {
|
||||
command: FfmpegCommand
|
||||
resolution: number
|
||||
fps: number
|
||||
streamNum: number
|
||||
streamType: StreamType
|
||||
}
|
||||
) {
|
||||
const { inputUrl, bitrate, ratio, probe, splitAudioAndVideo, command, resolution, fps, streamNum, streamType, hasAudio } = options
|
||||
|
||||
const baseEncoderBuilderParams = {
|
||||
input: inputUrl,
|
||||
|
||||
canCopyAudio: true,
|
||||
canCopyVideo: true,
|
||||
|
||||
inputBitrate: bitrate,
|
||||
inputRatio: ratio,
|
||||
inputProbe: probe,
|
||||
|
||||
resolution,
|
||||
fps,
|
||||
|
||||
streamNum,
|
||||
videoType: 'live' as 'live'
|
||||
}
|
||||
|
||||
const streamMap: string[] = []
|
||||
const varStreamMap: string[] = []
|
||||
const complexFilter: FilterSpecification[] = []
|
||||
|
||||
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
||||
if (!builderResult) {
|
||||
throw new Error(`No available live ${streamType} encoder found`)
|
||||
}
|
||||
|
||||
if (streamType === 'audio') {
|
||||
command.outputOption('-map a:0')
|
||||
} else {
|
||||
command.outputOption(`-map [vout${resolution}]`)
|
||||
}
|
||||
|
||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum })
|
||||
|
||||
this.commandWrapper.debugLog(
|
||||
`Apply ffmpeg live ${streamType} params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
|
||||
{ builderResult, fps, resolution }
|
||||
)
|
||||
|
||||
if (streamType === 'audio') {
|
||||
command.outputOption(`${buildStreamSuffix('-c:a', streamNum)} ${builderResult.encoder}`)
|
||||
|
||||
if (splitAudioAndVideo) {
|
||||
varStreamMap.push(`a:${streamNum},agroup:Audio,default:yes`)
|
||||
} else {
|
||||
streamMap.push(`a:${streamNum}`)
|
||||
}
|
||||
} else {
|
||||
command.outputOption(`${buildStreamSuffix('-c:v', streamNum)} ${builderResult.encoder}`)
|
||||
|
||||
complexFilter.push({
|
||||
inputs: `vtemp${resolution}`,
|
||||
filter: getScaleFilter(builderResult.result),
|
||||
options: `w=-2:h=${resolution}`,
|
||||
outputs: `vout${resolution}`
|
||||
})
|
||||
|
||||
if (splitAudioAndVideo) {
|
||||
const suffix = hasAudio
|
||||
? `,agroup:Audio`
|
||||
: ''
|
||||
|
||||
varStreamMap.push(`v:${streamNum}${suffix}`)
|
||||
} else {
|
||||
streamMap.push(`v:${streamNum}`)
|
||||
}
|
||||
}
|
||||
|
||||
applyEncoderOptions(command, builderResult.result)
|
||||
|
||||
return { varStreamMap, streamMap, complexFilter }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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+temp_file')
|
||||
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'))
|
||||
}
|
||||
}
|
||||
17
packages/ffmpeg/src/ffmpeg-utils.ts
Normal file
17
packages/ffmpeg/src/ffmpeg-utils.ts
Normal file
@@ -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'
|
||||
}
|
||||
23
packages/ffmpeg/src/ffmpeg-version.ts
Normal file
23
packages/ffmpeg/src/ffmpeg-version.ts
Normal file
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
271
packages/ffmpeg/src/ffmpeg-vod.ts
Normal file
271
packages/ffmpeg/src/ffmpeg-vod.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
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, presetVOD } from './shared/presets.js'
|
||||
|
||||
export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio'
|
||||
|
||||
export interface BaseTranscodeVODOptions {
|
||||
type: TranscodeVODOptionsType
|
||||
|
||||
videoInputPath: string
|
||||
separatedAudioInputPath?: 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
|
||||
separatedAudio: 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.buildVODCommand.bind(this)
|
||||
}
|
||||
|
||||
this.commandWrapper.debugLog('Will run transcode.', { options })
|
||||
|
||||
const inputPaths = [ options.videoInputPath, options.separatedAudioInputPath ].filter(e => !!e)
|
||||
|
||||
this.commandWrapper.buildCommand(inputPaths, 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 buildVODCommand (
|
||||
options: TranscodeVODOptions & {
|
||||
videoStreamOnly?: boolean
|
||||
canCopyAudio?: boolean
|
||||
canCopyVideo?: boolean
|
||||
}
|
||||
) {
|
||||
const {
|
||||
resolution,
|
||||
fps,
|
||||
videoInputPath,
|
||||
separatedAudioInputPath,
|
||||
videoStreamOnly = false,
|
||||
canCopyAudio = true,
|
||||
canCopyVideo = true
|
||||
} = options
|
||||
|
||||
let scaleFilterValue: string
|
||||
|
||||
if (resolution) {
|
||||
const probe = await ffprobePromise(videoInputPath)
|
||||
const videoStreamInfo = await getVideoStreamDimensionsInfo(videoInputPath, probe)
|
||||
|
||||
scaleFilterValue = videoStreamInfo?.isPortraitMode === true
|
||||
? `w=${resolution}:h=-2`
|
||||
: `w=-2:h=${resolution}`
|
||||
}
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
|
||||
resolution,
|
||||
videoStreamOnly,
|
||||
|
||||
videoInputPath,
|
||||
separatedAudioInputPath,
|
||||
|
||||
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,
|
||||
videoInputPath: options.audioPath,
|
||||
canCopyAudio: true,
|
||||
canCopyVideo: true,
|
||||
videoStreamOnly: false,
|
||||
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, {
|
||||
withAudio: !options.separatedAudio || !options.resolution,
|
||||
withVideo: !options.separatedAudio || !!options.resolution
|
||||
})
|
||||
} 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.buildVODCommand({
|
||||
...options,
|
||||
|
||||
canCopyAudio: false,
|
||||
canCopyVideo: false,
|
||||
videoStreamOnly: options.separatedAudio && !!options.resolution
|
||||
})
|
||||
}
|
||||
|
||||
this.addCommonHLSVODCommandOptions(command, videoPath)
|
||||
}
|
||||
|
||||
private buildHLSVODFromTSCommand (options: HLSFromTSTranscodeOptions) {
|
||||
const command = this.commandWrapper.getCommand()
|
||||
|
||||
const videoPath = this.getHLSVideoPath(options)
|
||||
|
||||
command.outputOption('-c copy')
|
||||
command.outputOption('-copyts')
|
||||
|
||||
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}`
|
||||
}
|
||||
}
|
||||
227
packages/ffmpeg/src/ffprobe.ts
Normal file
227
packages/ffmpeg/src/ffprobe.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { buildAspectRatio, forceNumber } from '@peertube/peertube-core-utils'
|
||||
import { VideoResolution } from '@peertube/peertube-models'
|
||||
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'
|
||||
|
||||
/**
|
||||
* Helpers to run ffprobe and extract data from the JSON output
|
||||
*/
|
||||
|
||||
export 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'
|
||||
])
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export async function hasAudioStream (path: string, existingProbe?: FfprobeData) {
|
||||
const { audioStream } = await getAudioStream(path, existingProbe)
|
||||
|
||||
return !!audioStream
|
||||
}
|
||||
|
||||
export async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) {
|
||||
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 }
|
||||
}
|
||||
|
||||
export function getMaxAudioKBitrate (type: string, bitrate: number) {
|
||||
const maxKBitrate = 384
|
||||
|
||||
// If we did not manage to get the bitrate, use an average value
|
||||
if (!bitrate) {
|
||||
// We expect uploader wants a high quality audio if the input is in FLAC format
|
||||
if (type === 'flac') return maxKBitrate
|
||||
|
||||
return 256
|
||||
}
|
||||
|
||||
const kBitrate = Math.round(bitrate / 1000)
|
||||
|
||||
if (type === 'aac') {
|
||||
if (kBitrate > maxKBitrate) return maxKBitrate
|
||||
|
||||
// We interpret it as a signal to copy the audio stream as is
|
||||
return -1
|
||||
}
|
||||
|
||||
return Math.min(maxKBitrate, kBitrate)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export 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
|
||||
}
|
||||
}
|
||||
|
||||
const rotation = videoStream.rotation
|
||||
? videoStream.rotation + ''
|
||||
: undefined
|
||||
|
||||
if (rotation === '90' || 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
|
||||
}
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) {
|
||||
const metadata = existingProbe || await ffprobePromise(path)
|
||||
|
||||
return Math.round(metadata.format.duration)
|
||||
}
|
||||
|
||||
export async function getVideoStream (path: string, existingProbe?: FfprobeData) {
|
||||
const metadata = existingProbe || await ffprobePromise(path)
|
||||
|
||||
return metadata.streams.find(s => s.codec_type === 'video')
|
||||
}
|
||||
|
||||
export async function hasVideoStream (path: string, existingProbe?: FfprobeData) {
|
||||
const videoStream = await getVideoStream(path, existingProbe)
|
||||
|
||||
return !!videoStream
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export 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)
|
||||
}))
|
||||
}
|
||||
10
packages/ffmpeg/src/index.ts
Normal file
10
packages/ffmpeg/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './ffmpeg-command-wrapper.js'
|
||||
export * from './ffmpeg-container.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'
|
||||
36
packages/ffmpeg/src/shared/encoder-options.ts
Normal file
36
packages/ffmpeg/src/shared/encoder-options.ts
Normal file
@@ -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: 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 ?? [])
|
||||
}
|
||||
2
packages/ffmpeg/src/shared/index.ts
Normal file
2
packages/ffmpeg/src/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './encoder-options.js'
|
||||
export * from './presets.js'
|
||||
116
packages/ffmpeg/src/shared/presets.ts
Normal file
116
packages/ffmpeg/src/shared/presets.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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
|
||||
|
||||
videoInputPath: string
|
||||
separatedAudioInputPath?: string
|
||||
|
||||
canCopyAudio: boolean
|
||||
canCopyVideo: boolean
|
||||
|
||||
resolution: number
|
||||
fps: number
|
||||
|
||||
videoStreamOnly: boolean
|
||||
|
||||
scaleFilterValue?: string
|
||||
}) {
|
||||
const { commandWrapper, videoInputPath, separatedAudioInputPath, resolution, fps, videoStreamOnly, scaleFilterValue } = options
|
||||
|
||||
if (videoStreamOnly && !resolution) {
|
||||
throw new Error('Cannot generate video stream only without valid resolution')
|
||||
}
|
||||
|
||||
const command = commandWrapper.getCommand()
|
||||
|
||||
command.format('mp4')
|
||||
.outputOption('-movflags faststart')
|
||||
|
||||
addDefaultEncoderGlobalParams(command)
|
||||
|
||||
const videoProbe = await ffprobePromise(videoInputPath)
|
||||
const audioProbe = separatedAudioInputPath
|
||||
? await ffprobePromise(separatedAudioInputPath)
|
||||
: videoProbe
|
||||
|
||||
// Audio encoder
|
||||
const bitrate = await getVideoStreamBitrate(videoInputPath, videoProbe)
|
||||
const videoStreamDimensions = await getVideoStreamDimensionsInfo(videoInputPath, videoProbe)
|
||||
|
||||
let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
|
||||
|
||||
if (videoStreamOnly || !await hasAudioStream(separatedAudioInputPath || videoInputPath, audioProbe)) {
|
||||
command.noAudio()
|
||||
streamsToProcess = [ 'video' ]
|
||||
} else if (!resolution) {
|
||||
command.noVideo()
|
||||
streamsToProcess = [ 'audio' ]
|
||||
}
|
||||
|
||||
for (const streamType of streamsToProcess) {
|
||||
const input = streamType === 'video'
|
||||
? videoInputPath
|
||||
: separatedAudioInputPath || videoInputPath
|
||||
|
||||
const builderResult = await commandWrapper.getEncoderBuilderResult({
|
||||
...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]),
|
||||
|
||||
input,
|
||||
inputProbe: streamType === 'video'
|
||||
? videoProbe
|
||||
: audioProbe,
|
||||
|
||||
inputBitrate: bitrate,
|
||||
inputRatio: videoStreamDimensions?.ratio || 0,
|
||||
|
||||
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, options: {
|
||||
withAudio?: boolean // default true
|
||||
withVideo?: boolean // default true
|
||||
} = {}) {
|
||||
const command = commandWrapper.getCommand()
|
||||
|
||||
command.format('mp4')
|
||||
|
||||
if (options.withAudio === false) command.noAudio()
|
||||
else command.audioCodec('copy')
|
||||
|
||||
if (options.withVideo === false) command.noVideo()
|
||||
else command.videoCodec('copy')
|
||||
}
|
||||
12
packages/ffmpeg/tsconfig.json
Normal file
12
packages/ffmpeg/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "src",
|
||||
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../models" },
|
||||
{ "path": "../core-utils" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user