feat: scad_animate 도구 — OpenSCAD $t 프레임 렌더 → ffmpeg MP4 합성

워크숍 채팅에서 "애니메이션으로 굴려보자" 요청이 python_eval subprocess
차단(scad_to_stl 설명의 "직접 실행하지 말 것" 안내)에 걸려 Pillow 투영
폴백으로 떨어지는 게 실측됨. OpenSCAD CLI는 runOpenscad으로 실제로 잘
돌아가므로, 프레임 렌더+합성 전용 단일목적 도구를 추가.

- 프레임은 /tmp 임시디렉터리에서 렌더해 워크스페이스엔 mp4 하나만 남김
  (PNG 수십 장이 파일탭에 쏟아지는 사고 방지)
- $t는 frames 기준 균등 간격(0~1-1/N)으로 -D 전달, defines 오버라이드
  scad_to_stl과 동일 패턴. camera는 김발 7값 CSV 검증 후 --camera 통과,
  생략 시 --autocenter --viewall
- ffmpeg libx264/yuv420p(+faststart) — 브라우저 <video> 호환
- stl_cad 3종과 동일하게 ws_ 세션 전용 게이트(tool-scope), 스키마 사본은
  build-tools에도 추가(둘 다 갱신 원칙). 숫자 그라운딩 게이트 면제 대상에
  scad_animate 추가(프레임 수/fps 등 방금 만든 파일 실측값이라 AUTO-RECOVER
  오탐 방지 — stl_cad/scad_to_stl과 동일 취지)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-22 21:10:08 +09:00
