Community코딩 & 개발github.com

Dev-Claude Reel

약 50초 분량의 세로형(1080×1920) 3D 릴을 제작합니다. 개발자와 'Claude' 캐릭터가 짧고 재미있는 대화를 나누는 장면을, 스크립트, ElevenLabs 음성, three.js 장면의 프레임별 렌더링, 카메라 및 컷, 오디오 믹스, 최종 mp4 출력까지 전 과정을 자동화하며, 풀 렌더 전에 어색한 움직임을 잡아내는 QA 패스를 포함합니다.

Dev-Claude Reel란 무엇인가요?

이 스킬은 AI 에이전트가 약 50초 분량의 전문가 수준 3D 릴 비디오를 완전 자동 제작할 수 있도록 지원합니다. 사용자는 간단한 아이디어만 제공하면, 스킬이 스크립트 작성부터 ElevenLabs 음성 합성, three.js 기반 3D 장면의 프레임별 렌더링, 카메라 컷 편집, 오디오 믹싱, 최종 mp4 파일 생성까지 모든 파이프라인을 순차적으로 처리합니다. 특히 '풀 렌더 전에 스틸 컷으로 QA를 수행'하여 움직임 결함을 조기에 발견하고, 'setT(t)' 함수 기반의 결정론적 장면 설계로 프레임을 병렬 또는 순서에 관계없이 안전하게 렌더링할 수 있습니다. Claude Code, Cursor, Codex와 같은 터미널 기반 AI 코딩 도구에서 실행되며, Node.js 22+, headless Chrome, 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에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

dev-claude-reel

출력물: mp4 파일 1개, 1080×1920, 30fps, H.264 + AAC, 약 50초. 로우폴리 3D 장면, 두 캐릭터(개발자와 Claude 캐릭터), 실제 음성, 자막 내장, 대화에 맞춰 컷이 전환되는 카메라, 깔끔한 오디오 믹스.

방식: 단일 setT(t) 함수로 구동되는 three.js 페이지를 headless Chrome에서 한 프레임씩 렌더링하고, ffmpeg가 프레임과 오디오를 결합합니다.

이 스킬이 결정하지 않는 것: 캐릭터의 외형, 위치, 대화 주제. 사용자가 제공했다면 그 내용을 사용하고, 그렇지 않으면 몇 줄 안에 적절한 내용을 선택하여 진행합니다.

파이프라인 (순서대로, 건너뛰기 없음): 개요 → 스크립트 → 음성 → 타임라인 → 장면 → 스틸 → 움직임 검사 → 믹스 → 풀 렌더 → 먹싱 → 검증 → 전달


1. 기본 규칙

이 규칙들은 편의보다 우선하지만 사용자보다 우선하지는 않습니다.

  • 머신에 무리를 주지 마십시오. 사용자의 일상용 컴퓨터입니다. headless 브라우저 하나, GPU 렌더링, 최저 프로세스 우선순위, 병렬 워커 없음. WebGL이 소프트웨어 렌더러(SwiftShader / "software" / "Basic Render")를 보고하면 중단하고 사용자에게 알리십시오. 절대로 소프트웨어 GL을 강제하지 마십시오.
  • 비밀은 비밀로 유지하십시오. ElevenLabs 키는 ELEVENLABS_API_KEY (환경 변수 또는 .env 파일)에 저장됩니다. 스크립트 내에서만 읽고, 절대 인쇄, 로그 또는 눈에 보이는 곳에 기록하지 마십시오.
  • 스틸 우선, 영화는 그 다음. 풀 렌더는 스틸 컨택 시트가 제대로 보일 때까지 수행하지 마십시오. 단일 쇼트 실수를 위해 전체를 다시 렌더링하는 것은 이 워크플로에서 가장 비용이 많이 드는 오류입니다.
  • 포그라운드 청크 단위로 렌더링하십시오. 백그라운드 작업은 턴이 종료되면 사라집니다. 각 렌더 호출을 약 8분 미만으로 유지하고 재개 가능하게 만드십시오 (섹션 11).
  • 채팅당 하나의 비디오. 장면 파일이 빠르게 커집니다. 부분으로 나누고, 이유 없이 다시 읽지 말며, 다음 릴을 위해 새 채팅을 시작하십시오.
  • 파일을 전달하고, 절대 게시하지 마십시오. 게시는 사용자의 결정입니다.
  • 격차에 대해 정직하십시오. 키, ffmpeg 또는 Chrome이 누락되었습니까? 정확히 무엇이 누락되었는지 말하고 가장 가까운 실용적인 옵션을 제시하십시오. 결과를 절대 위조하지 마십시오.

