Community程式設計與開發github.com

NiklasMVP/dev-claude-reel

製作一個約 50 秒的垂直 (1080x1920) 3D 短片,內容是一位開發者和一個「Claude」角色進行簡短有趣的對話。此技能負責處理腳本、ElevenLabs 聲音、使用 three.js 場景在無頭 Chrome 中逐幀渲染、鏡頭與切換、音訊混音、最終 mp4 輸出,以及一個在全渲染前捕捉卡頓動作的 QA 流程。

dev-claude-reel 是什麼?

此技能旨在自動化生成一個約 50 秒、1080x1920 解析度、30fps 的 3D 動畫短片,主要用於展示開發者與 AI 角色(如 Claude)之間的對話。它能處理整個流程,從腳本生成、使用 ElevenLabs 進行語音合成、到使用 three.js 在無頭 Chrome 瀏覽器中逐幀渲染 3D 場景,並最終合成音訊與字幕為一個 MP4 檔案。其核心優勢在於效率與資源管理,採用「先預覽再渲染」的策略,透過接觸片(still sheets)檢查動作是否流暢,避免因單一錯誤而耗費大量時間重新渲染。同時,它會將渲染任務分割成可恢復的區塊,並嚴格限制對系統資源的消耗,確保不會影響使用者日常電腦的運作。此技能支援 Node.js、Chrome/Edge 瀏覽器、ffmpeg 和 ElevenLabs API,並內建了 GPU 檢測、優先級控制和 ElevenLabs 憑證安全處理等機制。總之,它為開發者提供了一個一站式的解決方案,能快速、可靠地產出帶有專業配音和後製效果的高品質概念驗證短片,非常適合用於展示、教學或社群媒體內容創作。

相容平台✓Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/NiklasMVP/dev-claude-reel/tree/HEAD/dev-claude-reel

Installed? Explore more 程式設計與開發 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

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.jsnpm 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 內部的工作順序

  1. 擺放角色姿勢
  2. scene.updateMatrixWorld(true)
  3. 地面聯繫與附加(道具到手部,座位到地板)
  4. 再次執行 updateMatrixWorld
  5. 更新錨點(頭部、手部、道具、螢幕的世界位置)
  6. 根據鏡頭清單放置攝影機
  7. 更新字幕
  8. 渲染場景 → 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

...(原文已截斷)

相關技能