Community编程与开发github.com

dev-claude-reel

制作一个约50秒的竖屏(1080×1920)3D短片,让开发者和一个“Claude”角色进行一段简短有趣的对话。处理脚本、ElevenLabs语音、逐帧渲染的Three.js场景、相机与剪辑、音频混音和最终MP4输出,并包含质量检查环节。

dev-claude-reel 是什么?

该技能是一个完整的AI代理工作流,用于自动生成一段约50秒的竖屏3D动画短片,其中包含开发者与Claude智能体之间的幽默对话。它从头到尾处理整个制作流程:撰写脚本、使用ElevenLabs生成角色语音(支持自动缓存以节省配额)、在Headless Chrome浏览器中逐帧渲染Three.js 3D场景、通过深度场和泛光等后期特效实现电影级画面、用ffmpeg合成音频与画面,最后输出H.264编码的MP4文件。它支持macOS、Windows和Linux平台,设计上兼顾资源友好性:优先渲染静态图像检查质量,仅在通过后才进行完整渲染;采用可中断的渲染分块策略,避免耗尽系统资源;严格保护API密钥安全。该技能不适合处理大型项目,建议每个对话只生成一个视频。

兼容平台✓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文件,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.jsnpm 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 内部的工作顺序

  1. 摆好角色姿势
  2. scene.updateMatrixWorld(true)
  3. 接地和附件(道具到手,座椅到地板)
  4. 再次 updateMatrixWorld
  5. 更新锚点(头部、手、道具、屏幕的世界位置)
  6. 根据镜头列表放置相机
  7. 更新字幕
  8. 渲染场景 → 景深 → 灰尘 → 亮部提取 → 模糊 → 合成到画布

后期处理

景深。 使用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...

相关技能