import { describe, expect, it } from 'vitest' import { buildAudioDecodeArgs, buildConcatArgs, buildFrameListContent, buildMuxAudioVideoArgs, buildNarrationEncodeArgs, buildSceneClipArgs, concatListContent, encodeFrames, probeFfmpeg, resolveFfmpeg, } from '../../scripts/lib/reelFfmpeg.mjs' // C196 — ffmpeg resolution/args are pinned: the capability probe fails closed // (libx264 + the filters the composer uses) and the clip/concat commands are // deterministic. type RunResult = { ok: boolean; stdout: string; stderr: string; error?: { code?: string } } const okRun = async (_bin: string, args: string[]): Promise => { if (args.includes('-version')) return { ok: true, stdout: 'ffmpeg version 7.0.2-test\n', stderr: '' } if (args.includes('-encoders')) return { ok: true, stdout: ' V....D libx264 H.264\n', stderr: '' } if (args.includes('-filters')) { return { ok: true, stdout: [ ' ... overlay VV->V', ' ..C crop V->V', ' ..C scale V->V', ' ... zoompan V->V', ' .S. fade V->V', ].join('\n'), stderr: '', } } return { ok: true, stdout: '', stderr: '' } } const zoom = { z: 'if(lt(on,3),1,1.18)', x: 'max(0,min(iw-iw/zoom,0))', y: 'max(0,min(ih-ih/zoom,0))', inputWidth: 2_160, inputHeight: 3_840, } describe('reelFfmpeg', () => { it('probes version, libx264 and the required filters', async () => { await expect(probeFfmpeg('/ffmpeg', { run: okRun })).resolves.toMatchObject({ ok: true, bin: '/ffmpeg', }) }) it('fails the probe without libx264 or a required filter', async () => { const no264 = async (_bin: string, args: string[]): Promise => { if (args.includes('-version')) return { ok: true, stdout: 'ffmpeg version x\n', stderr: '' } if (args.includes('-encoders')) return { ok: true, stdout: ' V....D h264\n', stderr: '' } return { ok: true, stdout: '', stderr: '' } } await expect(probeFfmpeg('/ffmpeg', { run: no264 })).resolves.toMatchObject({ ok: false, reason: 'sem encoder libx264', }) const noZoompan = async (_bin: string, args: string[]): Promise => { if (args.includes('-version')) return { ok: true, stdout: 'ffmpeg version x\n', stderr: '' } if (args.includes('-encoders')) return { ok: true, stdout: 'libx264\n', stderr: '' } return { ok: true, stdout: 'overlay\ncrop\nscale\nfade\n', stderr: '' } } await expect(probeFfmpeg('/ffmpeg', { run: noZoompan })).resolves.toMatchObject({ ok: false, reason: 'sem filtro(s): zoompan', }) }) it('requires libmp3lame and atempo only when probing for audio', async () => { await expect(probeFfmpeg('/ffmpeg', { run: okRun, audio: true })).resolves.toMatchObject({ ok: false, reason: 'sem encoder libmp3lame', }) const audioRun = async (_bin: string, args: string[]): Promise => { if (args.includes('-version')) return { ok: true, stdout: 'ffmpeg version 7.0.2-test\n', stderr: '' } if (args.includes('-encoders')) return { ok: true, stdout: ' V....D libx264 H.264\n A....D libmp3lame MP3\n', stderr: '' } return { ok: true, stdout: 'overlay\nfade\nscale\nzoompan\natempo A->A\n', stderr: '', } } await expect(probeFfmpeg('/ffmpeg', { run: audioRun, audio: true })).resolves.toMatchObject({ ok: true, }) const noAtempo = async (_bin: string, args: string[]): Promise => { if (args.includes('-version')) return { ok: true, stdout: 'ffmpeg version x\n', stderr: '' } if (args.includes('-encoders')) return { ok: true, stdout: 'libx264\nlibmp3lame\n', stderr: '' } return { ok: true, stdout: 'overlay\nfade\nscale\nzoompan\n', stderr: '' } } await expect(probeFfmpeg('/ffmpeg', { run: noAtempo, audio: true })).resolves.toMatchObject({ ok: false, reason: 'sem filtro(s): atempo', }) }) it('resolves FFMPEG_PATH strictly, then PATH, then the packaged binary', async () => { const explicit = await resolveFfmpeg({ env: { FFMPEG_PATH: '/custom/ffmpeg' }, run: okRun, installer: { path: '/packed/ffmpeg' }, }) expect(explicit).toMatchObject({ bin: '/custom/ffmpeg', source: 'FFMPEG_PATH' }) const pathFirst = await resolveFfmpeg({ env: {}, run: okRun, installer: { path: '/packed/ffmpeg' }, }) expect(pathFirst).toMatchObject({ bin: 'ffmpeg', source: 'PATH' }) const packed = async (bin: string, args: string[]): Promise => { if (bin === 'ffmpeg') return { ok: false, stdout: '', stderr: '', error: { code: 'ENOENT' } } return okRun(bin, args) } const fallback = await resolveFfmpeg({ env: {}, run: packed, installer: { path: '/packed/ffmpeg' }, }) expect(fallback).toMatchObject({ bin: '/packed/ffmpeg', source: 'empacotado' }) const brokenExplicit = async (bin: string, args: string[]): Promise => bin === '/custom/ffmpeg' ? { ok: false, stdout: '', stderr: '', error: { code: 'ENOENT' } } : okRun(bin, args) await expect( resolveFfmpeg({ env: { FFMPEG_PATH: '/custom/ffmpeg' }, run: brokenExplicit, installer: { path: '/packed/ffmpeg' }, }), ).rejects.toThrow(/Nenhum ffmpeg utilizável/) }) it('builds the scene clip with zoom, burned captions and fades', () => { const args = buildSceneClipArgs({ input: 'in.mp4', output: 'out.mp4', startMs: 1_500, durationMs: 3_000, zoom, captions: [ { path: 'chrome.png', startMs: 0, endMs: 3_000 }, { path: 'caption.png', startMs: 300, endMs: 1_800 }, ], fadeInMs: 300, fadeOutMs: 400, fps: 30, width: 1_080, height: 1_920, }) const graph = args[args.indexOf('-filter_complex') + 1] expect(graph).toContain('zoompan=') expect(graph).toContain("overlay=0:0:enable='between(t,0,3)'") expect(graph).toContain("overlay=0:0:enable='between(t,0.3,1.8)'") expect(graph).toContain('fade=t=in:st=0:d=0.3') expect(graph).toContain('fade=t=out:st=2.6:d=0.4') expect(args).toContain('-loop') expect(args.filter((arg: string) => arg === '-loop')).toHaveLength(2) expect(args).toContain('libx264') expect(args).toContain('[v2]') expect(args[args.indexOf('-ss') + 1]).toBe('1.5') }) it('skips zoompan for scenes without clicks', () => { const args = buildSceneClipArgs({ input: 'in.mp4', output: 'out.mp4', startMs: 0, durationMs: 2_400, fps: 30, width: 1_080, height: 1_920, }) expect(args[args.indexOf('-filter_complex') + 1]).not.toContain('zoompan') expect(args[args.indexOf('-filter_complex') + 1]).toContain('scale=1080:1920') }) it('escapes concat entries and streams the final concat', () => { expect(concatListContent(['/tmp/a.mp4', "/tmp/o'brien.mp4"])).toBe( "file '/tmp/a.mp4'\nfile '/tmp/o'\\''brien.mp4'\n", ) expect(buildConcatArgs({ listPath: 'list.txt', output: 'reel.mp4' })).toEqual([ '-hide_banner', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', 'list.txt', '-c', 'copy', '-movflags', '+faststart', '-y', 'reel.mp4', ]) }) it('builds the decode, encode and mux commands of the narration track', () => { expect(buildAudioDecodeArgs({ input: 'in.mp3', output: 'out.pcm' })).toEqual([ '-hide_banner', '-loglevel', 'error', '-i', 'in.mp3', '-ac', '1', '-ar', '24000', '-c:a', 'pcm_s16le', '-f', 's16le', '-y', 'out.pcm', ]) const fitted = buildAudioDecodeArgs({ input: 'in.mp3', output: 'fit.pcm', tempo: 1.5 }) expect(fitted).toContain('atempo=1.5') expect(fitted[fitted.indexOf('-filter:a') - 1]).toBe('in.mp3') expect(buildNarrationEncodeArgs({ input: 'narracao.pcm', output: 'narracao.mp3' })).toEqual([ '-hide_banner', '-loglevel', 'error', '-f', 's16le', '-ar', '24000', '-ac', '1', '-i', 'narracao.pcm', '-c:a', 'libmp3lame', '-b:a', '128k', '-y', 'narracao.mp3', ]) expect( buildMuxAudioVideoArgs({ video: 'reel.mp4', audio: 'narracao.mp3', output: 'reel-audio.mp4', }), ).toEqual([ '-hide_banner', '-loglevel', 'error', '-i', 'reel.mp4', '-i', 'narracao.mp3', '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k', '-shortest', '-movflags', '+faststart', '-y', 'reel-audio.mp4', ]) }) it('writes frame durations from capture deltas and repeats the last frame', () => { const content = buildFrameListContent({ frames: [ { file: '/f/1.jpg', captureMs: 0 }, { file: '/f/2.jpg', captureMs: 50 }, { file: '/f/3.jpg', captureMs: 250 }, ], fps: 30, }) expect(content).toBe( "file '/f/1.jpg'\nduration 0.05\nfile '/f/2.jpg'\nduration 0.2\nfile '/f/3.jpg'\nduration 0.033\nfile '/f/3.jpg'\n", ) }) it('encodes frames offline through the injected runner', async () => { const calls: Array<{ bin: string; args: string[] }> = [] const writes: Array<{ path: string; content: string }> = [] const run = async (bin: string, args: string[]): Promise => { calls.push({ bin, args }) return { ok: true, stdout: '', stderr: '' } } await encodeFrames({ ffmpegBin: '/ffmpeg', frames: [ { file: '/f/1.jpg', captureMs: 0 }, { file: '/f/2.jpg', captureMs: 40 }, ], listPath: '/w/frames.txt', output: '/w/cap.mp4', fps: 30, run, writeFileImpl: async (path: string, content: string) => { writes.push({ path, content }) }, }) expect(writes).toHaveLength(1) expect(writes[0].content).toContain("file '/f/1.jpg'") expect(calls).toHaveLength(1) expect(calls[0].args).toContain('concat') expect(calls[0].args).toContain('/w/cap.mp4') }) })