2. 설정 확인 (첫 1분)

필요 사항참고 사항
Node 22+내장 fetch와 WebSocket을 사용합니다. npm 의존성은 three.js뿐입니다.
Chrome 또는 EdgeHeadless로 실행합니다.
ffmpeg + ffprobePATH 또는 알려진 위치에 있어야 합니다.
three.jsnpm i [email protected], 로컬 정적 서버에서 디스크로 제공됩니다. CDN 없음, 렌더 중 네트워크 없음.
ElevenLabs 키무료 티어로 충분합니다 (~10,000 크레딧/월, API 액세스, 사전 제작 음성, 상용 라이선스 없음). 문자당 약 1크레딧, 사운드 생성은 추가 비용.
GPU한 번 프로브합니다 (섹션 3).

ElevenLabs 예산: 어떤 것도 생성하기 전에 스크립트를 완료하고, 문자 수를 세고, 주요 대사에 대해서만 대체 테이크를 만들고, 모든 결과를 캐싱하여 두 번 지불하지 않도록 하십시오.

모델: eleven_v3. 계정에서 사용할 수 없으면 eleven_multilingual_v2로 폴백하고, 텍스트에서 [audio tags]를 제거한 후, 사용자에게 액팅이 더 평평해질 것이라고 경고하십시오.

키가 없습니까? 그렇게 말하십시오. 유용한 만큼 스크립트, 장면 및 무음 테스트 렌더를 구축한 다음 키를 요청하십시오. 사용자가 없이 진행하기를 고집하는 경우: OS 텍스트 음성 변환, 문자 수에서 추정된 단어 타이밍. 립싱크와 자막이 더 거칠어질 것이라고 알리십시오.

폴더 구조

비디오당 하나의 폴더, 일반 파일, 빌드 단계 없음.

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           headless Chrome 드라이버
  render.mjs        스틸 | 풀 (청크, 재개 가능)
  check.mjs         자동화된 QA
  make.mjs          gen-audio -> build-film -> music -> mix 실행
  vo/ sfx/ frames/ stills/

timeline.mjs를 변경하면 make.mjs를 처음부터 끝까지 다시 실행해야 합니다. 오래된 오디오 스템은 고전적인 보이지 않는 버그입니다. 이전 타이밍에 속한 사운드가 무작위 클릭으로 나타납니다.


3. headless 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 프로세스의 우선순위를 다시 높입니다). 자식 PID는 브라우저 웹소켓(/json/version → webSocketDebuggerUrl)에서 SystemInfo.getProcessInfo를 통해 가져옵니다. 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 프로브, proc.pid + 자식 프로세스에 대한 5초 우선순위 하향 타이머, 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 실패: ' + (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('시간 초과: ' + 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);   // 블룸 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-디스크 탭을 사용한 뎁스 인식 수집. 밝은 샘플은 추가 가중치를 받아 하이라이트가 보케 디스크로 변합니다. 흐릿한 배경이 선명한 전경을 덮는 것은 허용되지 않습니다.

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. 클수록 얕음. 클로즈업은 높게, 와이드는 낮게. 기본값은 보통; 심한 블러는 인스타그램 필터처럼 보입니다.
  • uMaxCoc: 22px.

블룸.

  • 밝은 영역 패스: `c * clamp((max(r,g,b

…

관련 스킬