dev-claude-reel 是做什么的?
输出: 一个MP4文件,1080×1920,30帧/秒,H.264 + AAC,时长约50秒。一个低多边形3D场景,两个角色(一个开发者和一个Claude角色),真实语音,内嵌字幕,根据对话切换的相机剪辑,以及清晰的混音。
方法: 一个Three.js页面由单一的setT(t)函数驱动。Headless Chrome逐帧渲染,ffmpeg将帧和音频合成。
此技能不做决定: 角色的外观、位置以及他们的对话内容。这些由用户提供。如果用户没有提供,则在几行内自行选择并开始执行。
流程(按此顺序,不可跳过): 简介 → 脚本 → 语音 → 时间线 → 场景 → 静帧 → 运动检查 → 混音 → 完整渲染 → 合成 → 校验 → 交付
1. 基本原则
这些规则优先于便利性,但不优先于用户需求。
- 不要伤害机器。 这是用户的日常工作电脑。只用一个无头浏览器,GPU渲染,最低进程优先级,无并行工作线程。如果WebGL报告使用软件渲染器(SwiftShader/"software"/"Basic Render"),立即停止并告知用户。永远不要强制使用软件GL。
- 秘密保密。 ElevenLabs密钥存储在
ELEVENLABS_API_KEY(环境变量或.env文件)中。仅在脚本内部读取。切勿在任何可见位置打印、记录或写入。 - 先静帧,后成片。 在静帧接触片验证正确之前,不要进行完整渲染。因单个镜头错误而重新完整渲染是本工作流中最昂贵的错误。
- 在前台分块渲染。 后台任务在轮次结束时中止。确保每次渲染调用时长控制在8分钟以内,并且可以恢复(见第11节)。
- 每次对话一个视频。 场景文件会快速增长。将其拆分为多个部分,不要无故重新读取,为下一个短片重新开始一个新的对话。
- 交付文件,不要发布。 发布由用户决定。
- 诚实面对缺口。 如果缺少密钥、ffmpeg或Chrome,准确说明缺少什么,并提供最接近的可行方案。切勿伪造结果。
2. 设置检查(第一分钟)
| 需求 | 备注 |
|---|---|
| Node 22+ | 使用内置的fetch和WebSocket。唯一的npm依赖项:three.js。 |
| Chrome 或 Edge | 以无头模式运行。 |
| ffmpeg + ffprobe | 位于PATH或已知位置。 |
| three.js | npm i [email protected],通过本地静态服务器从磁盘提供服务。渲染期间无需CDN,无需网络。 |
| ElevenLabs密钥 | 免费层级即可使用(每月约1万积分,API访问,预制语音,无商业许可)。每个字符约1积分,音效生成额外收费。 |
| GPU | 探测一次(第3节)。 |
ElevenLabs预算: 在生成任何内容之前完成脚本,统计字符数,仅对关键台词制作备用版本,并缓存每个结果,避免重复付费。
模型: eleven_v3。如果账户无法使用,降级到eleven_multilingual_v2,从文本中移除[音频标签],并警告用户表演效果会较差。
没有密钥? 如实告知。尽可能构建脚本、场景和静默测试渲染,然后请求密钥。如果用户坚持没有密钥继续:使用操作系统文本转语音,根据字符数估算单词时间。告知用户唇形同步和字幕的效果会比较差。
文件夹布局
每个视频一个文件夹,文件结构简单,无需构建步骤。
reel/
package.json three
film.html 画布 + 字幕叠加 + 导入映射,加载 scene.js
scene.js 3D世界 + setT(t)(过大时拆分为多个部分)
timeline.mjs 时间 + 镜头,由Node和浏览器共同导入
gen-audio.mjs takes.json -> ElevenLabs语音 + 环境音(缓存在磁盘上)
words.mjs 来自对齐json的单词时间信息
build-film.mjs 每个说话者的语音总线 + film.json(包络线,字幕)
music.mjs 可选背景音乐
mix.mjs 最终混音
cdp.mjs 无头Chrome驱动
render.mjs 静帧 | 完整渲染(分块,可恢复)
check.mjs 自动质量检查
make.mjs 运行 gen-audio -> build-film -> music -> mix
vo/ sfx/ frames/ stills/
对timeline.mjs的任何修改都需要从头到尾重新运行make.mjs。 过时的音频片段是最经典的隐形错误:属于旧时间线的声音会作为随机咔嗒声出现。
3. 驱动无头Chrome (cdp.mjs)
一个小型Chrome DevTools协议客户端,无依赖项。
启动标志:
--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
后端:Windows上使用d3d11,macOS上使用metal,Linux上使用gl。
连接后:
Emulation.setDeviceMetricsOverride { width: 1080, height: 1920, deviceScaleFactor: 1, mobile: false }- GPU探测: 通过
WEBGL_debug_renderer_info读取UNMASKED_RENDERER_WEBGL。如果为空,或匹配/swiftshader|software|basic render/i→ 中止并告知用户。 - 保持礼貌: 在每个Node脚本顶部设置
os.setPriority(os.constants.priority.PRIORITY_LOW)。大约每5秒,降低浏览器和所有子进程的优先级(Chrome会将其GPU进程的优先级恢复)。从浏览器WebSocket (/json/version→webSocketDebuggerUrl) 获取子进程pid。在Windows上,还通过PowerShell将浏览器锁定到一个逻辑核心 ((Get-Process -Id <pid>).ProcessorAffinity = <single bit>)。
API 表面: goto(url), eval(expr) (awaitPromise + returnByValue,抛出exceptionDetails), waitFor(expr, timeout), send(method, params), 一个logs数组(控制台调用、异常、日志条目),close()(终止进程,删除临时配置文件)。
起始点(请适配使用,不要盲目粘贴):
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 probe, 5 s priority-lowering timer for proc.pid + children, Windows affinity 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 failed: ' + (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('timeout: ' + expr);
},
async close() {
try { ws.close(); } catch {} try { proc.kill(); } catch {}
setTimeout(() => fs.rmSync(profile, { recursive: true, force: true }), 500);
},
};
return api;
}
4. 胶片页面 (film.html + scene.js)
黄金法则
window.setT(t) 是时间的一个纯函数。 给它秒数,它摆好每个角色、道具、灯光和相机,渲染帧,完成。这意味着:
- 没有
performance.now(),没有requestAnimationFrame,没有累积状态,没有物理引擎 - 没有
Math.random():改用基于索引的种子哈希
正因为如此,帧可以按任何顺序渲染,分块可以干净地恢复,并且任何时间点的单个静帧都很便宜。一旦所有内容(字体、纹理)加载完成,设置 window.TOTAL,window.END_T 和 window.__ready = true。
film.html
黑色背景,零边距,overflow: hidden。一个导入映射,将 three 和 three/addons/ 指向 node_modules/three/...。一个 #subwrap / #sub 字幕叠加层。<script type="module" src="/scene.js">。
渲染器
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 must equal uNear/uFar in the DOF shader
场景渲染到一个线性HDR目标(半浮点,4x MSAA,深度纹理),因此屏幕和灯光可以超过1.0。然后进行景深处理,接着在HDR中进行泛光,最后通过一次合成通道进行色调映射和伽马校正。这个流程是区分“电影感”和“WebGL演示”的关键。
setT 内部的工作顺序
- 摆好角色姿势
scene.updateMatrixWorld(true)- 接地和附件(道具到手,座椅到地板)
- 再次
updateMatrixWorld - 更新锚点(头部、手、道具、屏幕的世界位置)
- 根据镜头列表放置相机
- 更新字幕
- 渲染场景 → 景深 → 灰尘 → 亮部提取 → 模糊 → 合成到画布
后期处理
景深。 使用64个Vogel瓣采样的深度感知采集。亮部样本获得额外权重,使高光变成散景盘。不允许模糊的背景渗透到清晰的前景上。
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; // background can't cover sharp foreground
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 boost
acc += s * w; wsum += w;
}
gl_FragColor = vec4(acc / wsum, 1.0);
}
uFocus:从相机到焦点锚点的距离,沿着视线方向测量,每帧重新计算。uAperture:约1000–2500。值越大,景深越浅。特写镜头高,广角镜头低。默认为中等;过度模糊看起来像Instagram滤镜。uMaxCoc:22像素。
泛光。
- 亮部提取:`c * clamp((max(r,g,b...