Files
ferdi2goandClaude Opus 5.5 03bfda09b2 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>
2026-09-29 10:53:14 +00:00

305 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from 'node:child_process';
import crypto from 'node:crypto';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { TARGET_SECONDS } from './settings.js';
export const OUTPUT = { width: 1920, height: 1080, fps: 30, sampleRate: 48000 };
// Erhöhen, wenn sich die Filterkette ändert: dann verfallen zwischengespeicherte Segmente.
const RENDER_VERSION = 1;
const MUSIC_FADE_SECONDS = 2;
const AUDIO_FORMAT = `aformat=sample_fmts=fltp:sample_rates=${OUTPUT.sampleRate}:channel_layouts=stereo`;
// Zwischenstände tragen unkomprimierten Ton; erst das Endergebnis bekommt AAC.
// So summieren sich über viele Übergänge keine Verschiebungen auf.
const SEGMENT_CODECS = [
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
'-r', String(OUTPUT.fps), '-video_track_timescale', '15360',
'-c:a', 'pcm_s16le',
];
const hash = (value) =>
crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 32);
const factor = (volume) => Math.round(volume) / 100;
const seconds = (value) => value.toFixed(3);
function readyAsset(project, slot) {
const asset = project.assets?.[slot];
return asset?.status === 'ready' ? asset : null;
}
// Beschreibt, was gerendert wird – ohne Pfade, damit der Plan vergleichbar bleibt.
export function planRender(project) {
const { settings } = project;
const bookend = (slot) => {
const asset = readyAsset(project, slot);
if (!settings[slot].enabled || !asset) return null;
return {
kind: slot,
target: { type: 'asset', slot },
source: `${slot}:${asset.revision}`,
speed: 1,
outDuration: asset.media.duration,
audio: asset.media.hasAudio ? { volume: factor(settings[slot].volume), tempo: 1 } : null,
voice: null,
};
};
const clips = project.clips
.filter((clip) => clip.status === 'ready' && !settings.excluded.includes(clip.index))
.map((clip) => {
const { duration, hasAudio } = clip.media;
const speed = settings.speedUp && duration > TARGET_SECONDS ? duration / TARGET_SECONDS : 1;
const voice = readyAsset(project, `voice-${clip.index}`);
return {
kind: 'clip',
index: clip.index,
target: { type: 'clip', index: clip.index },
source: `clip:${clip.index}:${clip.file}:${clip.size}`,
speed,
outDuration: duration / speed,
audio: !settings.muteClips && hasAudio ? { volume: factor(settings.clipVolume), tempo: speed } : null,
voice: voice && {
target: { type: 'asset', slot: `voice-${clip.index}` },
source: `voice:${voice.revision}`,
volume: factor(settings.voiceVolume) * factor(settings.voiceVolumes[clip.index] ?? 100),
},
};
});
const intro = bookend('intro');
const outro = bookend('outro');
const segments = [intro, ...clips, outro].filter(Boolean);
for (const segment of segments) {
const { source, speed, outDuration, audio, voice } = segment;
segment.key = hash({
version: RENDER_VERSION,
source,
speed,
outDuration,
audio,
voice: voice && { source: voice.source, volume: voice.volume },
});
}
const clipsDuration = clips.reduce((sum, clip) => sum + clip.outDuration, 0);
const musicAsset = readyAsset(project, 'music');
const music =
settings.music.enabled && musicAsset && clips.length > 0
? {
target: { type: 'asset', slot: 'music' },
source: `music:${musicAsset.revision}`,
volume: factor(settings.music.volume),
duration: clipsDuration,
}
: null;
return {
intro,
clips,
outro,
music,
clipsDuration,
duration: segments.reduce((sum, segment) => sum + segment.outDuration, 0),
key: hash({
segments: segments.map((segment) => segment.key),
music: music && { source: music.source, volume: music.volume },
}),
};
}
// atempo schafft je Stufe höchstens Faktor 2, also verketten.
function tempoFilters(tempo) {
const filters = [];
let rest = tempo;
while (rest > 2) {
filters.push('atempo=2');
rest /= 2;
}
if (rest !== 1) filters.push(`atempo=${rest.toFixed(6)}`);
return filters;
}
// Ein Segment: auf 1920×1080 bei 30 fps, Hochkant mit unscharfem Hintergrund,
// Ton aus Clip (oder Stille) plus Voice-over, exakt auf Segmentlänge.
export function segmentArgs(segment, inputs, output) {
const { width, height, fps } = OUTPUT;
const duration = seconds(segment.outDuration);
const speed = segment.speed === 1 ? '' : `setpts=PTS/${segment.speed.toFixed(6)},`;
const filters = [
`[0:v]scale=iw*sar:ih,setsar=1,${speed}fps=${fps},split=2[bgsrc][fgsrc]`,
`[bgsrc]scale=${width / 4}:${height / 4}:force_original_aspect_ratio=increase,` +
`crop=${width / 4}:${height / 4},boxblur=8:2,scale=${width}:${height}[bg]`,
`[fgsrc]scale=${width}:${height}:force_original_aspect_ratio=decrease[fg]`,
'[bg][fg]overlay=(W-w)/2:(H-h)/2,setsar=1,format=yuv420p[v]',
segment.audio
? `[0:a]${[...tempoFilters(segment.audio.tempo), `volume=${segment.audio.volume}`, AUDIO_FORMAT].join(',')}[base]`
: `anullsrc=r=${OUTPUT.sampleRate}:cl=stereo,${AUDIO_FORMAT}[base]`,
];
if (segment.voice) {
filters.push(`[1:a]volume=${segment.voice.volume},${AUDIO_FORMAT}[voice]`);
filters.push('[base][voice]amix=inputs=2:duration=first:normalize=0[mixed]');
}
filters.push(`[${segment.voice ? 'mixed' : 'base'}]apad,atrim=0:${duration}[a]`);
return [
'-i', inputs.video,
...(segment.voice ? ['-i', inputs.voice] : []),
'-filter_complex', filters.join(';'),
'-map', '[v]', '-map', '[a]', '-t', duration,
...SEGMENT_CODECS,
output,
];
}
// Musik unter den Clip-Teil: wiederholt bis zum Ende, dort gekürzt und ausgeblendet.
export function musicArgs(music, inputs, output) {
const duration = seconds(music.duration);
const fade = Math.min(MUSIC_FADE_SECONDS, music.duration);
const fadeStart = seconds(Math.max(0, music.duration - fade));
return [
'-i', inputs.middle,
'-stream_loop', '-1', '-i', inputs.music,
'-filter_complex',
`[1:a]${AUDIO_FORMAT},volume=${music.volume},atrim=0:${duration},` +
`afade=t=out:st=${fadeStart}:d=${seconds(fade)}[m];` +
'[0:a][m]amix=inputs=2:duration=first:normalize=0[a]',
'-map', '0:v', '-map', '[a]', '-c:v', 'copy', '-c:a', 'pcm_s16le',
output,
];
}
export function concatArgs(listFile, output, { final }) {
return [
'-f', 'concat', '-safe', '0', '-i', listFile,
'-map', '0:v', '-map', '0:a', '-c:v', 'copy',
...(final ? ['-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart'] : ['-c:a', 'copy']),
output,
];
}
// Startet ffmpeg und meldet die bereits erzeugte Ausgabezeit in Sekunden.
export function runFfmpeg(args, { onTime, signal } = {}) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new Error('Abgebrochen.'));
const child = spawn(
'ffmpeg',
['-hide_banner', '-nostdin', '-y', '-v', 'error', '-progress', 'pipe:1', ...args],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let stderr = '';
let buffer = '';
const abort = () => child.kill('SIGKILL');
signal?.addEventListener('abort', abort, { once: true });
child.stdout.on('data', (data) => {
buffer += data;
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const match = /^out_time_us=(\d+)/.exec(line);
if (match && onTime) onTime(Number(match[1]) / 1e6);
}
});
child.stderr.on('data', (data) => {
stderr = (stderr + data).slice(-4000);
});
child.on('error', reject);
child.on('close', (code) => {
signal?.removeEventListener('abort', abort);
if (signal?.aborted) reject(new Error('Abgebrochen.'));
else if (code === 0) resolve();
else reject(new Error(`ffmpeg ist fehlgeschlagen: ${stderr.trim().split('\n').slice(-3).join(' | ')}`));
});
});
}
async function exists(file) {
try {
await fsp.access(file);
return true;
} catch {
return false;
}
}
async function writeConcatList(file, entries) {
const quote = (entry) => `file '${entry.replace(/'/g, "'\\''")}'`;
await fsp.writeFile(file, `${entries.map(quote).join('\n')}\n`);
}
// Rendert den Plan nach output. resolve(target) liefert den Pfad einer Eingabedatei.
export async function renderPlan({ plan, resolve, workDir, output, onProgress = () => {}, signal }) {
if (plan.clips.length === 0) throw new Error('Keine Clips für das Video ausgewählt.');
const cacheDir = path.join(workDir, 'cache');
const tempDir = path.join(workDir, 'tmp');
await fsp.mkdir(cacheDir, { recursive: true });
await fsp.rm(tempDir, { recursive: true, force: true });
await fsp.mkdir(tempDir, { recursive: true });
// Gewichte für die Fortschrittsanzeige: Segmente kosten fast die ganze Zeit.
const musicWeight = plan.music ? plan.clipsDuration * 0.1 : 0;
const finalWeight = plan.duration * 0.05;
const total = plan.duration + musicWeight + finalWeight;
let done = 0;
const report = (current = 0) => onProgress(Math.min(1, (done + current) / total));
const segmentFile = (segment) => path.join(cacheDir, `${segment.key}.mov`);
const segments = [plan.intro, ...plan.clips, plan.outro].filter(Boolean);
for (const segment of segments) {
const file = segmentFile(segment);
if (!(await exists(file))) {
const temp = path.join(tempDir, `${segment.key}.mov`);
const inputs = {
video: resolve(segment.target),
voice: segment.voice ? resolve(segment.voice.target) : null,
};
await runFfmpeg(segmentArgs(segment, inputs, temp), {
signal,
onTime: (time) => report(Math.min(time, segment.outDuration)),
});
await fsp.rename(temp, file);
}
done += segment.outDuration;
report();
}
const middleList = path.join(tempDir, 'middle.txt');
let middle = path.join(tempDir, 'middle.mov');
await writeConcatList(middleList, plan.clips.map(segmentFile));
await runFfmpeg(concatArgs(middleList, middle, { final: false }), { signal });
if (plan.music) {
const withMusic = path.join(tempDir, 'middle-music.mov');
await runFfmpeg(musicArgs(plan.music, { middle, music: resolve(plan.music.target) }, withMusic), {
signal,
onTime: (time) => report((Math.min(time, plan.clipsDuration) / plan.clipsDuration) * musicWeight),
});
middle = withMusic;
done += musicWeight;
report();
}
const finalList = path.join(tempDir, 'final.txt');
const parts = [plan.intro && segmentFile(plan.intro), middle, plan.outro && segmentFile(plan.outro)];
await writeConcatList(finalList, parts.filter(Boolean));
const tempOutput = path.join(tempDir, 'output.mp4');
await runFfmpeg(concatArgs(finalList, tempOutput, { final: true }), {
signal,
onTime: (time) => report((Math.min(time, plan.duration) / plan.duration) * finalWeight),
});
await fsp.rename(tempOutput, output);
onProgress(1);
// Aufräumen: Zwischenstände und Segmente, die der aktuelle Plan nicht mehr braucht.
await fsp.rm(tempDir, { recursive: true, force: true });
const keep = new Set(segments.map((segment) => `${segment.key}.mov`));
for (const entry of await fsp.readdir(cacheDir)) {
if (!keep.has(entry)) await fsp.rm(path.join(cacheDir, entry), { force: true });
}
}