Schnitt-Kette mit ffmpeg bauen

Jeder Clip wird einzeln auf 1920x1080 bei 30 fps gebracht, bei Bedarf
auf 10 Sekunden beschleunigt und hochkant mit unscharfem Hintergrund
gesetzt; Clip-Ton oder Stille plus Voice-over werden eingemischt. Der
Backing Track läuft nur unter den Clips, wird wiederholt, gekürzt und
ausgeblendet. Segmente werden zwischengespeichert, Zwischenstände tragen
unkomprimierten Ton, damit sich über viele Übergänge nichts verschiebt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Opus 5.5 committed 2026-09-29 10:53:14 +00:00
1 parent 72e4b8ab7a
commit 03bfda09b2
2 files changed
+504

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
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');
});