Bisher landeten die Untertitel auch in den fertigen Folgen. Nur noch im Entwurf, mit --untertitel erzwingbar. Der Fingerabdruck enthält die Einstellung, vorhandene volle Szenen werden dadurch neu gerendert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
6 KiB
JavaScript
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 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)`);
|