dev-claude-reel 是做什麼的?
輸出: 一個 mp4 檔案,解析度 1080x1920,30 fps,H.264 + AAC 編碼,長度約 50 秒。包含一個低多邊形 3D 場景、兩個角色(一位開發者和一個 Claude 角色)、真實人聲、內嵌字幕、根據對話切換的鏡頭,以及乾淨的音訊混音。
運作方式: 一個 three.js 頁面由一個單一的 setT(t) 函數驅動。無頭 Chrome 瀏覽器逐幀渲染畫面,ffmpeg 將幀與音訊結合。
此技能不決定的事項: 角色的外觀、所在位置以及他們談論的內容。如果使用者有提供,則採用使用者的設定。否則,用幾行字快速決定這些細節並開始執行。
流程(按此順序,不可跳過): brief → script → voices → timeline → scene → stills → motion check → mix → full render → mux → verify → deliver
1. 基本原則
這些規則優先於便利性。它們不凌駕於使用者的指示之上。
- 不要傷害機器。 這是使用者的日常用電腦。使用一個無頭瀏覽器、GPU 渲染、最低程式優先級、無平行工作執行緒。如果 WebGL 報告使用了軟體渲染器(SwiftShader / "software" / "Basic Render"),立即停止並告知使用者。絕不強制使用軟體 GL。
- 機密保持機密。 ElevenLabs 金鑰存放於
ELEVENLABS_API_KEY環境變數或.env檔案中。僅在腳本內部讀取它。絕不可以將其印出、記錄或寫入任何可見的地方。 - 先有預覽幀,再產出影片。 在預覽幀的接觸片看起來沒問題之前,不要進行完整的渲染。為了一個鏡頭的錯誤而重新完整渲染是此工作流程中最昂貴的失誤。
- 以前景區塊方式渲染。 後台工作會在工作階段結束時終止。每次渲染呼叫應控制在 8 分鐘以內,並使其可恢復。
- 每個聊天一個影片。 場景檔案會快速增長。將其分割成多個部分,不要無故重新讀取它,為下一個短片開啟一個新的聊天。
- 交付檔案,絕不發布。 發布與否由使用者決定。
- 誠實說明落差。 如果缺少金鑰、ffmpeg 或 Chrome?確切說明缺少什麼,並提供最接近的可行選項。絕不偽造結果。
2. 設定檢查(第一分鐘)
| 需求 | 備註 |
|---|---|
| Node 22+ | 使用內建的 fetch 和 WebSocket。唯一的 npm 依賴是 three.js。 |
| Chrome 或 Edge | 以無頭模式執行。 |
| ffmpeg + ffprobe | 位於 PATH 環境變數或已知路徑。 |
| three.js | npm i [email protected],由本地靜態伺服器從磁碟提供服務。渲染期間無 CDN,無網路連線。 |
| ElevenLabs 金鑰 | 免費方案即可使用(約 10,000 額度/月,API 存取,預設語音,無商業授權)。大約 1 額度/字元,音效生成需額外費用。 |
| GPU | 探查一次。 |
ElevenLabs 預算: 在生成任何內容之前先完成腳本,計算其字元數,僅為關鍵台詞製作備用錄音,並快取每個結果,避免為同一內容付費兩次。
模型: eleven_v3。如果帳戶無法使用,則回退至 eleven_multilingual_v2,從文字中移除 [audio tags],並警告使用者表演品質會較平淡。
沒有金鑰? 告知使用者。盡可能建立腳本、場景和靜音測試渲染至有用階段,然後要求提供金鑰。如果使用者堅持不提供金鑰:使用作業系統的文字轉語音功能,根據字元數估算單詞時間。告知使用者嘴型同步和字幕會較粗糙。
資料夾結構
每個影片一個資料夾,使用純文字檔案,無建置步驟。
reel/
package.json three
film.html 畫布 + 字幕疊層 + import map,載入 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 Protocol 客戶端,無依賴。
啟動參數:
--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 的SystemInfo.getProcessInfo取得子程序 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 陣列(console 呼叫、例外、日誌條目), 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 探查,5 秒優先級降低計時器用於 proc.pid + 子程序,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('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。一個 import map,將 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); // 泛光 1/4
const rtB2 = rt(W >> 3, H >> 3), rtB2b = rt(W >> 3, H >> 3); // 泛光 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 必須等於 DOF 著色器中的 uNear/uFar
場景渲染至一個線性 HDR 目標(半浮點,4x MSAA,深度紋理),因此螢幕和燈光可以超過 1.0。然後進行景深,然後在 HDR 中進行泛光,最後進行一次合成傳遞,進行色調映射和伽馬校正。這個鏈結就是「電影感」和「WebGL 展示」之間的區別。
setT 內部的工作順序
- 擺放角色姿勢
scene.updateMatrixWorld(true)- 地面聯繫與附加(道具到手部,座位到地板)
- 再次執行
updateMatrixWorld - 更新錨點(頭部、手部、道具、螢幕的世界位置)
- 根據鏡頭清單放置攝影機
- 更新字幕
- 渲染場景 → DOF → 灰塵 → 亮度提取 → 模糊 → 合成至畫布
後製處理
景深。 使用深度感知的 64 點 Vogel-disc 採集。亮色樣本獲得額外權重,使高光變成散景盤。不允許模糊的背景滲透到清晰的背景上。
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; // 背景不能覆蓋清晰的背景
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); // 散景增強
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
...(原文已截斷)