fix: scad_animate를 --animate 네이티브 방식으로 전환
OpenSCAD 2021.01 CLI에서 -D '$t=x' 오버라이드가 최상위 변수 할당에 반영되지 않아 프레임 48장이 전부 동일 이미지가 되는 문제 수정. - 프레임 렌더를 프레임별 -D 루프 → --animate N 단일 프로세스로 전환 (엔진이 $t를 직접 구동, frame00000.png~ 5자리 0-인덱스로 export) - 프루브도 -D '$t=0.5' 단일 포즈 → --animate 4 포즈 STL bbox 합집합으로 카메라 자동 프레이밍(관절이 벌어지는 전 범위 커버) - 렌더 타임아웃을 프레임 수 기준으로 재조정(60s + 프레임당 20s, 상한 900s) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1235,7 +1235,7 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
|
||||
output_path: { type: 'string', description: '결과 MP4를 저장할 워크스페이스 기준 상대경로(.mp4로 끝나야 함).' },
|
||||
frames: { type: 'number', description: '(선택) 프레임 수 — 기본 24, 최대 120. $t는 0부터 (1-1/frames)까지 균등 간격.' },
|
||||
fps: { type: 'number', description: '(선택) MP4 프레임레이트 — 기본 10(1초~2.4초 분량). 부드러운 동작이면 15~24.' },
|
||||
camera: { type: 'string', description: "(선택) OpenSCAD --camera 김발값 — 'tx,ty,tz,rx,ry,rz,dist' 형식(예: '0,0,50,70,0,315,480'). 생략하면 모델 실측(중간 프레임을 STL로 컴파일해 바운딩박스 측정) 기준으로 자동 프레이밍한다 — 굳이 줄 필요 없음. 고정 카메라에서 관절만 움직이는 게 보통 보기 좋다." },
|
||||
camera: { type: 'string', description: "(선택) OpenSCAD --camera 김발값 — 'tx,ty,tz,rx,ry,rz,dist' 형식(예: '0,0,50,70,0,315,480'). 생략하면 모델 실측(애니메이션 4포즈를 STL로 컴파일해 바운딩박스 합집합 측정) 기준으로 자동 프레이밍한다 — 굳이 줄 필요 없음. 고정 카메라에서 관절만 움직이는 게 보통 보기 좋다." },
|
||||
imgsize: { type: 'string', description: "(선택) 프레임 크기 'W,H' — 기본 '640,640'. MP4 인코딩 특성상 짝수만 유효(홀수는 자동으로 짝수로 내림)." },
|
||||
defines: { type: 'object', description: '(선택) 파라메트릭 변수 오버라이드 — scad_to_stl과 동일(-D 전달).' },
|
||||
},
|
||||
|
||||
+36
-29
@@ -494,37 +494,43 @@ export const scadAnimateTool = {
|
||||
try {
|
||||
// 카메라 자동 결정 — --autocenter/--viewall은 오프스크린 CLI 렌더에서 모델을 프레임에
|
||||
// 신뢰성 있게 못 담는다(2026-09-22 실측: 모델이 구석에 작게 나오거나 아예 밖으로 나가
|
||||
// "노란 빈 화면" MP4가 됨). renderStlPng와 같은 방식으로, 대표 프레임($t=0.5)을 STL로
|
||||
// 컴파일해 실측 바운딩박스 중심+거리로 명시적 김발 카메라를 계산한다. 애니메이션 중
|
||||
// 관절이 벌어지는 만큼 여유를 두고 dist는 최대길이×3.
|
||||
// "노란 빈 화면" MP4가 됨). renderStlPng와 같은 방식으로, --animate로 4개 포즈를 STL로
|
||||
// 뽑아 bbox 합집합 중심+거리로 명시적 김발 카메라를 계산한다(애니메이션 중 관절이
|
||||
// 벌어지는 범위까지 커버).
|
||||
if (!cameraArg) {
|
||||
const probeStl = path.join(tmpDir, 'probe.stl');
|
||||
const probe = await runOpenscad(['-D', '$t=0.5', ...buildDefineArgs(args?.defines), '-o', probeStl, '--export-format=binstl', scadPath], 120_000);
|
||||
if (probe.ok && fs.existsSync(probeStl) && fs.statSync(probeStl).size >= 84) {
|
||||
try {
|
||||
const bbox = readStlBBox(probeStl);
|
||||
const center = [0, 1, 2].map(i => (bbox.min[i] + bbox.max[i]) / 2);
|
||||
const maxExtent = Math.max(bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]);
|
||||
const dist = Math.max(50, maxExtent * 3);
|
||||
cameraArg = `${center.map(n => n.toFixed(2)).join(',')},65,0,315,${dist.toFixed(1)}`;
|
||||
} catch { /* 자동 프레이밍 실패 — 아래 폴백(--autocenter --viewall)로 렌더 시도 */ }
|
||||
await runOpenscad(['--animate', '4', ...buildDefineArgs(args?.defines), '--export-format=binstl', '-o', path.join(tmpDir, 'probe.stl'), scadPath], 240_000);
|
||||
const probeStls = fs.readdirSync(tmpDir).filter(f => /^probe\d+\.stl$/.test(f)).sort();
|
||||
const boxes: ReturnType<typeof readStlBBox>[] = [];
|
||||
for (const f of probeStls) {
|
||||
const p = path.join(tmpDir, f);
|
||||
if (!fs.existsSync(p) || fs.statSync(p).size < 84) continue;
|
||||
try { boxes.push(readStlBBox(p)); } catch { /* 프루브 포즈 하나 실패는 무시 */ }
|
||||
}
|
||||
if (boxes.length) {
|
||||
const mn = [0, 1, 2].map(k => Math.min(...boxes.map(b => b.min[k])));
|
||||
const mx = [0, 1, 2].map(k => Math.max(...boxes.map(b => b.max[k])));
|
||||
const center = [0, 1, 2].map(k => (mn[k] + mx[k]) / 2);
|
||||
const maxExtent = Math.max(mx[0] - mn[0], mx[1] - mn[1], mx[2] - mn[2]);
|
||||
const dist = Math.max(50, maxExtent * 3);
|
||||
cameraArg = `${center.map(n => n.toFixed(2)).join(',')},65,0,315,${dist.toFixed(1)}`;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < frames; i++) {
|
||||
const t = (i / frames).toFixed(4);
|
||||
const framePath = path.join(tmpDir, `frame_${String(i + 1).padStart(4, '0')}.png`);
|
||||
const r = await runOpenscad([
|
||||
'-D', `$t=${t}`,
|
||||
...buildDefineArgs(args?.defines),
|
||||
...(cameraArg ? [`--camera=${cameraArg.replace(/\s+/g, '')}`] : ['--autocenter', '--viewall']),
|
||||
'--imgsize=' + w + ',' + h,
|
||||
'--colorscheme=Tomorrow',
|
||||
'-o', framePath,
|
||||
scadPath,
|
||||
], 90_000);
|
||||
if (!r.ok || !fs.existsSync(framePath) || fs.statSync(framePath).size === 0) {
|
||||
return { success: false, error: `프레임 ${i + 1}/${frames}($t=${t}) 렌더 실패: ${r.ok ? 'PNG이 생성되지 않음' : r.detail}` };
|
||||
}
|
||||
// 프레임 렌더는 --animate N 한 번으로 — OpenSCAD가 $t를 0..(N-1)/N으로 돌리며
|
||||
// frame00000.png.. 식으로 내려준다. ⚠️ -D '$t=x'로 프레임별 오버라이드는 금물:
|
||||
// OpenSCAD 2021.01 CLI에서 최상위 변수 할당(예: J1 = 90*sin($t*360))에 -D 오버라이드가
|
||||
// 반영되지 않아 48장이 전부 동일 이미지가 되는 사고를 실측함(2026-09-22).
|
||||
const r = await runOpenscad([
|
||||
'--animate', String(frames),
|
||||
...buildDefineArgs(args?.defines),
|
||||
...(cameraArg ? [`--camera=${cameraArg.replace(/\s+/g, '')}`] : ['--autocenter', '--viewall']),
|
||||
'--imgsize=' + w + ',' + h,
|
||||
'--colorscheme=Tomorrow',
|
||||
'-o', path.join(tmpDir, 'frame.png'),
|
||||
scadPath,
|
||||
], Math.min(900_000, 60_000 + frames * 20_000));
|
||||
const frameFiles = fs.readdirSync(tmpDir).filter(f => /^frame\d+\.png$/.test(f)).sort();
|
||||
if (!r.ok || frameFiles.length < frames) {
|
||||
return { success: false, error: `프레임 렌더 실패: ${frames}장 중 ${frameFiles.length}장 생성${r.ok ? '' : ` — ${r.detail}`}` };
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
@@ -532,7 +538,8 @@ export const scadAnimateTool = {
|
||||
// yuv420p: 브라우저 <video> 호환(일부 플레이어가 high444를 못 닦음). +faststart: 스트리밍 재생.
|
||||
const proc = spawn('ffmpeg', [
|
||||
'-y', '-framerate', String(fps),
|
||||
'-i', path.join(tmpDir, 'frame_%04d.png'),
|
||||
'-start_number', '0',
|
||||
'-i', path.join(tmpDir, 'frame%05d.png'),
|
||||
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
|
||||
'-loglevel', 'error',
|
||||
outPath,
|
||||
|
||||
Reference in New Issue
Block a user