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 또는 Edge | Headless로 실행합니다. |
| ffmpeg + ffprobe | PATH 또는 알려진 위치에 있어야 합니다. |
| three.js | npm 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 내부 작업 순서
- 캐릭터 포즈
scene.updateMatrixWorld(true)- 접지 및 부착물(손에 소품, 바닥에 의자)
updateMatrixWorld다시 호출- 앵커 업데이트 (머리, 손, 소품, 화면의 월드 위치)
- 쇼트 목록에서 카메라 배치
- 자막 업데이트
- 장면 렌더링 → 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
…