youtube/remotion/werkzeuge/render.mjs

130 lines
6 KiB
JavaScript

// Rendert eine Folge Szene für Szene und klebt sie zusammen.
// Szenen, an denen sich nichts geändert hat, werden nicht neu gerendert.
//
// node werkzeuge/render.mjs F02 ganze Folge, volle Qualität
// node werkzeuge/render.mjs F02 --entwurf halbe Auflösung + Zeit/Szene eingeblendet
// node werkzeuge/render.mjs F02 --sprache es spanische Fassung (de, es, fr; ohne Angabe de)
// node werkzeuge/render.mjs F02 --szene 3,5 nur diese Szenen (neu) rendern
// node werkzeuge/render.mjs F02 --neu alles neu, Zwischenstand ignorieren
// node werkzeuge/render.mjs F02 --kerne 6 Anzahl paralleler Browser-Tabs
// node werkzeuge/render.mjs F02 --ziel /shared/youtube/folge02/folge02.mp4
//
// Läuft unter Linux und Windows. Ergebnis und Zwischenstand: out/<kürzel>/<sprache>/
import {spawnSync} from 'node:child_process';
import {createHash} from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const WURZEL = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
process.chdir(WURZEL);
const args = process.argv.slice(2);
const kuerzel = args.find((a) => !a.startsWith('--'));
if (!kuerzel) {
console.error('Bitte Folge angeben, z. B.: node werkzeuge/render.mjs F02');
process.exit(1);
}
const wert = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const entwurf = args.includes('--entwurf');
const neu = args.includes('--neu');
const nurSzenen = wert('--szene')?.split(',').map(Number);
const kerne = Number(wert('--kerne') ?? Math.max(1, os.cpus().length - 1));
const ziel = wert('--ziel');
const sprache = wert('--sprache') ?? 'de';
if (!['de', 'es', 'fr'].includes(sprache)) {
console.error(`Unbekannte Sprache: ${sprache} (de, es, fr)`);
process.exit(1);
}
const nummer = kuerzel.replace(/\D/g, '');
const ordner = `src/folge${nummer}`;
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const lauf = (cmd, a, opt = {}) => {
const r = spawnSync(cmd, a, {stdio: opt.still ? 'pipe' : 'inherit', encoding: 'utf8', shell: process.platform === 'win32', env: {...process.env, SPRACHE: sprache}});
if (r.status !== 0) {
if (opt.still) console.error(r.stdout, r.stderr);
throw new Error(`Fehlgeschlagen: ${cmd} ${a.join(' ')}`);
}
return r.stdout;
};
// Szenenliste aus dem Code der Folge holen
const liste = JSON.parse(
lauf(npx, ['tsx', 'werkzeuge/szenenliste.mts', `./${ordner}/Folge${nummer}.tsx`, kuerzel], {still: true})
.split('\n')
.filter((z) => z.startsWith('['))
.pop(),
);
// Fingerabdruck: gemeinsamer Code + Bilder/Clips/Stimmen + eigene Szenendatei
const hashDatei = (h, p, ohne = []) => {
if (ohne.includes(p.replaceAll('\\', '/'))) return;
const st = fs.statSync(p);
if (st.isDirectory()) {
for (const n of fs.readdirSync(p).sort()) hashDatei(h, path.join(p, n), ohne);
} else {
h.update(p.replaceAll('\\', '/'));
h.update(fs.readFileSync(p));
}
};
const szenenDateien = new Set(liste.map((s) => s.datei));
const gemeinsam = createHash('sha256');
// Texte, Stimmen und Orte mit Schrift: nur die der gewählten Sprache
const andere = ['de', 'es', 'fr'].filter((s) => s !== sprache);
const ohne = [`public/stimmen/folge${nummer}`, ...andere.flatMap((s) => [`${ordner}/texte/${s}.json`, `${ordner}/stimmen/${s}.json`, `public/orte/kalender_${s}.webp`])];
for (const p of ['src/baukasten', 'src/bewegung.ts', 'public', 'package-lock.json']) if (fs.existsSync(p)) hashDatei(gemeinsam, p, ohne);
if (fs.existsSync(`public/stimmen/folge${nummer}/${sprache}`)) hashDatei(gemeinsam, `public/stimmen/folge${nummer}/${sprache}`);
for (const n of fs.readdirSync(ordner).sort()) {
const p = path.join(ordner, n);
if (!szenenDateien.has(n) || n === `Folge${nummer}.tsx`) hashDatei(gemeinsam, p, ohne);
}
const basis = gemeinsam.digest('hex');
const qualitaet = entwurf ? 'entwurf' : 'voll';
const aus = path.join('out', kuerzel, sprache, qualitaet);
fs.mkdirSync(aus, {recursive: true});
const propsDatei = path.join(aus, 'props.json');
// Untertitel werden eingebrannt (Entscheidung Nutzerin 06.11.2026: Eltern lesen mit, auch ohne Ton).
// Mit --ohne-untertitel abschalten (das Mitmach-„?“ bleibt).
const untertitel = !args.includes('--ohne-untertitel');
fs.writeFileSync(propsDatei, JSON.stringify({entwurf, sprache, untertitel}));
const gl = process.platform === 'linux' ? 'swangle' : 'angle';
const teile = [];
const start = Date.now();
for (const [i, s] of liste.entries()) {
const h = createHash('sha256');
h.update(basis + qualitaet + s.dauer + (untertitel ? '' : ' ohne Untertitel'));
hashDatei(h, path.join(ordner, s.datei));
const fp = h.digest('hex');
const datei = path.join(aus, `${s.id}.mp4`);
const fpDatei = datei + '.fingerabdruck';
const aktuell = fs.existsSync(datei) && fs.existsSync(fpDatei) && fs.readFileSync(fpDatei, 'utf8') === fp;
const gewaehlt = nurSzenen ? nurSzenen.includes(i + 1) : !aktuell || neu;
teile.push(datei);
if (!gewaehlt) {
console.log(`= Szene ${i + 1} „${s.name}“ unverändert`);
continue;
}
console.log(`▶ Szene ${i + 1} „${s.name}“ (${(s.dauer / 30).toFixed(1)} s) …`);
const t0 = Date.now();
const a = ['remotion', 'render', 'src/index.ts', s.id, datei, `--concurrency=${kerne}`, `--gl=${gl}`, `--props=${propsDatei}`, '--log=error'];
if (entwurf) a.push('--scale=0.5');
lauf(npx, a);
fs.writeFileSync(fpDatei, fp);
const sek = (Date.now() - t0) / 1000;
console.log(` fertig in ${sek.toFixed(0)} s (${(s.dauer / sek).toFixed(1)} Bilder/s)`);
}
// Zusammenkleben (ohne neu zu kodieren)
const listeDatei = path.join(aus, 'teile.txt');
fs.writeFileSync(listeDatei, teile.map((t) => `file '${path.resolve(t).replaceAll('\\', '/')}'`).join('\n'));
const ergebnis = ziel ?? path.join('out', kuerzel, `${kuerzel}-${sprache}${entwurf ? '-entwurf' : ''}.mp4`);
lauf(npx, ['remotion', 'ffmpeg', '-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', listeDatei, '-c', 'copy', '-movflags', '+faststart', ergebnis]);
console.log(`\nFertig: ${ergebnis} (${((Date.now() - start) / 60000).toFixed(1)} min)`);