111 lines
3.1 KiB
TypeScript
111 lines
3.1 KiB
TypeScript
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
|
import { tryAtomicMove } from '@server/helpers/fs.js'
|
|
import { CONFIG } from '@server/initializers/config.js'
|
|
import { remove } from 'fs-extra/esm'
|
|
import { existsSync } from 'node:fs'
|
|
import { dirname } from 'node:path'
|
|
import { spawn } from 'node:child_process'
|
|
|
|
const lTags = loggerTagsFactory('live-videos', 'preprocessed-video')
|
|
|
|
export function getLiveVideoBurnedPath (videoPath: string) {
|
|
return `${videoPath}.burned.mp4`
|
|
}
|
|
|
|
export function getLiveVideoSubtitlePath (videoPath: string) {
|
|
if (existsSync(videoPath + '.vtt')) return videoPath + '.vtt'
|
|
if (existsSync(videoPath + '.srt')) return videoPath + '.srt'
|
|
|
|
return null
|
|
}
|
|
|
|
export function getLiveVideoPlaybackInputPath (videoPath: string) {
|
|
const burnedPath = getLiveVideoBurnedPath(videoPath)
|
|
return existsSync(burnedPath) ? burnedPath : videoPath
|
|
}
|
|
|
|
export async function syncPreprocessedLiveVideo (videoPath: string) {
|
|
const subtitlePath = getLiveVideoSubtitlePath(videoPath)
|
|
const burnedPath = getLiveVideoBurnedPath(videoPath)
|
|
|
|
if (!subtitlePath) {
|
|
await remove(burnedPath).catch(() => undefined)
|
|
return null
|
|
}
|
|
|
|
const tmpOutputPath = `${burnedPath}.tmp.mp4`
|
|
await remove(tmpOutputPath).catch(() => undefined)
|
|
|
|
const ffmpegPath = CONFIG.LIVE.FFMPEG.PATH?.trim() || 'ffmpeg'
|
|
const subtitleFilterPath = escapeSubtitleFilterPath(subtitlePath)
|
|
const ffmpegArgs = [
|
|
'-y',
|
|
'-hide_banner',
|
|
'-loglevel', 'warning',
|
|
'-i', videoPath,
|
|
'-map', '0:v:0',
|
|
'-map', '0:a?',
|
|
'-vf', `subtitles=${subtitleFilterPath}`,
|
|
'-c:v', 'libx264',
|
|
'-preset', 'veryfast',
|
|
'-c:a', 'aac',
|
|
'-movflags', '+faststart',
|
|
tmpOutputPath
|
|
]
|
|
|
|
await runFFmpeg(ffmpegPath, ffmpegArgs, { videoPath, subtitlePath })
|
|
await remove(burnedPath).catch(() => undefined)
|
|
await tryAtomicMove(tmpOutputPath, burnedPath)
|
|
|
|
return burnedPath
|
|
}
|
|
|
|
function escapeSubtitleFilterPath (subtitlePath: string) {
|
|
return subtitlePath
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/:/g, '\\:')
|
|
.replace(/ /g, '\\ ')
|
|
.replace(/\[/g, '\\[')
|
|
.replace(/\]/g, '\\]')
|
|
.replace(/,/g, '\\,')
|
|
.replace(/'/g, "\\'")
|
|
}
|
|
|
|
function runFFmpeg (ffmpegPath: string, ffmpegArgs: string[], meta: { videoPath: string, subtitlePath: string }) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const ffmpegProcess = spawn(ffmpegPath, ffmpegArgs, {
|
|
cwd: dirname(meta.videoPath),
|
|
stdio: [ 'ignore', 'ignore', 'pipe' ]
|
|
})
|
|
|
|
let stderrBuffer = ''
|
|
|
|
ffmpegProcess.stderr.on('data', chunk => {
|
|
const line = chunk?.toString?.() || ''
|
|
stderrBuffer = (stderrBuffer + line).slice(-4_000)
|
|
})
|
|
|
|
ffmpegProcess.once('error', err => {
|
|
reject(err)
|
|
})
|
|
|
|
ffmpegProcess.once('exit', code => {
|
|
if (code === 0) {
|
|
resolve()
|
|
return
|
|
}
|
|
|
|
const stderr = stderrBuffer.trim()
|
|
|
|
logger.error('Cannot preprocess live video with burned subtitles.', {
|
|
code,
|
|
stderr,
|
|
...meta,
|
|
...lTags()
|
|
})
|
|
|
|
reject(new Error(stderr || `ffmpeg exited with code ${code}`))
|
|
})
|
|
})
|
|
}
|