はじまりの大地

このコミットが含まれているのは:
2024-07-15 09:14:04 +09:00
コミット 6632905f32
3501個のファイルの変更1439465行の追加0行の削除
+19
ファイルの表示
@@ -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": {}
}
+257
ファイルの表示
@@ -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
}
}
+184
ファイルの表示
@@ -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`
]
}
+239
ファイルの表示
@@ -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()
}
}
+141
ファイルの表示
@@ -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()
}
}
+189
ファイルの表示
@@ -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'))
}
}
+17
ファイルの表示
@@ -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
ファイルの表示
@@ -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])
})
})
})
}
+245
ファイルの表示
@@ -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}`
}
}
+213
ファイルの表示
@@ -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
}
+9
ファイルの表示
@@ -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'
+36
ファイルの表示
@@ -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 ?? [])
}
+2
ファイルの表示
@@ -0,0 +1,2 @@
export * from './encoder-options.js'
export * from './presets.js'
+94
ファイルの表示
@@ -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()
}
+12
ファイルの表示
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"references": [
{ "path": "../models" },
{ "path": "../core-utils" }
]
}