teqo-backup/scripts/lib/reelFfmpeg.mjs

332 lines
8.8 KiB
JavaScript

/**
* C196 — ffmpeg plumbing of the reel composer: the per-scene clip command
* (zoompan + burned caption overlays) and the concat that stitches the scenes
* into `reel.mp4`. The generic binary resolution/probe/runner (C215 moved them
* to `mediaBinaries.mjs`) is re-exported here so the reel tools and their unit
* pins keep the same import surface; the reel requirements (libx264 + the
* composer filters, `atempo` when audio) are injected into the wrappers.
*/
import { writeFile } from 'node:fs/promises'
import {
probeFfmpeg as probeFfmpegBin,
resolveFfmpeg as resolveFfmpegBin,
runProcess,
runTool,
} from './mediaBinaries.mjs'
import { NARRATION_PCM } from './reelScript.mjs'
import { seconds } from './reelTimeline.mjs'
const REQUIRED_FILTERS = ['fade', 'overlay', 'scale', 'zoompan']
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
*/
export { runProcess, runTool }
/**
* 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 = (bin, { audio = false, ...options } = {}) =>
probeFfmpegBin(bin, {
...options,
audio,
encoders: ['libx264'],
filters: [...REQUIRED_FILTERS, ...(audio ? AUDIO_REQUIRED_FILTERS : [])],
})
/**
* Resolves the first candidate that passes the reel 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 = (options = {}) =>
resolveFfmpegBin({
requirementsLabel: `libx264 + filtros do reel${options.audio ? ' + áudio' : ''}`,
...options,
encoders: ['libx264'],
filters: [...REQUIRED_FILTERS, ...(options.audio ? AUDIO_REQUIRED_FILTERS : [])],
})
/** @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 = runProcess,
}) => {
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,
]