Que fait dev-claude-reel ?
Sortie : un fichier MP4, 1080×1920, 30 fps, H.264 + AAC, environ 50 secondes. Une scène 3D basse poly, deux personnages (un développeur et un personnage Claude), des voix réelles, des sous-titres incrustés, une caméra qui coupe sur le dialogue, et un mixage propre.
Comment : une page three.js est pilotée par une unique fonction setT(t). Chrome headless la rend image par image, ffmpeg assemble les images et l'audio.
Ce que ce skill ne décide pas : l'apparence des personnages, leur emplacement et le sujet de leur conversation. Prenez cela de l'utilisateur s'il l'a fourni. Sinon, choisissez quelque chose en quelques lignes et lancez-vous.
Pipeline (dans cet ordre, sans saut) : brève → script → voix → chronologie → scène → images fixes → vérification du mouvement → mixage → rendu complet → assemblage → vérification → livraison
1. Règles de base
Elles priment sur la commodité. Elles ne priment pas sur l'utilisateur.
- Ne pas endommager la machine. C'est l'ordinateur personnel de l'utilisateur. Un navigateur headless, rendu GPU, priorité de processus la plus basse, aucun travailleur parallèle. Si WebGL signale un rendu logiciel (SwiftShader / "software" / "Basic Render"), arrêtez et prévenez l'utilisateur. Ne forcez jamais le rendu logiciel GL.
- Les secrets restent secrets. La clé ElevenLabs se trouve dans
ELEVENLABS_API_KEY(variable d'environnement ou.env). Lisez-la uniquement dans les scripts. Ne l'imprimez, ne la journalisez et ne l'écrivez jamais à un endroit visible. - D'abord les images fixes, ensuite le film. Pas de rendu complet avant qu'une planche contact d'images fixes ne semble correcte. Un re-rendu complet pour une erreur sur un seul plan est l'erreur la plus coûteuse de ce flux de travail.
- Rendu par blocs en avant-plan. Les tâches en arrière-plan meurent à la fin du tour. Maintenez chaque appel de rendu sous ~8 minutes et rendez-le reprenable (section 11).
- Une vidéo par session de chat. Le fichier de scène grossit vite. Divisez-le en parties, ne le relisez pas sans raison, commencez un nouveau chat pour la prochaine vidéo.
- Livrez un fichier, ne le publiez jamais. La publication est la décision de l'utilisateur.
- Soyez honnête sur les lacunes. Clé manquante, ffmpeg ou Chrome ? Dites exactement ce qui manque et proposez l'option la plus proche possible. Ne simulez jamais un résultat.
2. Vérification de la configuration (première minute)
| Besoin | Notes |
|---|---|
| Node 22+ | Utilise les fonctions fetch et WebSocket intégrées. Seule dépendance npm : three.js. |
| Chrome ou Edge | Exécutez-le en mode headless. |
| ffmpeg + ffprobe | Dans le PATH ou à un emplacement connu. |
| three.js | npm i [email protected], servi depuis le disque par un serveur statique local. Pas de CDN, pas de réseau pendant le rendu. |
| Clé ElevenLabs | Le niveau gratuit fonctionne (~10 000 crédits/mois, accès API, voix prédéfinies, pas de licence commerciale). ~1 crédit par caractère, la génération de sons coûte plus cher. |
| GPU | Testez une fois (section 3). |
Budget ElevenLabs : terminez le script avant de générer quoi que ce soit, comptez ses caractères, ne faites des prises alternatives que pour les lignes clés, et mettez en cache chaque résultat pour ne jamais payer deux fois.
Modèle : eleven_v3. Si le compte ne peut pas l'utiliser, revenez à eleven_multilingual_v2, retirez les [audio tags] du texte, et avertissez l'utilisateur que le jeu d'acteur sera plus plat.
Pas de clé ? Dites-le. Construisez le script, la scène et un rendu de test silencieux aussi loin que possible, puis demandez la clé. Si l'utilisateur insiste pour continuer sans : synthèse vocale du système, synchronisation des mots estimée à partir du nombre de caractères. Prévenez-les que le lipsync et les sous-titres seront moins précis.
Structure des dossiers
Un dossier par vidéo, fichiers simples, pas d'étape de build.
reel/
package.json three
film.html canevas + superposition de sous-titres + import map, charge scene.js
scene.js Monde 3D + setT(t) (divisé en parties quand il devient volumineux)
timeline.mjs Chronologie + plans, importé par Node ET le navigateur
gen-audio.mjs takes.json -> Voix ElevenLabs + ambiance (mis en cache sur disque)
words.mjs Synchronisation des mots à partir du JSON d'alignement
build-film.mjs Bus de voix par intervenant + film.json (enveloppes, sous-titres)
music.mjs Fond musical optionnel
mix.mjs Mixage final
cdp.mjs Pilote Chrome headless
render.mjs Images fixes | complet (par blocs, reprenable)
check.mjs Contrôle qualité automatisé
make.mjs Exécute gen-audio -> build-film -> musique -> mix
vo/ sfx/ frames/ stills/
Toute modification de timeline.mjs nécessite de réexécuter make.mjs de bout en bout. Des pistes audio obsolètes sont le bug invisible classique : un son appartenant à une ancienne chronologie apparaît comme un clic aléatoire.
3. Piloter Chrome headless (cdp.mjs)
Un petit client du protocole Chrome DevTools, sans dépendances.
Options de lancement :
--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 sur Windows, metal sur macOS, gl sur Linux.
Après la connexion :
Emulation.setDeviceMetricsOverride { width: 1080, height: 1920, deviceScaleFactor: 1, mobile: false }- Test GPU : lisez
UNMASKED_RENDERER_WEBGLviaWEBGL_debug_renderer_info. Vide, ou correspondant à/swiftshader|software|basic render/i→ abandonnez et prévenez l'utilisateur. - Restez poli :
os.setPriority(os.constants.priority.PRIORITY_LOW)en haut de chaque script Node. Toutes les ~5 secondes, réduisez à nouveau la priorité du navigateur et de chaque processus enfant (Chrome remonte son processus GPU). Obtenez les PID enfants viaSystemInfo.getProcessInfosur le websocket du navigateur (/json/version→webSocketDebuggerUrl). Sur Windows, épinglez également le navigateur à un seul cœur logique via PowerShell ((Get-Process -Id <pid>).ProcessorAffinity = <single bit>).
Surface API : goto(url), eval(expr) (awaitPromise + returnByValue, lève une exception en cas de exceptionDetails), waitFor(expr, timeout), send(method, params), un tableau logs (appels console, exceptions, entrées de log), close() (tuer le processus, supprimer le profil temporaire).
Point de départ (adaptez-le, ne le collez pas aveuglément) :
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 : test GPU, minuteur de réduction de priorité toutes les 5 s pour proc.pid + enfants, épingle d'affinité Windows
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('echec eval : ' + (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('delai depasse : ' + expr);
},
async close() {
try { ws.close(); } catch {} try { proc.kill(); } catch {}
setTimeout(() => fs.rmSync(profile, { recursive: true, force: true }), 500);
},
};
return api;
}
4. La page du film (film.html + scene.js)
La règle d'or
window.setT(t) est une fonction pure du temps. Donnez-lui des secondes, elle pose chaque personnage, accessoire, lumière et la caméra, rend l'image, terminé. Cela signifie :
- pas de
performance.now(), pas derequestAnimationFrame, pas d'état accumulé, pas de physique - pas de
Math.random(): utilisez un hachage ensemencé d'un index à la place
Grâce à cela, les images sont rendues dans n'importe quel ordre, les blocs reprennent proprement, et une seule image à n'importe quel moment est peu coûteuse. Une fois tout (polices, textures) chargé, définissez window.TOTAL, window.END_T et window.__ready = true.
film.html
Corps noir, marge nulle, overflow: hidden. Une import map pointant three et three/addons/ vers node_modules/three/.... Une superposition de sous-titres #subwrap / #sub. <script type="module" src="/scene.js">.
Rendu
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 doivent être égaux à uNear/uFar dans le shader DOF
La scène est rendue dans une cible HDR linéaire (half-float, MSAA 4x, texture de profondeur), afin que les écrans et lumières puissent dépasser 1.0. Ensuite, profondeur de champ, puis bloom en HDR, puis une passe composite qui effectue le mappage tonal et la correction gamma. Cette chaîne fait la différence entre "cinématographique" et "démo WebGL".
Ordre de travail dans setT
- poser les personnages
scene.updateMatrixWorld(true)- ancrage et attaches (accessoires aux mains, sièges au sol)
updateMatrixWorldà nouveau- mettre à jour les ancres (positions mondiales des têtes, mains, accessoires, écrans)
- placer la caméra à partir de la liste des plans
- mettre à jour le sous-titre
- rendre la scène → DOF → poussière → passe lumineuse → flous → composite vers le canevas
Post-traitement
Profondeur de champ. Agrégation sensible à la profondeur avec 64 échantillons en disque de Vogel. Les échantillons lumineux reçoivent un poids supplémentaire pour que les hautes lumières deviennent des disques de bokeh. Un arrière-plan flou n'est pas autorisé à déborder sur un premier plan net.
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; // l'arrière-plan ne peut pas couvrir un premier plan net
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); // boost bokeh
acc += s * w; wsum += w;
}
gl_FragColor = vec4(acc / wsum, 1.0);
}
uFocus: distance de la caméra à l'ancre de mise au point, mesurée le long de la direction de vue, recalculée à chaque image.uAperture: ~1000–2500. Plus élevé = plus faible profondeur de champ. Gros plans élevés, plans larges faibles. Par défaut modéré ; un flou épais ressemble à un filtre Instagram.uMaxCoc: 22 px.
Bloom.
- Passe lumineuse : `c * clamp((max(r,g,b)