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((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}`)) }) }) }