import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { after, before, test } from 'node:test'; import { probe } from '../server/media.js'; import { planRender, renderPlan, segmentArgs } from '../server/render.js'; import { defaultSettings } from '../server/settings.js'; const media = (duration, hasAudio = true) => ({ duration, hasVideo: true, hasAudio, width: 320, height: 180 }); function fakeProject(settings = {}) { return { clips: [ { index: 0, file: 'a.mp4', size: 10, status: 'ready', media: media(95) }, { index: 1, file: 'b.mp4', size: 20, status: 'ready', media: media(4, false) }, { index: 2, file: 'c.mp4', size: 30, status: 'ready', media: media(30) }, { index: 3, file: 'd.mp4', size: 40, status: 'pending' }, ], assets: { intro: { status: 'ready', revision: 'i1', media: media(3) }, music: { status: 'ready', revision: 'm1', media: { duration: 60, hasAudio: true } }, 'voice-2': { status: 'ready', revision: 'v1', media: { duration: 8, hasAudio: true } }, }, settings: { ...defaultSettings(), ...settings }, }; } test('Lange Clips werden auf 10 Sekunden beschleunigt, kurze bleiben', () => { const plan = planRender(fakeProject()); assert.deepEqual(plan.clips.map((clip) => clip.index), [0, 1, 2]); assert.equal(plan.clips[0].speed, 9.5); assert.equal(plan.clips[0].outDuration, 10); assert.equal(plan.clips[1].speed, 1); assert.equal(plan.clips[1].outDuration, 4); assert.equal(plan.clipsDuration, 24); }); test('Ohne Beschleunigen behalten Clips ihre Länge', () => { const plan = planRender(fakeProject({ speedUp: false })); assert.equal(plan.clips[0].outDuration, 95); }); test('Stummgeschaltete Clips haben keinen eigenen Ton, Intro und Outro schon', () => { const plan = planRender(fakeProject({ intro: { enabled: true, volume: 80 } })); assert.ok(plan.clips.every((clip) => clip.audio === null)); assert.deepEqual(plan.intro.audio, { volume: 0.8, tempo: 1 }); }); test('Mit Ton wird der Clip-Ton im selben Tempo beschleunigt', () => { const plan = planRender(fakeProject({ muteClips: false, clipVolume: 50 })); assert.deepEqual(plan.clips[0].audio, { volume: 0.5, tempo: 9.5 }); assert.equal(plan.clips[1].audio, null, 'Clip ohne Tonspur'); const args = segmentArgs(plan.clips[0], { video: 'in.mp4' }, 'out.mov').join(' '); assert.match(args, /atempo=2,atempo=2,atempo=2,atempo=1\.187500/); }); test('Voice-over-Lautstärke ist Gesamtregler mal Einzelregler', () => { const plan = planRender(fakeProject({ voiceVolume: 50, voiceVolumes: { 2: 80 } })); assert.equal(plan.clips[2].voice.volume, 0.4); assert.equal(plan.clips[0].voice, null); }); test('Ausgeschlossene Clips und fehlende Dateien fallen raus', () => { const plan = planRender(fakeProject({ excluded: [0], intro: { enabled: true, volume: 100 } })); assert.deepEqual(plan.clips.map((clip) => clip.index), [1, 2]); assert.equal(plan.duration, 3 + 4 + 10); const withoutAsset = fakeProject({ outro: { enabled: true, volume: 100 } }); assert.equal(planRender(withoutAsset).outro, null); }); test('Musik läuft nur unter den Clips', () => { const plan = planRender(fakeProject({ music: { enabled: true, volume: 30 }, intro: { enabled: true, volume: 100 } })); assert.equal(plan.music.duration, 24); assert.equal(plan.music.volume, 0.3); }); test('Musiklautstärke ändert keine Clip-Segmente, Clip-Lautstärke im Stumm-Modus auch nicht', () => { const base = planRender(fakeProject({ music: { enabled: true, volume: 30 } })); const louder = planRender(fakeProject({ music: { enabled: true, volume: 90 }, clipVolume: 20 })); assert.deepEqual(louder.clips.map((c) => c.key), base.clips.map((c) => c.key)); assert.notEqual(louder.key, base.key); const unmuted = planRender(fakeProject({ muteClips: false })); assert.notEqual(unmuted.clips[0].key, base.clips[0].key); }); // --- Echter Schnitt mit kleinen Testvideos --- const hasFfmpeg = (() => { try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); return true; } catch { return false; } })(); let dir; const files = {}; const ffmpeg = (...args) => execFileSync('ffmpeg', ['-v', 'error', '-y', ...args]); // Mittlere Lautstärke eines Ausschnitts in dB (volumedetect schreibt auf stderr) function meanVolumeOf(file, start, duration) { const { stderr } = spawnSync( 'ffmpeg', ['-v', 'info', '-ss', String(start), '-t', String(duration), '-i', file, '-af', 'volumedetect', '-f', 'null', '-'], { encoding: 'utf8' }, ); const match = /mean_volume: (-?[\d.]+|-inf) dB/.exec(stderr); if (!match) throw new Error(`Keine Lautstärke gemessen: ${stderr.slice(-300)}`); return match[1] === '-inf' ? -Infinity : Number(match[1]); } before(() => { if (!hasFfmpeg) return; dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fdc-render-')); const file = (name) => (files[name] = path.join(dir, name)); // 12 s Hochkant wie vom Handy (quer gespeichert, gedreht), mit Ton ffmpeg('-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=500', '-t', '12', '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'aac', '-shortest', file('raw.mp4')); ffmpeg('-i', files['raw.mp4'], '-c', 'copy', '-metadata:s:v:0', 'rotate=90', file('clip0.mp4')); // 4 s quer, ohne Ton ffmpeg('-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=25', '-t', '4', '-c:v', 'libx264', '-preset', 'ultrafast', file('clip1.mp4')); // 2 s Intro mit Ton ffmpeg('-f', 'lavfi', '-i', 'color=c=blue:size=320x180:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=800', '-t', '2', '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'aac', '-shortest', file('intro.mp4')); // 3 s Musik (muss wiederholt werden) und 3 s Voice-over ffmpeg('-f', 'lavfi', '-i', 'sine=frequency=300', '-t', '3', file('music.mp3')); ffmpeg('-f', 'lavfi', '-i', 'sine=frequency=1000', '-t', '3', '-c:a', 'libopus', file('voice.webm')); }); after(() => { if (dir) fs.rmSync(dir, { recursive: true, force: true }); }); async function realProject(settings) { return { clips: [ { index: 0, file: 'clip0.mp4', size: 1, status: 'ready', media: await probe(files['clip0.mp4']) }, { index: 1, file: 'clip1.mp4', size: 1, status: 'ready', media: await probe(files['clip1.mp4']) }, ], assets: { intro: { status: 'ready', revision: 'i', media: await probe(files['intro.mp4']) }, music: { status: 'ready', revision: 'm', media: await probe(files['music.mp3']) }, 'voice-1': { status: 'ready', revision: 'v', media: await probe(files['voice.webm']) }, }, settings: { ...defaultSettings(), ...settings }, }; } const resolve = (target) => target.type === 'clip' ? files[`clip${target.index}.mp4`] : { intro: files['intro.mp4'], music: files['music.mp3'], 'voice-1': files['voice.webm'] }[target.slot]; test('Schnitt: Intro, beschleunigter Hochkant-Clip, Voice-over und Musik', { skip: !hasFfmpeg && 'ffmpeg fehlt', timeout: 180000 }, async () => { const workDir = path.join(dir, 'work'); const output = path.join(dir, 'out.mp4'); const project = await realProject({ intro: { enabled: true, volume: 100 }, music: { enabled: true, volume: 60 }, }); const progress = []; await renderPlan({ plan: planRender(project), resolve, workDir, output, onProgress: (p) => progress.push(p) }); const result = await probe(output); // 2 s Intro + 12 s auf 10 s beschleunigt + 4 s assert.ok(Math.abs(result.duration - 16) < 0.2, `Länge ${result.duration}`); assert.equal(result.width, 1920); assert.equal(result.height, 1080); assert.equal(result.hasAudio, true); assert.equal(progress.at(-1), 1); assert.ok(progress.every((value, i) => i === 0 || value >= progress[i - 1]), 'Fortschritt steigt'); // Intro hat Ton, stummgeschalteter Clip trägt nur Musik bzw. Voice-over assert.ok(meanVolumeOf(output, 0.5, 1) > -40, 'Intro hörbar'); assert.ok(meanVolumeOf(output, 3, 2) > -40, 'Musik unter dem stummen Clip hörbar'); assert.ok(meanVolumeOf(output, 12.5, 1.5) > -40, 'Voice-over hörbar'); // Zweiter Lauf mit anderer Musiklautstärke rechnet die Segmente nicht neu const cacheDir = path.join(workDir, 'cache'); const before = new Map(fs.readdirSync(cacheDir).map((f) => [f, fs.statSync(path.join(cacheDir, f)).mtimeMs])); assert.equal(before.size, 3); project.settings.music.volume = 20; await renderPlan({ plan: planRender(project), resolve, workDir, output }); for (const [name, mtime] of before) { assert.equal(fs.statSync(path.join(cacheDir, name)).mtimeMs, mtime, `${name} neu gerechnet`); } }); test('Schnitt: ohne Musik und Voice-over sind stummgeschaltete Clips still', { skip: !hasFfmpeg && 'ffmpeg fehlt', timeout: 180000 }, async () => { const output = path.join(dir, 'still.mp4'); const project = await realProject({}); delete project.assets['voice-1']; await renderPlan({ plan: planRender(project), resolve, workDir: path.join(dir, 'work2'), output }); const result = await probe(output); assert.ok(Math.abs(result.duration - 14) < 0.2, `Länge ${result.duration}`); assert.ok(meanVolumeOf(output, 1, 10) < -80, 'still'); });