co-authored by Claude Code
parent bff18650a5
commit 2ee133b183
5 changed files with 139 additions and 3 deletions
+24
View File
@@ -1218,6 +1218,30 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
},
},
},
{
type: 'function',
function: {
// .scad $t 애니메이션 프레임 렌더 → ffmpeg MP4 합성 — 2026-09-22 추가.
// 기존 3종(stl_cad/scad_to_stl/write_scad)으론 프레임 렌더+동영상 합성이 안 돼서
// 모델이 Pillow 투영 애니메이션 폴백을 쓰는 게 실측됨(로봇 팔 애니메이션 요청).
// stl-cad.ts의 scadAnimateTool과 실행 로직 공유 — 이 파일의 스키마는 registry.ts의
// tool.jsonSchema와 별개로 유지되는 사본이라 둘 다 갱신해야 함.
name: 'scad_animate',
description: '.scad(OpenSCAD 소스) 파일을 $t 애니메이션으로 프레임 렌더해 MP4 동영상으로 합성한다. "애니메이션/움직이는 미리보기/MP4로 만들어줘" 같은 요청엔 이 도구를 바로 쓴다 — python_eval로 openscad/ffmpeg를 직접 실행하려 하지 말 것(subprocess가 막혀있음). scad 소스 안에 $t 변수로 관절/위치가 변하는 구조가 있어야 의미 있는 결과가 나온다($t가 없으면 프레임이 전부 같은 그림 — 그 경우 write_scad로 소스에 $t 분기를 넣은 뒤 이 도구를 호출할 것). 결과 mp4는 파일 탭(🎬)에 표시된다.',
parameters: {
type: 'object', required: ['scad_path', 'output_path'],
properties: {
scad_path: { type: 'string', description: '워크스페이스 기준 상대경로의 .scad 소스 파일. $t로 움직이는 구조(예: 회전각 = 90*$t)가 있어야 프레임마다 다른 장면이 나온다.' },
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'). 생략하면 --autocenter --viewall로 전체를 알아서 담는다. 고정 카메라에서 관절만 움직이는 게 보통 보기 좋다." },
imgsize: { type: 'string', description: "(선택) 프레임 크기 'W,H' — 기본 '640,640'. MP4 인코딩 특성상 짝수만 유효(홀수는 자동으로 짝수로 내림)." },
defines: { type: 'object', description: '(선택) 파라메트릭 변수 오버라이드 — scad_to_stl과 동일(-D 전달).' },
},
},
},
},
{
type: 'function',
function: {
+1 -1
View File
@@ -3190,7 +3190,7 @@ async function handleChat(
// 교차검증 등) 안내는 이들 결과엔 안 맞는다 — 2026-09-22 실측: CAD 변환 성공 응답에 이
// 안내가 붙자 약한 모델이 실제 STL 결과 대신 "규칙 숙지했습니다"로 엉뚱하게 답함.
if (!groundingReadingRuleInjected
&& allToolResults.some(r => GROUNDING_TOOL_PATTERN.test(String(r?.name || '')) && !/^(stl_cad|scad_to_stl)$/.test(String(r?.name || '')) && !r.error)) {
&& allToolResults.some(r => GROUNDING_TOOL_PATTERN.test(String(r?.name || '')) && !/^(stl_cad|scad_to_stl|scad_animate)$/.test(String(r?.name || '')) && !r.error)) {
groundingReadingRuleInjected = true;
messages.push({ role: 'user', content: groundingResultReadingRule(dateStr) });
}
+1 -1
View File
@@ -392,7 +392,7 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
// 원래 stl_cad는 키워드만 맞으면 메인 채팅/다른 앱에서도 쓰였는데(k2-app.html 웹 UI
// 패널과는 별개 경로라 안 건드려도 됨 — routes-cad.ts 직접 호출, 이 게이트 무관),
// 이제 워크숍 밖에선 안 뜬다.
if ((name === 'stl_cad' || name === 'scad_to_stl' || name === 'write_scad') && !isWorkshopAppSession) return false;
if ((name === 'stl_cad' || name === 'scad_to_stl' || name === 'write_scad' || name === 'scad_animate') && !isWorkshopAppSession) return false;
if (name === 'workshop_project' && !isWorkshopAppSession && !hasWorkshopKeyword) return false;
if (name === 'run_command' && !hasRunCommandKeyword) return false;
return true;
+2 -1
View File
@@ -33,7 +33,7 @@ import { drugDurCheckTool } from './mfds.js';
import { nvrSnapshotTool } from './nvr.js';
import { print3dModelTool } from './print3d.js';
import { workshopProjectTool } from './workshop-project.js';
import { stlCadTool, scadToStlTool, writeScadTool } from './stl-cad.js';
import { stlCadTool, scadToStlTool, writeScadTool, scadAnimateTool } from './stl-cad.js';
export interface Tool {
name: string;
@@ -244,6 +244,7 @@ class ToolRegistry {
this.registerSafe(stlCadTool);
this.registerSafe(scadToStlTool);
this.registerSafe(writeScadTool);
this.registerSafe(scadAnimateTool);
// News (NewsData.io — real headlines with real publish dates, for news queries)
this.registerSafe(newsSearchTool);
// Email tools (IMAP + SMTP)
+111
View File
@@ -1,5 +1,7 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawn } from 'child_process';
import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
import { buildImageMarkdown, isPathInsideDir } from './image.js';
@@ -433,3 +435,112 @@ export const writeScadTool = {
return { success: true, stdout: `저장됨: ${scadPathArg} — OpenSCAD 문법/렌더 검증 통과.${analysisLines}` };
},
};
// scad_animate — OpenSCAD $t 애니메이션 프레임 렌더 → ffmpeg MP4 합성 (2026-09-22 추가).
// stl_cad render는 STL 단일 프레임, scad_to_stl은 컴파일만 지원 — "애니메이션으로 굴려보자"
// 같은 요청(로봇 팔 관절 동작 등)은 이 도구로. 프레임은 /tmp 임시디렉토리에서 렌더해
// 워크스페이스에는 mp4 하나만 남긴다(파일탭에 PNG 24장이 쏟아지는 사고 방지).
// 워크숍 세션(ws_) 전용으로 노출(tool-scope.ts의 게이트).
export const scadAnimateTool = {
name: 'scad_animate',
description: '.scad(OpenSCAD 소스) 파일을 $t 애니메이션으로 프레임 렌더해 MP4 동영상으로 합성한다. "애니메이션/움직이는 미리보기/MP4로 만들어줘" 같은 요청엔 이 도구를 바로 쓴다 — python_eval로 openscad/ffmpeg를 직접 실행하려 하지 말 것(subprocess가 막혀있음). scad 소스 안에 $t 변수로 관절/위치가 변하는 구조가 있어야 의미 있는 결과가 나온다($t가 없으면 프레임이 전부 같은 그림 — 그 경우 write_scad로 소스에 $t 분기를 넣은 뒤 이 도구를 호출할 것). 결과 mp4는 파일 탭(🎬)에 표시된다.',
schema: { scad_path: 'string', output_path: 'string' },
jsonSchema: {
type: 'object',
required: ['scad_path', 'output_path'],
properties: {
scad_path: { type: 'string', description: '워크스페이스 기준 상대경로의 .scad 소스 파일. $t로 움직이는 구조(예: 회전각 = 90*$t)가 있어야 프레임마다 다른 장면이 나온다.' },
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'). 생략하면 --autocenter --viewall로 전체를 알아서 담는다. 고정 카메라에서 관절만 움직이는 게 보통 보기 좋다." },
imgsize: { type: 'string', description: "(선택) 프레임 크기 'W,H' — 기본 '640,640'. MP4 인코딩 특성상 짝수만 유효(홀수는 자동으로 짝수로 내림)." },
defines: { type: 'object', description: '(선택) 파라메트릭 변수 오버라이드 — scad_to_stl과 동일(-D 전달).' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
if (!fs.existsSync(OPENSCAD_BIN)) {
return { success: false, error: `OpenSCAD가 설치돼있지 않습니다(${OPENSCAD_BIN}). "sudo apt-get install -y openscad" 필요.` };
}
const workspacePath = getWorkspacePath(args);
const scadPathArg = String(args?.scad_path || '').trim();
if (!scadPathArg) return { success: false, error: 'scad_path가 필요합니다.' };
if (!/\.scad$/i.test(scadPathArg)) return { success: false, error: 'scad_path는 .scad로 끝나야 합니다.' };
let scadPath: string;
try { scadPath = resolveWorkspacePath(workspacePath, scadPathArg); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
if (!fs.existsSync(scadPath)) return { success: false, error: `파일을 찾을 수 없습니다: ${scadPathArg}` };
const outPathArg = String(args?.output_path || '').trim();
if (!outPathArg) return { success: false, error: 'output_path가 필요합니다.' };
let outPath: string;
try { outPath = resolveWorkspacePath(workspacePath, outPathArg); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
if (!/\.mp4$/i.test(outPath)) return { success: false, error: 'output_path는 .mp4로 끝나야 합니다.' };
const frames = Math.max(2, Math.min(120, Math.floor(Number(args?.frames)) || 24));
const fps = Math.max(1, Math.min(30, Math.floor(Number(args?.fps)) || 10));
// MP4(yuv420p)는 짝수 크기만 유효 — 홀수는 짝수로 내림.
const sizeM = /^(\d+)\s*,\s*(\d+)$/.exec(String(args?.imgsize || '').trim());
const w = Math.max(2, Math.floor(Number(sizeM?.[1] || 640) / 2) * 2);
const h = Math.max(2, Math.floor(Number(sizeM?.[2] || 640) / 2) * 2);
// --camera 값은 OpenSCAD 문법 그대로 통과 — 숫자 7개 CSV인지 정도만 검사해 오타를 일찍 잡는다.
const cameraArg = String(args?.camera || '').trim();
if (cameraArg && !/^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/.test(cameraArg.replace(/\s+/g, ''))) {
return { success: false, error: 'camera는 "tx,ty,tz,rx,ry,rz,dist" 숫자 7개 형식이어야 합니다 (예: "0,0,50,70,0,315,480").' };
}
// 프레임 저장소는 워크스페이스 밖(/tmp) — 성공/실패와 무관하게 finally에서 통째로 정리.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scad-anim-'));
try {
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,
'-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}` };
}
}
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const ffmpeg = await new Promise<{ ok: boolean; detail: string }>((resolve) => {
// yuv420p: 브라우저 <video> 호환(일부 플레이어가 high444를 못 닦음). +faststart: 스트리밍 재생.
const proc = spawn('ffmpeg', [
'-y', '-framerate', String(fps),
'-i', path.join(tmpDir, 'frame_%04d.png'),
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
'-loglevel', 'error',
outPath,
], { stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString('utf8'); if (stderr.length > 4000) stderr = stderr.slice(-4000); });
const killTimer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } }, 300_000);
proc.on('error', (e) => { clearTimeout(killTimer); resolve({ ok: false, detail: String(e.message || e) }); });
proc.on('close', (code) => {
clearTimeout(killTimer);
if (code === 0) resolve({ ok: true, detail: '' });
else resolve({ ok: false, detail: stderr.split('\n').filter(Boolean).slice(-8).join(' ') || `ffmpeg exit ${code}` });
});
});
if (!ffmpeg.ok || !fs.existsSync(outPath) || fs.statSync(outPath).size === 0) {
return { success: false, error: `MP4 합성 실패: ${ffmpeg.detail || '출력 파일이 비었음'}` };
}
const sizeMB = (fs.statSync(outPath).size / 1024 / 1024).toFixed(2);
return {
success: true,
stdout: `**${outPathArg}** 생성됨 — ${frames}프레임 @ ${fps}fps (재생 길이 약 ${(frames / fps).toFixed(1)}초, ${sizeMB}MB)\n` +
`- 파일 탭(🎬)에 표시된다. 카메라를 바꿔 각도가 마음에 들 때까지 다시 호출해도 된다(camera 파라메트릭).\n` +
`- 부드러운 움직임이 필요하면 fps를 15~24로, 반복을 담으려면 scad 소스에서 $t 활용 범위를 조정할 것.`,
};
} finally {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
}
},
};