Was macht dev-claude-reel?
Ausgabe: eine mp4-Datei, 1080x1920, 30 fps, H.264 + AAC, etwa 50 Sekunden lang. Eine Low-Poly-3D-Szene, zwei Charaktere (ein Entwickler und ein Claude-Charakter), echte Stimmen, eingebrannte Untertitel, eine Kamera, die auf den Dialog schneidet, und ein sauberer Mix.
Wie: Eine three.js-Seite wird von einer einzigen setT(t)-Funktion gesteuert. Headless Chrome rendert sie Frame für Frame, ffmpeg fügt Bilder und Audio zusammen.
Was diese Fähigkeit nicht entscheidet: Wie die Charaktere aussehen, wo sie sind und worüber sie sprechen. Das vom Benutzer übernehmen, falls vorhanden. Sonst in wenigen Zeilen etwas auswählen und loslegen.
Pipeline (in dieser Reihenfolge, keine Schritte auslassen): Kurzbeschreibung → Skript → Stimmen → Zeitstrahl → Szene → Standbilder → Bewegungsprüfung → Mix → Vollrender → Muxen → Verifikation → Lieferung
1. Grundregeln
Diese Regeln haben Vorrang vor Bequemlichkeit. Sie haben keinen Vorrang vor dem Benutzer.
- Überlaste den Rechner nicht. Es ist der Alltagsrechner des Benutzers. Ein Headless-Browser, GPU-Rendering, niedrigste Prozesspriorität, keine parallelen Arbeiter. Wenn WebGL einen Software-Renderer meldet (SwiftShader / „software“ / „Basic Render“), anhalten und den Benutzer informieren. Niemals Software-GL erzwingen.
- Geheimnisse bleiben geheim. Der ElevenLabs-Schlüssel befindet sich in
ELEVENLABS_API_KEY(Umgebungsvariable oder.env). Nur in Skripten auslesen. Niemals an sichtbarer Stelle ausgeben, protokollieren oder schreiben. - Erst Standbilder, dann Film. Kein Vollrender, bevor ein Kontaktabzug der Standbilder korrekt aussieht. Ein erneuter Vollrender wegen eines einzelnen Fehlers ist der teuerste Fehler in diesem Workflow.
- In sichtbaren Blöcken rendern. Hintergrundaufträge sterben, wenn die Sitzung endet. Jeden Render-Aufruf unter ~8 Minuten halten und fortsetzbar machen (Abschnitt 11).
- Ein Video pro Chat. Die Szenendatei wächst schnell. In Teile aufteilen, nicht ohne Grund erneut einlesen, für das nächste Reel eine neue Chat-Sitzung starten.
- Eine Datei liefern, niemals veröffentlichen. Das Veröffentlichen liegt in der Verantwortung des Benutzers.
- Ehrlich mit Lücken umgehen. Fehlender Schlüssel, ffmpeg oder Chrome? Genau sagen, was fehlt, und die bestmögliche Alternative anbieten. Niemals ein Ergebnis vortäuschen.
2. Setup-Prüfung (erste Minute)
| Benötigt | Hinweise |
|---|---|
| Node 22+ | Verwendet integriertes fetch und WebSocket. Einzige npm-Abhängigkeit: three.js. |
| Chrome oder Edge | Headless ausführen. |
| ffmpeg + ffprobe | Im PATH oder an einem bekannten Speicherort. |
| three.js | npm i [email protected], von einem lokalen statischen Server von der Festplatte bereitgestellt. Kein CDN, kein Netzwerk während des Renderings. |
| ElevenLabs-Schlüssel | Kostenloser Tarif funktioniert (~10k Credits/Monat, API-Zugriff, vorgefertigte Stimmen, keine kommerzielle Lizenz). ~1 Credit pro Zeichen, Soundgenerierung kostet extra. |
| GPU | Einmal testen (Abschnitt 3). |
ElevenLabs-Budget: Das Skript fertigstellen, bevor etwas generiert wird, seine Zeichen zählen, nur Alternativaufnahmen für Schlüsselsätze erstellen und jedes Ergebnis zwischenspeichern, damit nichts doppelt bezahlt wird.
Modell: eleven_v3. Wenn das Konto es nicht nutzen kann, auf eleven_multilingual_v2 zurückgreifen, die [Audio-Tags] aus dem Text entfernen und den Benutzer warnen, dass die schauspielerische Leistung flacher sein wird.
Kein Schlüssel? Dies mitteilen. Skript, Szene und einen leisen Test-Render so weit wie sinnvoll erstellen, dann den Schlüssel anfordern. Wenn der Benutzer darauf besteht, ohne Schlüssel fortzufahren: Betriebssystem-Text-to-Speech, Wortzeitangaben geschätzt anhand der Zeichenanzahl. Den Benutzer darauf hinweisen, dass Lippen-Synchronisation und Untertitel ungenauer sein werden.
Ordnerstruktur
Ein Ordner pro Video, einfache Dateien, kein Build-Schritt.
reel/
package.json three
film.html Canvas + Untertitel-Overlay + Import-Map, lädt scene.js
scene.js 3D-Welt + setT(t) (in Teile aufteilen, wenn es groß wird)
timeline.mjs Timing + Aufnahmen, von Node UND dem Browser importiert
gen-audio.mjs nimmt.json -> ElevenLabs-Stimmen + Atmosphäre (auf Festplatte zwischengespeichert)
words.mjs Wortzeitangaben aus Alignment-JSON
build-film.mjs Sprachbusse pro Sprecher + film.json (Hüllkurven, Untertitel)
music.mjs optionale Hintergrundmusik
mix.mjs Endmix
cdp.mjs Headless-Chrome-Treiber
render.mjs Standbilder | Voll (in Blöcken, fortsetzbar)
check.mjs automatisierte Qualitätssicherung
make.mjs führt gen-audio -> build-film -> music -> mix aus
vo/ sfx/ frames/ stills/
Jede Änderung an timeline.mjs bedeutet, make.mjs vollständig von Anfang bis Ende erneut auszuführen. Veraltete Audiospuren sind der klassische unsichtbare Fehler: Ein Ton, der zu einem alten Timing gehört, erscheint als zufälliger Klick.
3. Headless Chrome steuern (cdp.mjs)
Ein kleiner Chrome DevTools Protocol-Client, keine Abhängigkeiten.
Start-Flags:
--headless=new --remote-debugging-port=<p> --user-data-dir=<tmp> --no-first-run --disable-extensions --disable-background-timer-throttling --disable-renderer-backgrounding --mute-audio --window-size=W,H --use-gl=angle --use-angle=<backend> --ignore-gpu-blocklist --enable-gpu
Backend: d3d11 unter Windows, metal unter macOS, gl unter Linux.
Nach dem Verbinden:
Emulation.setDeviceMetricsOverride { width: 1080, height: 1920, deviceScaleFactor: 1, mobile: false }- GPU-Test:
UNMASKED_RENDERER_WEBGLüberWEBGL_debug_renderer_infoauslesen. Leer oder passend zu/swiftshader|software|basic render/i→ abbrechen und Benutzer informieren. - Höflich bleiben:
os.setPriority(os.constants.priority.PRIORITY_LOW)am Anfang jedes Node-Skripts. Etwa alle 5 Sekunden die Priorität des Browsers und aller Kindprozesse erneut senken (Chrome erhöht seinen GPU-Prozess wieder). Kind-PIDs überSystemInfo.getProcessInfoauf dem Browser-Websocket abrufen (/json/version→webSocketDebuggerUrl). Unter Windows den Browser zusätzlich über PowerShell auf einen logischen Kern festlegen ((Get-Process -Id <pid>).ProcessorAffinity = <single bit>).
API-Oberfläche: goto(url), eval(expr) (awaitPromise + returnByValue, wirft bei exceptionDetails), waitFor(expr, timeout), send(method, params), ein logs-Array (Konsolenaufrufe, Ausnahmen, Log-Einträge), close() (Prozess beenden, temporäres Profil löschen).
Startpunkt (anpassen, nicht blind einfügen):
import { spawn } from 'node:child_process';
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
const BROWSERS = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome',
];
const BIN = BROWSERS.find(p => fs.existsSync(p));
const ANGLE = { win32: 'd3d11', darwin: 'metal' }[process.platform] ?? 'gl';
const sleep = ms => new Promise(r => setTimeout(r, ms));
export async function launch({ width = 1080, height = 1920, port = 9450 } = {}) {
try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch {}
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'reel-'));
const proc = spawn(BIN, [
`--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, '--headless=new',
'--no-first-run', '--disable-extensions', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--mute-audio', `--window-size=${width},${height}`,
'--use-gl=angle', `--use-angle=${ANGLE}`, '--ignore-gpu-blocklist', '--enable-gpu', 'about:blank',
], { stdio: 'ignore' });
let targets;
for (let i = 0; i < 100 && !targets?.length; i++) {
try { targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); }
catch { await sleep(150); }
}
const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
await new Promise((ok, fail) => { ws.onopen = ok; ws.onerror = fail; });
let seq = 0; const waiting = new Map(); const logs = [];
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.id && waiting.has(msg.id)) {
const { ok, fail } = waiting.get(msg.id); waiting.delete(msg.id);
msg.error ? fail(new Error(msg.error.message)) : ok(msg.result);
} else if (msg.method === 'Runtime.exceptionThrown') {
const d = msg.params.exceptionDetails;
logs.push({ type: 'exception', text: d.exception?.description || d.text });
} else if (msg.method === 'Runtime.consoleAPICalled') {
logs.push({ type: msg.params.type, text: msg.params.args.map(a => a.value ?? a.description).join(' ') });
}
};
const send = (method, params = {}) => new Promise((ok, fail) => {
const id = ++seq; waiting.set(id, { ok, fail });
ws.send(JSON.stringify({ id, method, params }));
});
await send('Runtime.enable'); await send('Page.enable'); await send('Log.enable');
await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
// TODO: GPU-Test, 5-Sekunden-Prioritätssenkungs-Timer für proc.pid + Kindprozesse, Windows-Affinitäts-Pin
const api = {
send, logs, proc,
async eval(expression) {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('Eval fehlgeschlagen: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text));
return r.result.value;
},
async goto(url) { await send('Page.navigate', { url }); await api.waitFor('document.readyState === "complete"', 15000); },
async waitFor(expr, ms = 10000) {
for (const t0 = Date.now(); Date.now() - t0 < ms; await sleep(100)) {
try { if (await api.eval(expr)) return true; } catch {}
}
throw new Error('Zeitüberschreitung: ' + expr);
},
async close() {
try { ws.close(); } catch {} try { proc.kill(); } catch {}
setTimeout(() => fs.rmSync(profile, { recursive: true, force: true }), 500);
},
};
return api;
}
4. Die Filmseite (film.html + scene.js)
Die goldene Regel
window.setT(t) ist eine reine Funktion der Zeit. Gib ihr Sekunden, sie positioniert jeden Charakter, jedes Requisit, jedes Licht und die Kamera, rendert den Frame, fertig. Das bedeutet:
- kein
performance.now(), keinrequestAnimationFrame, kein akkumulierter Zustand, keine Physik - kein
Math.random(): stattdessen einen gesäten Hash eines Indexes verwenden
Dadurch werden Frames in beliebiger Reihenfolge gerendert, Blöcke sauber fortgesetzt und ein einzelnes Standbild zu einem beliebigen Zeitpunkt ist günstig. Sobald alles (Schriftarten, Texturen) geladen ist, window.TOTAL, window.END_T und window.__ready = true setzen.
film.html
Schwarzer Body, kein Rand, overflow: hidden. Eine Import-Map, die three und three/addons/ auf node_modules/three/... zeigt. Ein #subwrap/#sub-Untertitel-Overlay. <script type="module" src="/scene.js">.
Renderer
const W = 1080, H = 1920;
const renderer = new THREE.WebGLRenderer({ antialias: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(1); renderer.setSize(W, H); renderer.toneMapping = THREE.NoToneMapping;
document.body.appendChild(renderer.domElement);
const depthTex = new THREE.DepthTexture(W, H); depthTex.type = THREE.UnsignedIntType;
const linear = { type: THREE.HalfFloatType, colorSpace: THREE.LinearSRGBColorSpace };
const rtScene = new THREE.WebGLRenderTarget(W, H, { ...linear, samples: 4, depthTexture: depthTex });
const rt = (w, h) => new THREE.WebGLRenderTarget(w, h, linear);
const rtDof = rt(W, H);
const rtB1 = rt(W >> 2, H >> 2), rtB1b = rt(W >> 2, H >> 2); // Bloom 1/4
const rtB2 = rt(W >> 3, H >> 3), rtB2b = rt(W >> 3, H >> 3); // Bloom 1/8
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2)); quad.frustumCulled = false;
const qScene = new THREE.Scene(); qScene.add(quad);
const qCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const pass = (mat, target) => { quad.material = mat; renderer.setRenderTarget(target); renderer.render(qScene, qCam); };
const VERT = 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }';
camera = new THREE.PerspectiveCamera(30, W / H, 1, 500); // near/far muss uNear/uFar im DOF-Shader entsprechen
Die Szene rendert in ein lineares HDR-Ziel (Half-Float, 4x MSAA, Tiefentextur), sodass Bildschirme und Lichter über 1,0 hinausgehen können. Dann Schärfentiefe, dann Bloom in HDR, dann ein einziger Komposit-Durchlauf, der Tonemapping und Gammakorrektur durchführt. Diese Kette ist der Unterschied zwischen „filmisch" und „WebGL-Demo".
Arbeitsreihenfolge innerhalb von setT
- Charaktere positionieren
scene.updateMatrixWorld(true)- Bodenkontakt und Anhänge (Requisiten an Hände, Sitze an Boden)
updateMatrixWorlderneut- Anker aktualisieren (Weltpositionen von Köpfen, Händen, Requisiten, Bildschirmen)
- Kamera aus der Aufnahmeliste platzieren
- Untertitel aktualisieren
- Szene rendern → DOF → Staub → Helle-Pass → Weichzeichner → Composite auf Canvas
Nachbearbeitung
Schärfentiefe. Tiefenbasierte Sammlung mit 64 Vogel-Scheiben-Taps. Helle Samples erhalten zusätzliches Gewicht, sodass Lichter zu Bokeh-Scheiben werden. Ein unscharfer Hintergrund darf nicht über einen scharfen Vordergrund ausbluten.
uniform sampler2D tColor, tDepth;
uniform vec2 uRes;
uniform float uNear, uFar, uFocus, uAperture, uMaxCoc;
varying vec2 vUv;
float linDepth(float d) { float z = d * 2. - 1.; return 2. * uNear * uFar / (uFar + uNear - z * (uFar - uNear)); }
float coc(float z) { return min(uMaxCoc, uAperture * abs(1. / uFocus - 1. / z)); }
void main() {
float z0 = linDepth(texture2D(tDepth, vUv).x);
float c0 = coc(z0);
float wsum = 1.0 / max(c0 * c0, 1.0);
vec3 acc = texture2D(tColor, vUv).rgb * wsum;
const int N = 64;
for (int i = 0; i < N; i++) {
float fi = float(i) + 0.5;
float r = sqrt(fi / float(N));
float a = fi * 2.39996323;
vec2 off = vec2(cos(a), sin(a)) * r * uMaxCoc;
float dist = length(off);
vec2 uv = vUv + off / uRes;
float zs = linDepth(texture2D(tDepth, uv).x);
float cs = coc(zs);
float eff = (zs > z0) ? min(cs, c0) : cs; // Hintergrund kann scharfen Vordergrund nicht überdecken
float w = smoothstep(dist - 1.0, dist + 1.0, eff) / max(eff * eff, 1.0);
vec3 s = texture2D(tColor, uv).rgb;
w *= 1.0 + 1.6 * clamp(dot(s, vec3(0.3, 0.5, 0.2)) - 0.8, 0.0, 6.0); // Bokeh-Verstärkung
acc += s * w; wsum += w;
}
gl_FragColor = vec4(acc / wsum, 1.0);
}
uFocus: Entfernung von der Kamera zum Fokus-Anker, gemessen entlang der Blickrichtung, wird bei jedem Frame neu berechnet.uAperture: ~1000–2500. Höher = geringere Schärfentiefe. Bei Nahaufnahmen hoch, bei Totalen niedrig. Standardmäßig moderat; starke Unschärfe sieht aus wie ein Instagram-Filter.uMaxCoc: 22 px.
Bloom.
- Helle-Pass: `c * clamp((max(r,g,b)
…
[Hier abgeschnitten für die Übersetzung]