teqo-backup/scripts/lib/reelFfmpeg.mjs

417 lines
12 KiB
JavaScript

/**
* C196 — ffmpeg plumbing of the reel composer: binary resolution with a
* fail-closed capability probe, the per-scene clip command (zoompan + burned
* caption overlays) and the concat that stitches the scenes into `reel.mp4`.
*
* Resolution order (see `docs/plans/reels-tutoriais-impl.md`, Decisão 2):
* `FFMPEG_PATH` → `ffmpeg` on PATH → the registry-packaged
* `@ffmpeg-installer/ffmpeg` binary. The packed fallback keeps the
* non-technical operator flow working on a workstation without a system
* ffmpeg; the homeserver Docker build installs it from npm (never from GitHub
* releases, which the homeserver cannot reach — see the sharp override note in
* `pnpm-workspace.yaml`).
*/
import { execFile } from 'node:child_process'
import { writeFile } from 'node:fs/promises'
import { promisify } from 'node:util'
import { NARRATION_PCM } from './reelScript.mjs'
import { seconds } from './reelTimeline.mjs'
const execFileAsync = promisify(execFile)
const MAX_BUFFER_BYTES = 16 * 1024 * 1024
const REQUIRED_FILTERS = ['fade', 'overlay', 'scale', 'zoompan']
const AUDIO_REQUIRED_ENCODER = 'libmp3lame'
const AUDIO_REQUIRED_FILTERS = ['atempo']
export const REEL_PRESET = 'medium'
export const REEL_CRF = 18
export const PREVIEW_PRESET = 'ultrafast'
export const PREVIEW_CRF = 14
/**
* @typedef {{ z: string, x: string, y: string, inputWidth: number, inputHeight: number }} ZoomLike
*/
/**
* @typedef {(bin: string, args: string[]) => Promise<{ ok: boolean, stdout: string, stderr: string, error?: { code?: string } }>} RunFn
*/
/**
* Generic process runner shared by the reel tools (ffmpeg and edge-tts): never
* throws, returns the exit outcome.
*
* @type {RunFn}
*/
export const runProcess = async (bin, args) => {
try {
const { stdout, stderr } = await execFileAsync(bin, args, { maxBuffer: MAX_BUFFER_BYTES })
return { ok: true, stdout, stderr }
} catch (error) {
return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '', error }
}
}
const defaultRun = runProcess
const versionLine = (stdout) =>
stdout.split('\n').find((line) => line.startsWith('ffmpeg version')) ?? ''
/**
* Capability probe: the binary must encode H.264 (libx264) and ship the
* filters the composer uses. Missing capabilities are reported, never assumed.
*
* @param {string} bin
* @param {{ run?: RunFn, audio?: boolean }} [options]
* @returns {Promise<{ ok: true, bin: string, version: string } | { ok: false, bin: string, reason: string }>}
*/
export const probeFfmpeg = async (bin, { run = defaultRun, audio = false } = {}) => {
const version = await run(bin, ['-hide_banner', '-version'])
if (!version.ok)
return { ok: false, bin, reason: `não executou (${version.error?.code ?? 'erro'})` }
const encoders = await run(bin, ['-hide_banner', '-encoders'])
if (!encoders.ok || !/\blibx264\b/.test(encoders.stdout)) {
return { ok: false, bin, reason: 'sem encoder libx264' }
}
if (audio && !new RegExp(`\\b${AUDIO_REQUIRED_ENCODER}\\b`).test(encoders.stdout)) {
return { ok: false, bin, reason: `sem encoder ${AUDIO_REQUIRED_ENCODER}` }
}
const filters = await run(bin, ['-hide_banner', '-filters'])
if (!filters.ok) return { ok: false, bin, reason: 'não listou filtros' }
const requiredFilters = audio
? [...REQUIRED_FILTERS, ...AUDIO_REQUIRED_FILTERS]
: REQUIRED_FILTERS
const missing = requiredFilters.filter(
(filter) => !new RegExp(`\\b${filter}\\b`).test(filters.stdout),
)
if (missing.length > 0) return { ok: false, bin, reason: `sem filtro(s): ${missing.join(', ')}` }
return { ok: true, bin, version: versionLine(version.stdout) }
}
const installHint =
'Defina FFMPEG_PATH, instale o ffmpeg (ex.: `sudo apt install ffmpeg`) ou rode `pnpm install` para o binário empacotado.'
/**
* Resolves the first candidate that passes the probe. Explicit `FFMPEG_PATH` is strict.
*
* @param {{ env?: Record<string, string | undefined>, run?: RunFn, installer?: { path?: string } | null, audio?: boolean }} [options]
* @returns {Promise<{ ok: true, bin: string, version: string, source: string }>}
*/
export const resolveFfmpeg = async ({
env = process.env,
run = defaultRun,
installer,
audio = false,
} = {}) => {
let packaged = installer
if (packaged === undefined) {
try {
packaged = (await import('@ffmpeg-installer/ffmpeg')).default
} catch {
// The packaged binary throws on import when its platform package is
// missing; the probe below decides, never the import.
packaged = null
}
}
const candidates = []
const explicit = env.FFMPEG_PATH?.trim()
if (explicit) candidates.push({ bin: explicit, source: 'FFMPEG_PATH', strict: true })
candidates.push({ bin: 'ffmpeg', source: 'PATH', strict: false })
if (packaged?.path) candidates.push({ bin: packaged.path, source: 'empacotado', strict: false })
const failures = []
for (const candidate of candidates) {
const probe = await probeFfmpeg(candidate.bin, { run, audio })
if (probe.ok) {
return { ok: true, bin: probe.bin, version: probe.version, source: candidate.source }
}
failures.push(`${candidate.source}: ${probe.reason}`)
if (candidate.strict) break
}
throw new Error(
`Nenhum ffmpeg utilizável (libx264 + filtros do reel${audio ? ' + áudio' : ''}). ${failures.join('; ')}. ${installHint}`,
)
}
/**
* Runs a process and fails with the stderr tail — never swallows the exit
* code. Shared by the reel tools (ffmpeg and edge-tts); `runFfmpeg` is the
* ffmpeg-labelled alias kept for the historical call sites.
*
* @param {string} bin
* @param {string[]} args
* @param {{ run?: RunFn, label?: string }} [options]
*/
export const runTool = async (bin, args, { run = defaultRun, label = 'processo' } = {}) => {
const result = await run(bin, args)
if (!result.ok) {
const tail = (result.stderr || result.error?.message || '').trim().slice(-400)
throw new Error(`${label} falhou${tail ? `: ${tail}` : ''}`)
}
return result
}
/** @type {typeof runTool} */
export const runFfmpeg = (bin, args, options = {}) =>
runTool(bin, args, { label: 'ffmpeg', ...options })
/**
* One scene clip: seek the source, apply the zoom camera (when the scene has
* clicks), burn the caption overlays with their timelines and encode H.264.
* Pure: the caller runs the returned args.
*
* @param {{ input: string, output: string, startMs: number, durationMs: number, zoom?: ZoomLike | null, captions?: Array<{ path: string, startMs: number, endMs: number }>, fadeInMs?: number, fadeOutMs?: number, fps: number, width: number, height: number, preset?: string, crf?: number }} options
* @returns {string[]}
*/
export const buildSceneClipArgs = ({
input,
output,
startMs,
durationMs,
zoom = null,
captions = [],
fadeInMs = 0,
fadeOutMs = 0,
fps,
width,
height,
preset = REEL_PRESET,
crf = REEL_CRF,
}) => {
const filters = []
const videoFilters = [`fps=${fps}`]
if (zoom) {
videoFilters.push(`scale=${zoom.inputWidth}:${zoom.inputHeight}`)
videoFilters.push(
`zoompan=z='${zoom.z}':x='${zoom.x}':y='${zoom.y}':d=1:s=${width}x${height}:fps=${fps}`,
)
} else {
videoFilters.push(`scale=${width}:${height}`)
}
if (fadeInMs > 0) videoFilters.push(`fade=t=in:st=0:d=${seconds(fadeInMs)}`)
if (fadeOutMs > 0) {
videoFilters.push(
`fade=t=out:st=${seconds(Math.max(0, durationMs - fadeOutMs))}:d=${seconds(fadeOutMs)}`,
)
}
filters.push(`[0:v]${videoFilters.join(',')}[v0]`)
captions.forEach((caption, index) => {
filters.push(
`[v${index}][${index + 1}:v]overlay=0:0:enable='between(t,${seconds(caption.startMs)},${seconds(caption.endMs)})'[v${index + 1}]`,
)
})
const captionInputs = captions.flatMap((caption) => [
'-loop',
'1',
'-framerate',
String(fps),
'-i',
caption.path,
])
return [
'-hide_banner',
'-loglevel',
'error',
'-ss',
String(seconds(startMs)),
'-t',
String(seconds(durationMs)),
'-i',
input,
...captionInputs,
'-filter_complex',
filters.join(';'),
'-map',
`[v${captions.length}]`,
'-an',
'-t',
String(seconds(durationMs)),
'-r',
String(fps),
'-c:v',
'libx264',
'-preset',
preset,
'-crf',
String(crf),
'-pix_fmt',
'yuv420p',
'-movflags',
'+faststart',
'-y',
output,
]
}
/** `concat` demuxer list entry; single quotes in paths are escaped. */
const concatListEntry = (path) => `file '${path.replaceAll("'", "'\\''")}'`
export const concatListContent = (paths) => `${paths.map(concatListEntry).join('\n')}\n`
/**
* Offline frame encode: the screencast writes JPEG frames with their capture
* timestamps, and the video is built later from those exact times. Encoding
* after capture keeps ffmpeg's CPU work from backpressuring the browser and
* shifting the timeline (the pipe version drifted; see the impl plan).
*
* `frames`: `[{ file, captureMs }]` in capture order. The last frame is
* repeated so the concat demuxer honors its duration.
*
* @param {{ frames: Array<{ file: string, captureMs: number }>, fps: number }} options
* @returns {string}
*/
export const buildFrameListContent = ({ frames, fps }) => {
const minimum = 1 / fps
const lines = []
frames.forEach((frame, index) => {
const next = frames[index + 1]
const duration = next ? Math.max(0.001, (next.captureMs - frame.captureMs) / 1000) : minimum
lines.push(concatListEntry(frame.file), `duration ${seconds(duration * 1000)}`)
})
if (frames.length > 0) lines.push(concatListEntry(frames[frames.length - 1].file))
return `${lines.join('\n')}\n`
}
/**
* @param {{ ffmpegBin: string, frames: Array<{ file: string, captureMs: number }>, listPath: string, output: string, fps: number, preset?: string, crf?: number, writeFileImpl?: (path: string, content: string) => Promise<unknown>, run?: RunFn }} options
*/
export const encodeFrames = async ({
ffmpegBin,
frames,
listPath,
output,
fps,
preset = PREVIEW_PRESET,
crf = PREVIEW_CRF,
writeFileImpl = writeFile,
run = defaultRun,
}) => {
await writeFileImpl(listPath, buildFrameListContent({ frames, fps }))
await runFfmpeg(
ffmpegBin,
[
'-hide_banner',
'-loglevel',
'error',
'-f',
'concat',
'-safe',
'0',
'-i',
listPath,
'-r',
String(fps),
'-c:v',
'libx264',
'-preset',
preset,
'-crf',
String(crf),
'-pix_fmt',
'yuv420p',
'-movflags',
'+faststart',
'-y',
output,
],
{ run, label: 'encode frames' },
)
}
/** Stream-copy concat of the scene clips (same codec/params) into `reel.mp4`. */
export const buildConcatArgs = ({ listPath, output }) => [
'-hide_banner',
'-loglevel',
'error',
'-f',
'concat',
'-safe',
'0',
'-i',
listPath,
'-c',
'copy',
'-movflags',
'+faststart',
'-y',
output,
]
/**
* C197 — decode one TTS beat (mp3) to the raw narration PCM, optionally
* applying the `atempo` fit in the same pass.
*
* @param {{ input: string, output: string, tempo?: number | null }} options
* @returns {string[]}
*/
export const buildAudioDecodeArgs = ({ input, output, tempo = null }) => [
'-hide_banner',
'-loglevel',
'error',
'-i',
input,
...(tempo && tempo !== 1 ? ['-filter:a', `atempo=${tempo}`] : []),
'-ac',
String(NARRATION_PCM.channels),
'-ar',
String(NARRATION_PCM.sampleRate),
'-c:a',
'pcm_s16le',
'-f',
's16le',
'-y',
output,
]
/** Encode the padded narration PCM track into `narracao.mp3` (mono 128 kbps). */
export const buildNarrationEncodeArgs = ({ input, output }) => [
'-hide_banner',
'-loglevel',
'error',
'-f',
's16le',
'-ar',
String(NARRATION_PCM.sampleRate),
'-ac',
String(NARRATION_PCM.channels),
'-i',
input,
'-c:a',
'libmp3lame',
'-b:a',
'128k',
'-y',
output,
]
/**
* Mux `narracao.mp3` into a copy of `reel.mp4` — the video stream is copied
* bit-for-bit (both variants share the same track) and the audio becomes AAC.
*
* @param {{ video: string, audio: string, output: string }} options
* @returns {string[]}
*/
export const buildMuxAudioVideoArgs = ({ video, audio, output }) => [
'-hide_banner',
'-loglevel',
'error',
'-i',
video,
'-i',
audio,
'-map',
'0:v:0',
'-map',
'1:a:0',
'-c:v',
'copy',
'-c:a',
'aac',
'-b:a',
'128k',
'-shortest',
'-movflags',
'+faststart',
'-y',
output,
]