aimakeall-mcp 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Claude Code·Codex 같은 MCP 클라이언트에서 자연어로 AImakeAll 영
16
16
  "mcpServers": {
17
17
  "aimakeall": {
18
18
  "command": "npx",
19
- "args": ["-y", "aimakeall-mcp@0.5.0"],
19
+ "args": ["-y", "aimakeall-mcp@0.7.0"],
20
20
  "env": { "AIMAKEALL_PAT": "aio_pat_..." }
21
21
  }
22
22
  }
@@ -35,8 +35,8 @@ Claude Code·Codex 같은 MCP 클라이언트에서 자연어로 AImakeAll 영
35
35
  ## 기본 플로우 (쇼츠)
36
36
 
37
37
  ```
38
- plan_shorts_video → (씬마다) generate_scene_image → generate_scene_video_prompt → generate_scene_video
39
- → (보이스 미지정 시) recommend_voice → tts_narration → stitch_timeline → render_start → render_status(폴링) → render_result
38
+ plan_shorts_video → (씬마다) generate_scene_image → [QC: 동봉 이미지 직접 확인 또는 verify_scene_image] → generate_scene_video_prompt → generate_scene_video → [QC: 동봉 프레임 확인 또는 verify_scene_video]
39
+ → (보이스 미지정 시) recommend_voice → tts_narration_with_captions(하단자막·강조용) 또는 tts_narration → stitch_timeline → render_start → render_status(폴링) → render_result
40
40
  → ai_publish_metadata → publish_youtube
41
41
  ```
42
42
 
@@ -44,6 +44,8 @@ plan_shorts_video → (씬마다) generate_scene_image → generate_scene_video_
44
44
  - 렌더 잡은 `~/.aimakeall/render-jobs.json`에 기록되어 프록시 재시작 후 `list_render_jobs`로 복구할 수 있습니다.
45
45
  - 대용량 산출물(TTS mp3, 스티치 매니페스트)은 파일 경로로 주고받아 모델 컨텍스트를 오염시키지 않습니다.
46
46
  - `recommend_voice`는 주제·샘플 영상(유튜브)에 맞는 보이스를 자동 선정합니다 — 샘플 화자 분석은 컴패니언 앱이 실행 중일 때만 동작합니다.
47
+ - 자체검증(QC): 씬 이미지/영상 결과에 이미지·프레임 블록이 동봉되어 에이전트가 직접 보고 판정합니다. 비전 미지원 클라이언트는 `verify_scene_image`/`verify_scene_video`(서버 Gemini 판정)를 쓰세요 — 불합격 시 `regenerationHint`를 반영해 재생성.
48
+ - 하단자막 강조: `tts_narration_with_captions`의 `subtitleLines`와 `plan_shorts_video`의 `emphasisKeywords`를 `stitch_timeline`에 함께 넘기면 자막 안 해당 단어만 노랑/큰 글씨로 강조됩니다(최신 컴패니언 런타임 필요 — 구버전은 일반 자막으로 안전 강등).
47
49
 
48
50
  ## 한도
49
51
 
@@ -95,6 +95,49 @@ function jsonResult(value) {
95
95
  return textResult(JSON.stringify(value, null, 2));
96
96
  }
97
97
 
