// 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/// 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 nur im Entwurf: in der fertigen Folge sprechen die Aufnahmen, Untertitel kommen je Sprache // in YouTube Studio (Entscheidung „final ohne Untertitel“). Mit --untertitel trotzdem einbrennen. const untertitel = entwurf || args.includes('--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)`);