mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:35:20 +00:00
417 lines
12 KiB
JavaScript
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,
|
|
]
|