98
+ // JSON + 이미지 블록 결과 — 비전 지원 클라이언트의 에이전트가 산출물을 "직접 보고"
99
+ // QC(1층 검증)할 수 있게 한다. 비전 미지원 클라이언트는 이미지 블록을 무시한다.
100
+ function jsonWithImagesResult(value, images = []) {
101
+ return {
102
+ content: [
103
+ { text: JSON.stringify(value, null, 2), type: "text" },
104
+ ...images
105
+ .filter((image) => image?.base64)
106
+ .map((image) => ({ data: image.base64, mimeType: image.mimeType || "image/jpeg", type: "image" })),
107
+ ],
108
+ };
109
+ }
110
+
111
+ // 인라인 이미지 블록에 허용하는 MIME — MCP SDK 안전 목록(png/jpeg/webp/gif)만.
112
+ const ALLOWED_INLINE_IMAGE_MIME = new Set(["image/gif", "image/jpeg", "image/jpg", "image/png", "image/webp"]);
113
+ // 모델 API 의 이미지당 한도(약 5MB)를 넘지 않게 — 초과분은 동봉 생략(검증 도구 폴백 안내).
114
+ const MAX_INLINE_IMAGE_BYTES = 4 * 1024 * 1024;
115
+
116
+ // 결과 이미지 다운로드 — research_product 이미지 가드와 동일 규칙
117
+ // (공개 URL만, redirect 미추적, image/* 검증, 8MB 스트리밍 캡).
118
+ async function downloadImageAsBase64(imageUrl) {
119
+ if (!isSafePublicImageUrl(imageUrl)) return null;
120
+ try {
121
+ const response = await fetch(imageUrl, { redirect: "manual", signal: AbortSignal.timeout(30_000) });
122
+ if (!response.ok) return null;
123
+ const contentType = String(response.headers.get("content-type") || "").toLowerCase().split(";")[0].trim();
124
+ if (!ALLOWED_INLINE_IMAGE_MIME.has(contentType)) return null;
125
+ if (!response.body) return null;
126
+ const parts = [];
127
+ let received = 0;
128
+ for await (const chunk of response.body) {
129
+ const piece = Buffer.from(chunk);
130
+ received += piece.length;
131
+ if (received > MAX_INLINE_IMAGE_BYTES) return null;
132
+ parts.push(piece);
133
+ }
134
+ if (!received) return null;
135
+ return { base64: Buffer.concat(parts).toString("base64"), mimeType: contentType };
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
98
141
  const NO_PAT_GUIDE = "AIMAKEALL_PAT가 설정되지 않았습니다. aimakeall.com → API 키 설정 → MCP 토큰에서 발급한 뒤 MCP 설정의 env에 넣어주세요.";
99
142
 
100
143
  function describeLocalError(error) {
@@ -150,7 +193,7 @@ export function registerCloudTools(server, config, api) {
150
193
 
151
194
  server.tool(
152
195
  "plan_shorts_video",
153
- "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로 이어집니다.",
196
+ "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조).",
154
197
  {
155
198
  topic: z.string().optional().describe("영상 주제"),
156
199
  copy: z.string().optional().describe("핵심 카피/대사"),
@@ -180,6 +223,7 @@ export function registerCloudTools(server, config, api) {
180
223
  });
181
224
  return jsonResult({
182
225
  ctaText: payload?.ctaText,
226
+ emphasisKeywords: Array.isArray(payload?.emphasisKeywords) ? payload.emphasisKeywords : [],
183
227
  narrationScript: payload?.narrationScript,
184
228
  ok: payload?.ok,
185
229
  scenes: trimPlanScenes(payload?.scenes),
@@ -317,16 +361,17 @@ export function registerCloudTools(server, config, api) {
317
361
 
318
362
  server.tool(
319
363
  "generate_scene_image",
320
- "씬 이미지를 생성합니다 (기획 결과의 imagePrompt 사용). 반환된 imageUrl을 씬 영상 생성의 입력으로 쓰세요. 인물·제품 일관성은 이 참조 방식이 권장 경로입니다: 첫 씬(또는 캐릭터 시트/제품 사진)의 이미지를 referenceImageUrls·referenceImagePaths로 모든 씬에 앵커로 전달하세요.",
364
+ "씬 이미지를 생성합니다 (기획 결과의 imagePrompt 사용). 반환된 imageUrl을 씬 영상 생성의 입력으로 쓰세요. 인물·제품 일관성은 이 참조 방식이 권장 경로입니다: 첫 씬(또는 캐릭터 시트/제품 사진)의 이미지를 referenceImageUrls·referenceImagePaths로 모든 씬에 앵커로 전달하세요. 결과에 이미지 블록이 동봉됩니다 — 반드시 직접 보고 QC(캐릭터 앵커·왜곡·지시 이행) 후 불합격 시 재생성하세요. 이미지를 볼 수 없으면 verify_scene_image 사용.",
321
365
  {
322
366
  prompt: z.string().describe("이미지 프롬프트 (plan 결과의 imagePrompt)"),
323
367
  aspectRatio: z.string().optional().describe("기본 9:16"),
324
368
  model: z.enum(["gpt-image-2-beta", "gemini-3.1-flash-image-preview", "doubao-seedream-5.0-lite", "google-flow-nano-banana-pro", "kie-grok-imagine-image"]).optional().describe("이미지 모델, 기본 gpt-image-2-beta"),
369
+ returnImage: z.boolean().optional().describe("기본 true — 결과 이미지를 직접 보고 QC 할 수 있게 이미지 블록으로 함께 반환"),
325
370
  resolution: z.string().optional().describe("기본 1K"),
326
371
  referenceImageUrls: z.array(z.string()).max(6).optional().describe("참조 이미지 URL (씬1 앵커 등)"),
327
372
  referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 이미지 로컬 경로 (제품 사진 등)"),
328
373
  },
329
- wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [] }) => {
374
+ wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true }) => {
330
375
  for (const filePath of referenceImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 이미지" });
331
376
  assertEncodedBudget(referenceImagePaths, { context: "참조 이미지" });
332
377
  const referenceImages = [
@@ -353,6 +398,7 @@ export function registerCloudTools(server, config, api) {
353
398
 
354
399
  let imageUrl = String(payload?.imageUrl || "");
355
400
  let savedPath = "";
401
+ let inlineImage = null;
356
402
  if (!imageUrl && payload?.imageDataUrl) {
357
403
  // URL 없이 base64만 온 경우 — 컨텍스트로 돌려주지 않고 파일로 저장.
358
404
  const match = String(payload.imageDataUrl).match(/^data:([^;]+);base64,(.+)$/s);
@@ -360,14 +406,24 @@ export function registerCloudTools(server, config, api) {
360
406
  const ext = match[1].includes("png") ? "png" : "jpg";
361
407
  const saved = saveMediaBuffer(config.stateDir, `scene-image.${ext}`, Buffer.from(match[2], "base64"));
362
408
  savedPath = saved.filePath;
409
+ const inlineMime = String(match[1]).toLowerCase();
410
+ if (returnImage && ALLOWED_INLINE_IMAGE_MIME.has(inlineMime) && match[2].length <= MAX_INLINE_IMAGE_BYTES * 4 / 3) {
411
+ inlineImage = { base64: match[2], mimeType: inlineMime };
412
+ }
363
413
  }
414
+ } else if (imageUrl && returnImage) {
415
+ inlineImage = await downloadImageAsBase64(imageUrl);
364
416
  }
365
- return jsonResult({
417
+ const resultBody = {
366
418
  imageUrl: imageUrl || undefined,
367
419
  model: payload?.model,
420
+ qc: inlineImage
421
+ ? "동봉된 이미지를 직접 확인하세요: 캐릭터 앵커 일치·손가락/글자 왜곡·프롬프트 이행. 불합격이면 사유를 프롬프트에 반영해 재생성하세요. 이미지를 볼 수 없는 클라이언트라면 verify_scene_image 를 호출하세요."
422
+ : "이미지를 동봉하지 못했습니다 — verify_scene_image 로 서버측 검증을 수행하세요 (imageUrl 이 없으면 savedPath 를 imagePath 로 넘기세요).",
368
423
  savedPath: savedPath || undefined,
369
424
  taskId: payload?.taskId,
370
- });
425
+ };
426
+ return inlineImage ? jsonWithImagesResult(resultBody, [inlineImage]) : jsonResult(resultBody);
371
427
  }),
372
428
  );
373
429
 
@@ -414,7 +470,7 @@ export function registerCloudTools(server, config, api) {
414
470
 
415
471
  server.tool(
416
472
  "generate_scene_video",
417
- "씬 영상을 생성합니다 (동기 호출, 최대 ~30분 — MCP 클라이언트의 툴 타임아웃(MCP_TIMEOUT)을 그 이상으로 늘려두세요. 타임아웃되면 이미 과금된 결과를 회수할 수 없으니 주의). 반환된 videoUrl을 stitch_timeline의 sceneVideos에 넣으세요.",
473
+ "씬 영상을 생성합니다 (동기 호출, 최대 ~30분 — MCP 클라이언트의 툴 타임아웃(MCP_TIMEOUT)을 그 이상으로 늘려두세요. 타임아웃되면 이미 과금된 결과를 회수할 수 없으니 주의). 반환된 videoUrl을 stitch_timeline의 sceneVideos에 넣으세요. 결과에 대표 프레임 3장이 동봉됩니다 — 반드시 직접 보고 QC 후 불합격 시 재생성하세요. 볼 수 없으면 verify_scene_video 사용.",
418
474
  {
419
475
  prompt: z.string().describe("영상 프롬프트 (generate_scene_video_prompt의 videoPrompt)"),
420
476
  sceneImageUrl: z.string().optional().describe("씬 이미지 URL (i2v 입력)"),
@@ -422,8 +478,9 @@ export function registerCloudTools(server, config, api) {
422
478
  durationSec: z.number().optional().describe("기본 8 (모델 상한으로 클램프됨)"),
423
479
  modelId: z.enum(["kie-grok-imagine", "seedance-2.0", "seedance-2.0-mini", "kling-3.0"]).optional().describe("i2v 영상 모델, 기본 kie-grok-imagine"),
424
480
  quality: z.string().optional().describe("기본 720p"),
481
+ returnFrames: z.boolean().optional().describe("기본 true — 결과 영상의 대표 프레임 3장을 직접 보고 QC 할 수 있게 이미지 블록으로 반환"),
425
482
  },
426
- wrapCloudHandler(config, async ({ prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p" }) => {
483
+ wrapCloudHandler(config, async ({ prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p", returnFrames = true }) => {
427
484
  const payload = await api.request("/api/tracker/gemini/storyboard-scene-video", {
428
485
  body: {
429
486
  aspectRatio,
@@ -445,13 +502,31 @@ export function registerCloudTools(server, config, api) {
445
502
  // 서버 최악 경로(KIE 900s + Evolink 폴백 3회×900s)를 넘겨 잡아 조기 abort 로 과금-미회수를 방지.
446
503
  timeoutMs: 1_800_000,
447
504
  });
448
- return jsonResult({
505
+ // 대표 프레임 동봉(1층 QC) — 실패는 조용히 넘기고 verify_scene_video 안내로 대체.
506
+ let frames = [];
507
+ if (returnFrames && payload?.videoUrl) {
508
+ try {
509
+ const frameResp = await api.request("/api/tracker/video/frames", {
510
+ body: { count: 3, maxWidth: 480, videoUrl: payload.videoUrl },
511
+ method: "POST",
512
+ timeoutMs: 60_000, // 과금 완료된 결과를 오래 붙들지 않기 — 실패해도 verify 폴백 안내
513
+ });
514
+ if (frameResp?.ok && Array.isArray(frameResp?.frames)) frames = frameResp.frames;
515
+ } catch {
516
+ frames = [];
517
+ }
518
+ }
519
+ const resultBody = {
449
520
  durationLabel: payload?.durationLabel,
450
521
  model: payload?.model,
522
+ qc: frames.length
523
+ ? "동봉된 대표 프레임 3장을 직접 확인하세요: 캐릭터 붕괴·모션 파탄·왜곡. 불합격이면 프롬프트를 교정해 재생성하세요. 프레임을 볼 수 없는 클라이언트라면 verify_scene_video 를 호출하세요."
524
+ : "프레임을 동봉하지 못했습니다 — verify_scene_video 로 서버측 검증을 수행하세요.",
451
525
  resolvedRouteLabel: payload?.resolvedRouteLabel,
452
526
  taskId: payload?.taskId,
453
527
  videoUrl: payload?.videoUrl,
454
- });
528
+ };
529
+ return frames.length ? jsonWithImagesResult(resultBody, frames) : jsonResult(resultBody);
455
530
  }),
456
531
  );
457
532
 
@@ -579,7 +654,7 @@ export function registerCloudTools(server, config, api) {
579
654
 
580
655
  server.tool(
581
656
  "stitch_timeline",
582
- "씬 영상들과 오디오를 서버에서 타임라인 매니페스트로 합칩니다. 결과는 파일 핸들(payloadPath)로 반환되며, 이를 render_start에 넘기면 이 PC에서 mp4가 렌더됩니다. 서버는 렌더하지 않습니다.",
657
+ "씬 영상들과 오디오를 서버에서 타임라인 매니페스트로 합칩니다. 결과는 파일 핸들(payloadPath)로 반환되며, 이를 render_start에 넘기면 이 PC에서 mp4가 렌더됩니다. 서버는 렌더하지 않습니다. 하단자막 강조: emphasisKeywords 를 주면 자막 라인 안의 해당 단어만 다른 색/크기로 강조되고, subtitleLines[].style 로 라인별 디자인도 바꿀 수 있습니다.",
583
658
  {
584
659
  sceneVideos: z.array(z.object({
585
660
  durationSec: z.number(),
@@ -600,10 +675,24 @@ export function registerCloudTools(server, config, api) {
600
675
  endSec: z.number(),
601
676
  startSec: z.number(),
602
677
  text: z.string(),
678
+ style: z.object({
679
+ fontSize: z.number().optional(),
680
+ fontWeight: z.enum(["bold", "normal"]).optional(),
681
+ strokeColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
682
+ strokeWidth: z.number().optional(),
683
+ textBackgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
684
+ textBackgroundEnabled: z.boolean().optional(),
685
+ textBackgroundOpacity: z.number().optional(),
686
+ textColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
687
+ textEffectVariant: z.string().optional(),
688
+ }).optional().describe("이 라인만의 디자인 오버라이드 (내용에 따라 라인별 디자인을 달리할 때)"),
603
689
  })).optional().describe("자막 타이밍 (쇼츠 하단자막) — tts_narration_with_captions의 subtitleLines를 그대로 사용"),
690
+ emphasisKeywords: z.array(z.string()).max(12).optional().describe("강조 단어 목록 — 자막 라인 안에서 해당 단어만 다른 색/크기로 렌더 (plan_shorts_video의 emphasisKeywords 를 그대로 전달)"),
691
+ emphasisColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional().describe("강조 색 #RRGGBB (기본 #facc15 노랑)"),
692
+ emphasisSizeScale: z.number().optional().describe("강조 크기 배율 0.5~2 (기본 1.2)"),
604
693
  transitionMode: z.enum(["fade", "none"]).optional().describe("기본 fade"),
605
694
  },
606
- wrapCloudHandler(config, async ({ sceneVideos, featureKey = "commerceVideo", projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines = [], transitionMode = "fade" }) => {
695
+ wrapCloudHandler(config, async ({ sceneVideos, featureKey = "commerceVideo", projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines = [], emphasisKeywords = [], emphasisColor = "", emphasisSizeScale = 0, transitionMode = "fade" }) => {
607
696
  // 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
608
697
  if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
609
698
  if (bgmAudioPath) assertAllowedInputFile(bgmAudioPath, new Set([".mp3", ".wav"]), { kind: "BGM 오디오" });
@@ -626,6 +715,9 @@ export function registerCloudTools(server, config, api) {
626
715
  label: scene.label || `scene-${index + 1}`,
627
716
  url: scene.url,
628
717
  })),
718
+ emphasisColor,
719
+ emphasisKeywords,
720
+ emphasisSizeScale,
629
721
  subtitleLines,
630
722
  titleText,
631
723
  transitionMode,
@@ -1164,6 +1256,82 @@ export function registerCloudTools(server, config, api) {
1164
1256
  }),
1165
1257
  );
1166
1258
 
1259
+ server.tool(
1260
+ "verify_scene_image",
1261
+ "생성된 키프레임 이미지를 서버 비전(Gemini)이 QC 판정합니다 — 캐릭터 참조 대조·아티팩트·프롬프트 이행. 이미지 블록을 직접 볼 수 없는 클라이언트의 폴백이며, 판정 JSON(pass/violations/regenerationHint)만 읽으면 됩니다. 불합격이면 regenerationHint 를 프롬프트에 반영해 generate_scene_image 를 재호출하세요.",
1262
+ {
1263
+ imageUrl: z.string().optional().describe("generate_scene_image 가 반환한 imageUrl"),
1264
+ imagePath: z.string().optional().describe("imageUrl 이 없을 때 — 생성 결과의 savedPath (로컬 파일)"),
1265
+ prompt: z.string().optional().describe("기대 내용(생성에 쓴 프롬프트 요약)"),
1266
+ referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 캐릭터 이미지 로컬 경로 (생성에 쓴 것과 동일하게)"),
1267
+ checklist: z.array(z.string()).max(8).optional().describe("추가 검수 항목 (예: 후드티는 남색)"),
1268
+ },
1269
+ wrapCloudHandler(config, async ({ imageUrl = "", imagePath = "", prompt = "", referenceImagePaths = [], checklist = [] }) => {
1270
+ if (!imageUrl && !imagePath) {
1271
+ return textResult("imageUrl 또는 imagePath 중 하나는 필요합니다.", { isError: true });
1272
+ }
1273
+ if (imagePath) {
1274
+ assertAllowedInputFile(imagePath, ALLOWED_IMAGE_EXTS, { kind: "검증 대상 이미지" });
1275
+ }
1276
+ for (const filePath of referenceImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 이미지" });
1277
+ assertEncodedBudget([imagePath, ...referenceImagePaths].filter(Boolean), { context: "검증 이미지" });
1278
+ const candidateUrl = imageUrl || fileToDataUrl(imagePath, { allowedExts: ALLOWED_IMAGE_EXTS, kind: "검증 대상 이미지" });
1279
+ const payload = await api.request("/api/tracker/gemini/verify-scene", {
1280
+ body: {
1281
+ checklist,
1282
+ imageUrl: candidateUrl,
1283
+ kind: "image",
1284
+ prompt,
1285
+ referenceImages: referenceImagePaths.map((filePath) => ({
1286
+ name: path.basename(filePath),
1287
+ previewUrl: fileToDataUrl(filePath, { allowedExts: ALLOWED_IMAGE_EXTS, kind: "참조 이미지" }),
1288
+ })),
1289
+ },
1290
+ method: "POST",
1291
+ timeoutMs: 300_000,
1292
+ });
1293
+ if (payload?.ok === false) {
1294
+ return textResult(`씬 검증 실패: ${payload?.error || "알 수 없는 오류"}\n${payload?.guidance || ""}`.trim(), { isError: true });
1295
+ }
1296
+ return jsonResult({ referenceLoaded: payload?.referenceLoaded, verdict: payload?.verdict || null });
1297
+ }),
1298
+ );
1299
+
1300
+ server.tool(
1301
+ "verify_scene_video",
1302
+ "생성된 씬 영상을 서버가 대표 프레임으로 QC 판정합니다 (Gemini 비전) — 캐릭터 참조 대조·붕괴/왜곡·프롬프트 이행. 프레임 블록을 직접 볼 수 없는 클라이언트의 폴백. 불합격이면 regenerationHint 를 반영해 generate_scene_video 를 재호출하세요.",
1303
+ {
1304
+ videoUrl: z.string().describe("generate_scene_video 가 반환한 videoUrl"),
1305
+ prompt: z.string().optional().describe("기대 내용(생성에 쓴 프롬프트 요약)"),
1306
+ referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 캐릭터 이미지 로컬 경로"),
1307
+ checklist: z.array(z.string()).max(8).optional().describe("추가 검수 항목"),
1308
+ frameCount: z.number().int().min(2).max(4).optional().describe("판정에 쓸 프레임 수, 기본 3"),
1309
+ },
1310
+ wrapCloudHandler(config, async ({ videoUrl, prompt = "", referenceImagePaths = [], checklist = [], frameCount = 3 }) => {
1311
+ for (const filePath of referenceImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 이미지" });
1312
+ assertEncodedBudget(referenceImagePaths, { context: "참조 이미지" });
1313
+ const payload = await api.request("/api/tracker/gemini/verify-scene", {
1314
+ body: {
1315
+ checklist,
1316
+ frameCount,
1317
+ kind: "video",
1318
+ prompt,
1319
+ referenceImages: referenceImagePaths.map((filePath) => ({
1320
+ name: path.basename(filePath),
1321
+ previewUrl: fileToDataUrl(filePath, { allowedExts: ALLOWED_IMAGE_EXTS, kind: "참조 이미지" }),
1322
+ })),
1323
+ videoUrl,
1324
+ },
1325
+ method: "POST",
1326
+ timeoutMs: 600_000, // 다운로드 120s + ffmpeg + Gemini 최악 경로 여유
1327
+ });
1328
+ if (payload?.ok === false) {
1329
+ return textResult(`씬 검증 실패: ${payload?.error || "알 수 없는 오류"}\n${payload?.guidance || ""}`.trim(), { isError: true });
1330
+ }
1331
+ return jsonResult({ referenceLoaded: payload?.referenceLoaded, verdict: payload?.verdict || null });
1332
+ }),
1333
+ );
1334
+
1167
1335
  // ── 상세페이지(PDP) ─────────────────────────────────────────────────────────
1168
1336
  server.tool(
1169
1337
  "generate_pdp",
package/lib/config.mjs CHANGED
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
2
2
  import path from "node:path";
3
3
 
4
4
  // 프록시 버전 — 서버가 X-AImakeAll-MCP-Version 으로 하한을 강제(426)할 수 있다.
5
- export const MCP_PROXY_VERSION = "0.5.0";
5
+ export const MCP_PROXY_VERSION = "0.7.0";
6
6
 
7
7
  export const DEFAULT_API_BASE = "https://aimakeall.com";
8
8
  export const DEFAULT_COMPANION_URL = "http://127.0.0.1:9876";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimakeall-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "AImakeAll MCP 서버 — Claude Code/Codex에서 자연어로 영상 기획·생성·렌더·퍼블리시 (렌더는 로컬 컴패니언)",
5
5
  "type": "module",
6
6
  "bin": {