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 }); } }