aimakeall-mcp 0.6.0 → 0.8.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.6.0"],
19
+ "args": ["-y", "aimakeall-mcp@0.8.0"],
20
20
  "env": { "AIMAKEALL_PAT": "aio_pat_..." }
21
21
  }
22
22
  }
@@ -35,7 +35,7 @@ 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
38
+ plan_shorts_video → (씬마다) generate_scene_image → [QC: 동봉 이미지 직접 확인 또는 verify_scene_image] → generate_scene_video_prompt → generate_scene_video → [QC: 동봉 프레임 확인 또는 verify_scene_video]
39
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
  ```
@@ -44,7 +44,31 @@ 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`를 반영해 재생성.
47
48
  - 하단자막 강조: `tts_narration_with_captions`의 `subtitleLines`와 `plan_shorts_video`의 `emphasisKeywords`를 `stitch_timeline`에 함께 넘기면 자막 안 해당 단어만 노랑/큰 글씨로 강조됩니다(최신 컴패니언 런타임 필요 — 구버전은 일반 자막으로 안전 강등).
49
+ - 렌더 영수증: `render_status`(완료 시)와 `render_result`가 산출물 실측(`summary.measured` — 길이·해상도·오디오·디코드 청결성·파일 크기)을 요청 규격과 대조해 `mismatches`로 알려줍니다. 불일치가 있으면 저장 전 원인을 확인하세요.
50
+ - 원가 가시화: 생성 전 `estimate_video_cost`로 견적을 내고, 작업 후 `get_cost_report`로 실제 지출을 확인하세요. 어두운/검은 컷 의심 시 `verify_render_darkness`로 완성본 휘도를 실측할 수 있습니다.
51
+ - 캐릭터 일관성: `plan_*`에 `characters`(이름·외모 앵커·의상 고정)를 넘기면 씬마다 identity lock 이 프롬프트에 강제 주입되고, `generate_scene_image`의 `identityLock`으로 재생성 시에도 유지됩니다.
52
+
53
+ ## 채널 규격 지문 (벤치마킹)
54
+
55
+ ```
56
+ measure_channel_spec(벤치마크 영상 URL ≤5) → fingerprint → plan_shorts_video(channelFingerprint: fingerprint)
57
+ ```
58
+
59
+ - 벤치마크 채널의 길이·컷/분·라우드니스·말끝 스타일을 **실측**해 숫자 규격으로 만들고, 대본 생성이 그 규격을 강제하게 합니다. 실측(샷 감지·오디오 추출)은 컴패니언 앱이 수행합니다.
60
+ - 대본은 생성 후 규격 게이트(글자수·문장수·종결어미 교차·교훈조 아웃트로 금지)로 검증되며, 위반 시 1회 자동 리라이트 후 남은 위반을 `specWarnings`로 돌려줍니다.
61
+
62
+ ## 짜집기 (리메이크) 플로우
63
+
64
+ ```
65
+ detect_shots(원본별, 컴패니언) → analyze_edit_points(videos + shotsFiles) → 에이전트가 sceneCatalog에서 컷 선별·내레이션 작성
66
+ → prepare_remake_timeline(sourceVideos + rows) → render_start(payloadPath) → render_status(폴링) → render_result
67
+ ```
68
+
69
+ - 행(row) 모드: `[N]` TTS 내레이션(원본 무음) / `[S]` 원본 대사 유지 / `[A]` 현장음 유지 / `[SN]` 원본 대사 + TTS.
70
+ - `analyze_edit_points`가 돌려주는 영상별 `burnedSubtitle`(박힌 자막 관측)을 `prepare_remake_timeline`의 `sourceVideos[].burnedSubtitle`로 그대로 넘기고 `subtitleCropMode: "auto"`를 주면 자막 박힌 소스만 하단 크롭됩니다.
71
+ - `[N]`/`[SN]` 행의 `audioContent`는 유료 TTS로 합성됩니다 — 실행 전 사용자 확인을 받으세요. 원본 다운로드·렌더는 이 PC의 컴패니언이 수행합니다.
48
72
 
49
73
  ## 한도
50
74
 
@@ -29,6 +29,11 @@ import { trimWavToSeconds } from "./wav-trim.mjs";
29
29
  // 서버가 전체 업로드를 받은 뒤 413 을 내는 것을 막고 행동 가능한 한국어 안내를 준다.
30
30
  const BODY_BUDGET_BYTES = SERVER_JSON_BODY_LIMIT_BYTES - 512 * 1024; // 바디의 나머지 필드 여유
31
31
 
32
+ // 컴패니언 계약 상수 — companion/index.cjs 의 MAX_SHOT_COUNT 및 오디오 추출 포맷과 맞춘다.
33
+ // 오디오는 extractCompanionAudio 기본값(16kHz)·16bit·모노 = 초당 32,000 바이트.
34
+ const COMPANION_MAX_SHOTS = 18;
35
+ const COMPANION_AUDIO_BYTES_PER_SECOND = 16_000 * 2;
36
+
32
37
  function assertEncodedBudget(filePaths, { context = "요청" } = {}) {
33
38
  let total = 0;
34
39
  for (const filePath of filePaths.filter(Boolean)) {
@@ -95,6 +100,49 @@ function jsonResult(value) {
95
100
  return textResult(JSON.stringify(value, null, 2));
96
101
  }
97
102
 
103
+ // JSON + 이미지 블록 결과 — 비전 지원 클라이언트의 에이전트가 산출물을 "직접 보고"
104
+ // QC(1층 검증)할 수 있게 한다. 비전 미지원 클라이언트는 이미지 블록을 무시한다.
105
+ function jsonWithImagesResult(value, images = []) {
106
+ return {
107
+ content: [
108
+ { text: JSON.stringify(value, null, 2), type: "text" },
109
+ ...images
110
+ .filter((image) => image?.base64)
111
+ .map((image) => ({ data: image.base64, mimeType: image.mimeType || "image/jpeg", type: "image" })),
112
+ ],
113
+ };
114
+ }
115
+
116
+ // 인라인 이미지 블록에 허용하는 MIME — MCP SDK 안전 목록(png/jpeg/webp/gif)만.
117
+ const ALLOWED_INLINE_IMAGE_MIME = new Set(["image/gif", "image/jpeg", "image/jpg", "image/png", "image/webp"]);
118
+ // 모델 API 의 이미지당 한도(약 5MB)를 넘지 않게 — 초과분은 동봉 생략(검증 도구 폴백 안내).
119
+ const MAX_INLINE_IMAGE_BYTES = 4 * 1024 * 1024;
120
+
121
+ // 결과 이미지 다운로드 — research_product 이미지 가드와 동일 규칙
122
+ // (공개 URL만, redirect 미추적, image/* 검증, 8MB 스트리밍 캡).
123
+ async function downloadImageAsBase64(imageUrl) {
124
+ if (!isSafePublicImageUrl(imageUrl)) return null;
125
+ try {
126
+ const response = await fetch(imageUrl, { redirect: "manual", signal: AbortSignal.timeout(30_000) });
127
+ if (!response.ok) return null;
128
+ const contentType = String(response.headers.get("content-type") || "").toLowerCase().split(";")[0].trim();
129
+ if (!ALLOWED_INLINE_IMAGE_MIME.has(contentType)) return null;
130
+ if (!response.body) return null;
131
+ const parts = [];
132
+ let received = 0;
133
+ for await (const chunk of response.body) {
134
+ const piece = Buffer.from(chunk);
135
+ received += piece.length;
136
+ if (received > MAX_INLINE_IMAGE_BYTES) return null;
137
+ parts.push(piece);
138
+ }
139
+ if (!received) return null;
140
+ return { base64: Buffer.concat(parts).toString("base64"), mimeType: contentType };
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
98
146
  const NO_PAT_GUIDE = "AIMAKEALL_PAT가 설정되지 않았습니다. aimakeall.com → API 키 설정 → MCP 토큰에서 발급한 뒤 MCP 설정의 env에 넣어주세요.";
99
147
 
100
148
  function describeLocalError(error) {
@@ -130,6 +178,9 @@ function trimPlanScenes(scenes) {
130
178
  ratio: scene?.ratio,
131
179
  sceneNarration: scene?.sceneNarration,
132
180
  scenePurpose: scene?.scenePurpose,
181
+ visibleCharacterSlots: Array.isArray(scene?.visibleCharacterSlots) && scene.visibleCharacterSlots.length
182
+ ? scene.visibleCharacterSlots
183
+ : undefined,
133
184
  }));
134
185
  }
135
186
 
@@ -148,6 +199,51 @@ export function registerCloudTools(server, config, api) {
148
199
  wrapCloudHandler(config, async () => jsonResult(await api.request("/api/tracker/usage"))),
149
200
  );
150
201
 
202
+ server.tool(
203
+ "get_cost_report",
204
+ "내 계정의 AI 원가 원장을 요약 조회합니다 — 총 지출, 모델별 상위 지출, 최근 이벤트. 여러 씬을 생성/재생성하기 전후로 호출해 실제 지출 델타를 확인하세요 (추측 과금 방지).",
205
+ {
206
+ limit: z.number().int().min(10).max(120).optional().describe("최근 이벤트 조회 수, 기본 60"),
207
+ },
208
+ wrapCloudHandler(config, async ({ limit = 60 }) => {
209
+ const payload = await api.request(`/api/tracker/account/cost-ledger?limit=${limit}`, { method: "GET", timeoutMs: 30_000 });
210
+ const ai = payload?.productionCost?.ai || {};
211
+ const byModel = ai?.byModel && typeof ai.byModel === "object" ? ai.byModel : {};
212
+ const topModels = Object.entries(byModel)
213
+ .map(([modelKey, row]) => ({ calls: row?.calls || 0, modelKey, usd: row?.usd || 0 }))
214
+ .sort((left, right) => right.usd - left.usd)
215
+ .slice(0, 8);
216
+ const recent = (Array.isArray(ai?.history) ? ai.history : []).slice(0, 15)
217
+ .map((event) => ({ at: event?.createdAt, kind: event?.kind, label: event?.label, modelKey: event?.modelKey, usd: event?.usd }));
218
+ return jsonResult({
219
+ recent,
220
+ topModels,
221
+ totalCalls: ai?.calls ?? payload?.summary?.calls,
222
+ totalUsd: ai?.totalUsd ?? payload?.summary?.totalUsd,
223
+ });
224
+ }),
225
+ );
226
+
227
+ server.tool(
228
+ "estimate_video_cost",
229
+ "생성 파이프라인 비용을 실행 전에 견적냅니다 (공급자 호출·과금 없음, 단가표 계산). 씬 수를 늘리거나 재생성 루프를 돌기 전에 호출해 예상 지출을 확인하세요.",
230
+ {
231
+ sceneCount: z.number().int().min(0).max(60).describe("씬 수"),
232
+ imageModel: z.string().optional().describe("기본 gpt-image-2-beta"),
233
+ imagesPerScene: z.number().int().min(0).max(4).optional().describe("씬당 이미지 수, 기본 1"),
234
+ referenceImageCount: z.number().int().min(0).max(6).optional().describe("이미지당 참조 장수 (일부 모델 단가 가산)"),
235
+ videoModel: z.string().optional().describe("기본 kie-grok-imagine"),
236
+ videoSecondsPerScene: z.number().min(0).max(30).optional().describe("씬당 영상 초 (0이면 영상 미포함)"),
237
+ videoQuality: z.string().optional().describe("기본 720p"),
238
+ ttsProvider: z.enum(["typecast", "elevenlabs", "supertone"]).optional().describe("기본 typecast"),
239
+ ttsChars: z.number().int().min(0).max(20000).optional().describe("내레이션 글자 수"),
240
+ },
241
+ wrapCloudHandler(config, async (input) => {
242
+ const payload = await api.request("/api/tracker/cost/estimate", { body: input, method: "POST", timeoutMs: 30_000 });
243
+ return jsonResult(payload);
244
+ }),
245
+ );
246
+
151
247
  server.tool(
152
248
  "plan_shorts_video",
153
249
  "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조).",
@@ -158,17 +254,40 @@ export function registerCloudTools(server, config, api) {
158
254
  targetCustomer: z.string().optional(),
159
255
  tone: z.string().optional().describe("기본 '자동 추천'"),
160
256
  sceneCount: z.number().int().min(1).max(10).optional().describe("기본 3"),
257
+ channelFingerprint: z.object({
258
+ sampleSize: z.number().optional(),
259
+ durationTargetSec: z.number().nullable().optional(),
260
+ cutIntervalSec: z.number().nullable().optional(),
261
+ cutsPerMin: z.number().nullable().optional(),
262
+ cpsTarget: z.number().nullable().optional(),
263
+ loudnessDbfs: z.number().nullable().optional(),
264
+ silenceRatio: z.number().nullable().optional(),
265
+ subtitleYPct: z.number().nullable().optional(),
266
+ subtitleLines: z.number().nullable().optional(),
267
+ endingStyle: z.string().nullable().optional(),
268
+ emphasisCount: z.number().nullable().optional(),
269
+ hasBgm: z.boolean().nullable().optional(),
270
+ }).passthrough().optional().describe("measure_channel_spec 이 반환한 fingerprint — 대본이 실측 규격을 강제하게 함"),
271
+ characters: z.array(z.object({
272
+ name: z.string().describe("캐릭터 이름"),
273
+ appearance: z.string().optional().describe("외모 앵커 (얼굴형·헤어·피부톤·의상 등)"),
274
+ role: z.string().optional(),
275
+ outfitLock: z.string().optional().describe("의상 고정 (예: navy hoodie)"),
276
+ forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
277
+ })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
161
278
  },
162
- wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 3 }) => {
279
+ wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 3, characters = [], channelFingerprint = null }) => {
163
280
  if (!String(topic).trim() && !String(copy).trim()) {
164
281
  return textResult("topic 또는 copy 중 하나는 입력해야 합니다.", { isError: true });
165
282
  }
166
283
  const payload = await api.request("/api/tracker/shorts-video/plan", {
167
284
  body: {
168
285
  categoryId,
286
+ channelFingerprint,
169
287
  copy,
170
288
  operationId: createUsageEventId("shorts-plan"),
171
289
  sceneCount,
290
+ characters,
172
291
  selectedCharacters: [],
173
292
  skillOverride: null,
174
293
  targetCustomer,
@@ -179,12 +298,14 @@ export function registerCloudTools(server, config, api) {
179
298
  timeoutMs: 240_000,
180
299
  });
181
300
  return jsonResult({
301
+ charactersUsed: Array.isArray(payload?.charactersUsed) ? payload.charactersUsed : undefined,
182
302
  ctaText: payload?.ctaText,
183
303
  emphasisKeywords: Array.isArray(payload?.emphasisKeywords) ? payload.emphasisKeywords : [],
184
304
  narrationScript: payload?.narrationScript,
185
305
  ok: payload?.ok,
186
306
  scenes: trimPlanScenes(payload?.scenes),
187
307
  skill: payload?.skill,
308
+ specWarnings: Array.isArray(payload?.specWarnings) && payload.specWarnings.length ? payload.specWarnings : undefined,
188
309
  tagline: payload?.tagline,
189
310
  });
190
311
  }),
@@ -280,8 +401,15 @@ export function registerCloudTools(server, config, api) {
280
401
  categoryId: z.string().optional().describe("기본 ecommerce"),
281
402
  sceneCount: z.number().int().min(1).max(12).optional().describe("기본 6"),
282
403
  researchBrief: z.string().optional().describe("research_product 결과의 planBrief — 불만·후킹·경쟁공백이 기획에 반영됨"),
404
+ characters: z.array(z.object({
405
+ name: z.string().describe("캐릭터 이름"),
406
+ appearance: z.string().optional().describe("외모 앵커 (얼굴형·헤어·피부톤·의상 등)"),
407
+ role: z.string().optional(),
408
+ outfitLock: z.string().optional().describe("의상 고정 (예: navy hoodie)"),
409
+ forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
410
+ })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
283
411
  },
284
- wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" }) => {
412
+ wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" , characters = [] }) => {
285
413
  // 확장자 검증(임의 파일 업로드 차단) + 합산 크기 예산(서버 413 사전 차단).
286
414
  for (const filePath of productImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "이미지" });
287
415
  if (modelImagePath) assertAllowedInputFile(modelImagePath, ALLOWED_IMAGE_EXTS, { kind: "모델 이미지" });
@@ -297,6 +425,7 @@ export function registerCloudTools(server, config, api) {
297
425
  productName,
298
426
  researchBrief,
299
427
  sceneCount,
428
+ characters,
300
429
  selectedCharacters: [],
301
430
  skillOverride: null,
302
431
  targetCustomer,
@@ -318,16 +447,21 @@ export function registerCloudTools(server, config, api) {
318
447
 
319
448
  server.tool(
320
449
  "generate_scene_image",
321
- "씬 이미지를 생성합니다 (기획 결과의 imagePrompt 사용). 반환된 imageUrl을 씬 영상 생성의 입력으로 쓰세요. 인물·제품 일관성은 이 참조 방식이 권장 경로입니다: 첫 씬(또는 캐릭터 시트/제품 사진)의 이미지를 referenceImageUrls·referenceImagePaths로 모든 씬에 앵커로 전달하세요.",
450
+ "씬 이미지를 생성합니다 (기획 결과의 imagePrompt 사용). 반환된 imageUrl을 씬 영상 생성의 입력으로 쓰세요. 인물·제품 일관성은 이 참조 방식이 권장 경로입니다: 첫 씬(또는 캐릭터 시트/제품 사진)의 이미지를 referenceImageUrls·referenceImagePaths로 모든 씬에 앵커로 전달하세요. 결과에 이미지 블록이 동봉됩니다 — 반드시 직접 보고 QC(캐릭터 앵커·왜곡·지시 이행) 후 불합격 시 재생성하세요. 이미지를 볼 수 없으면 verify_scene_image 사용.",
322
451
  {
323
452
  prompt: z.string().describe("이미지 프롬프트 (plan 결과의 imagePrompt)"),
324
453
  aspectRatio: z.string().optional().describe("기본 9:16"),
325
454
  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"),
455
+ returnImage: z.boolean().optional().describe("기본 true — 결과 이미지를 직접 보고 QC 할 수 있게 이미지 블록으로 함께 반환"),
456
+ identityLock: z.string().optional().describe("plan 응답 charactersUsed[].identityLock 을 그대로 — 프롬프트 말미에 리터럴 부착해 캐릭터 일관성 잠금 (paraphrase 금지)"),
326
457
  resolution: z.string().optional().describe("기본 1K"),
327
458
  referenceImageUrls: z.array(z.string()).max(6).optional().describe("참조 이미지 URL (씬1 앵커 등)"),
328
459
  referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 이미지 로컬 경로 (제품 사진 등)"),
329
460
  },
330
- wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [] }) => {
461
+ wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true, identityLock = "" }) => {
462
+ // identity_lock 리터럴 부착 — 이미 포함돼 있으면 재부착하지 않는다(멱등: 프롬프트 캐시 보존).
463
+ const lock = String(identityLock || "").trim();
464
+ if (lock && !prompt.includes(lock)) prompt = `${prompt.trim()}, ${lock}`;
331
465
  for (const filePath of referenceImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 이미지" });
332
466
  assertEncodedBudget(referenceImagePaths, { context: "참조 이미지" });
333
467
  const referenceImages = [
@@ -354,6 +488,7 @@ export function registerCloudTools(server, config, api) {
354
488
 
355
489
  let imageUrl = String(payload?.imageUrl || "");
356
490
  let savedPath = "";
491
+ let inlineImage = null;
357
492
  if (!imageUrl && payload?.imageDataUrl) {
358
493
  // URL 없이 base64만 온 경우 — 컨텍스트로 돌려주지 않고 파일로 저장.
359
494
  const match = String(payload.imageDataUrl).match(/^data:([^;]+);base64,(.+)$/s);
@@ -361,14 +496,24 @@ export function registerCloudTools(server, config, api) {
361
496
  const ext = match[1].includes("png") ? "png" : "jpg";
362
497
  const saved = saveMediaBuffer(config.stateDir, `scene-image.${ext}`, Buffer.from(match[2], "base64"));
363
498
  savedPath = saved.filePath;
499
+ const inlineMime = String(match[1]).toLowerCase();
500
+ if (returnImage && ALLOWED_INLINE_IMAGE_MIME.has(inlineMime) && match[2].length <= MAX_INLINE_IMAGE_BYTES * 4 / 3) {
501
+ inlineImage = { base64: match[2], mimeType: inlineMime };
502
+ }
364
503
  }
504
+ } else if (imageUrl && returnImage) {
505
+ inlineImage = await downloadImageAsBase64(imageUrl);
365
506
  }
366
- return jsonResult({
507
+ const resultBody = {
367
508
  imageUrl: imageUrl || undefined,
368
509
  model: payload?.model,
510
+ qc: inlineImage
511
+ ? "동봉된 이미지를 직접 확인하세요: 캐릭터 앵커 일치·손가락/글자 왜곡·프롬프트 이행. 불합격이면 사유를 프롬프트에 반영해 재생성하세요. 이미지를 볼 수 없는 클라이언트라면 verify_scene_image 를 호출하세요."
512
+ : "이미지를 동봉하지 못했습니다 — verify_scene_image 로 서버측 검증을 수행하세요 (imageUrl 이 없으면 savedPath 를 imagePath 로 넘기세요).",
369
513
  savedPath: savedPath || undefined,
370
514
  taskId: payload?.taskId,
371
- });
515
+ };
516
+ return inlineImage ? jsonWithImagesResult(resultBody, [inlineImage]) : jsonResult(resultBody);
372
517
  }),
373
518
  );
374
519
 
@@ -415,7 +560,7 @@ export function registerCloudTools(server, config, api) {
415
560
 
416
561
  server.tool(
417
562
  "generate_scene_video",
418
- "씬 영상을 생성합니다 (동기 호출, 최대 ~30분 — MCP 클라이언트의 툴 타임아웃(MCP_TIMEOUT)을 그 이상으로 늘려두세요. 타임아웃되면 이미 과금된 결과를 회수할 수 없으니 주의). 반환된 videoUrl을 stitch_timeline의 sceneVideos에 넣으세요.",
563
+ "씬 영상을 생성합니다 (동기 호출, 최대 ~30분 — MCP 클라이언트의 툴 타임아웃(MCP_TIMEOUT)을 그 이상으로 늘려두세요. 타임아웃되면 이미 과금된 결과를 회수할 수 없으니 주의). 반환된 videoUrl을 stitch_timeline의 sceneVideos에 넣으세요. 결과에 대표 프레임 3장이 동봉됩니다 — 반드시 직접 보고 QC 후 불합격 시 재생성하세요. 볼 수 없으면 verify_scene_video 사용.",
419
564
  {
420
565
  prompt: z.string().describe("영상 프롬프트 (generate_scene_video_prompt의 videoPrompt)"),
421
566
  sceneImageUrl: z.string().optional().describe("씬 이미지 URL (i2v 입력)"),
@@ -423,8 +568,9 @@ export function registerCloudTools(server, config, api) {
423
568
  durationSec: z.number().optional().describe("기본 8 (모델 상한으로 클램프됨)"),
424
569
  modelId: z.enum(["kie-grok-imagine", "seedance-2.0", "seedance-2.0-mini", "kling-3.0"]).optional().describe("i2v 영상 모델, 기본 kie-grok-imagine"),
425
570
  quality: z.string().optional().describe("기본 720p"),
571
+ returnFrames: z.boolean().optional().describe("기본 true — 결과 영상의 대표 프레임 3장을 직접 보고 QC 할 수 있게 이미지 블록으로 반환"),
426
572
  },
427
- wrapCloudHandler(config, async ({ prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p" }) => {
573
+ wrapCloudHandler(config, async ({ prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p", returnFrames = true }) => {
428
574
  const payload = await api.request("/api/tracker/gemini/storyboard-scene-video", {
429
575
  body: {
430
576
  aspectRatio,
@@ -446,13 +592,31 @@ export function registerCloudTools(server, config, api) {
446
592
  // 서버 최악 경로(KIE 900s + Evolink 폴백 3회×900s)를 넘겨 잡아 조기 abort 로 과금-미회수를 방지.
447
593
  timeoutMs: 1_800_000,
448
594
  });
449
- return jsonResult({
595
+ // 대표 프레임 동봉(1층 QC) — 실패는 조용히 넘기고 verify_scene_video 안내로 대체.
596
+ let frames = [];
597
+ if (returnFrames && payload?.videoUrl) {
598
+ try {
599
+ const frameResp = await api.request("/api/tracker/video/frames", {
600
+ body: { count: 3, maxWidth: 480, videoUrl: payload.videoUrl },
601
+ method: "POST",
602
+ timeoutMs: 60_000, // 과금 완료된 결과를 오래 붙들지 않기 — 실패해도 verify 폴백 안내
603
+ });
604
+ if (frameResp?.ok && Array.isArray(frameResp?.frames)) frames = frameResp.frames;
605
+ } catch {
606
+ frames = [];
607
+ }
608
+ }
609
+ const resultBody = {
450
610
  durationLabel: payload?.durationLabel,
451
611
  model: payload?.model,
612
+ qc: frames.length
613
+ ? "동봉된 대표 프레임 3장을 직접 확인하세요: 캐릭터 붕괴·모션 파탄·왜곡. 불합격이면 프롬프트를 교정해 재생성하세요. 프레임을 볼 수 없는 클라이언트라면 verify_scene_video 를 호출하세요."
614
+ : "프레임을 동봉하지 못했습니다 — verify_scene_video 로 서버측 검증을 수행하세요.",
452
615
  resolvedRouteLabel: payload?.resolvedRouteLabel,
453
616
  taskId: payload?.taskId,
454
617
  videoUrl: payload?.videoUrl,
455
- });
618
+ };
619
+ return frames.length ? jsonWithImagesResult(resultBody, frames) : jsonResult(resultBody);
456
620
  }),
457
621
  );
458
622
 
@@ -941,9 +1105,16 @@ export function registerCloudTools(server, config, api) {
941
1105
  sceneSeconds: z.number().int().min(3).max(8).optional().describe("씬 길이(초), 기본 5"),
942
1106
  splitMode: z.enum(["fixed", "auto"]).optional().describe("씬 분할, 기본 fixed"),
943
1107
  videoStylePreset: z.string().optional().describe("기본 seedance-music-video"),
1108
+ characters: z.array(z.object({
1109
+ name: z.string().describe("캐릭터 이름"),
1110
+ appearance: z.string().optional().describe("외모 앵커 (얼굴형·헤어·피부톤·의상 등)"),
1111
+ role: z.string().optional(),
1112
+ outfitLock: z.string().optional().describe("의상 고정 (예: navy hoodie)"),
1113
+ forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
1114
+ })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
944
1115
  aspectRatio: z.string().optional().describe("기본 9:16"),
945
1116
  },
946
- wrapCloudHandler(config, async ({ durationSec, lyrics = "", conceptBrief = "", instrumental = false, sceneSeconds = 5, splitMode = "fixed", videoStylePreset = "seedance-music-video", aspectRatio = "9:16" }) => {
1117
+ wrapCloudHandler(config, async ({ durationSec, lyrics = "", conceptBrief = "", instrumental = false, sceneSeconds = 5, splitMode = "fixed", videoStylePreset = "seedance-music-video", aspectRatio = "9:16" , characters = [] }) => {
947
1118
  if (!instrumental && !String(lyrics).trim()) {
948
1119
  return textResult("lyrics를 입력하거나 instrumental=true로 지정하세요.", { isError: true });
949
1120
  }
@@ -956,6 +1127,7 @@ export function registerCloudTools(server, config, api) {
956
1127
  instrumental,
957
1128
  lyrics,
958
1129
  sceneSeconds,
1130
+ characters,
959
1131
  selectedCharacters: [],
960
1132
  songMeta: {},
961
1133
  splitMode,
@@ -1031,6 +1203,9 @@ export function registerCloudTools(server, config, api) {
1031
1203
  id: video?.id,
1032
1204
  keywords: video?.keywords,
1033
1205
  recommendedPreset: video?.recommendedPreset,
1206
+ // 박힌(하드섭) 자막 관측 — prepare_remake_timeline 의 sourceVideos[].burnedSubtitle 에
1207
+ // 그대로 넘기면 subtitleCropMode:"auto" 가 해당 소스만 하단 크롭한다.
1208
+ burnedSubtitle: video?.burnedSubtitle,
1034
1209
  sceneCatalog: (video?.sceneCatalog || []).map((scene) => ({
1035
1210
  actionTags: scene?.actionTags,
1036
1211
  ambienceScore: scene?.ambienceScore,
@@ -1054,6 +1229,85 @@ export function registerCloudTools(server, config, api) {
1054
1229
  }),
1055
1230
  );
1056
1231
 
1232
+ // ── 짜집기 렌더 준비 — 컷 세트 → TTS + 타임라인 페이로드 ────────────────────
1233
+ server.tool(
1234
+ "prepare_remake_timeline",
1235
+ "짜집기(리메이크) 렌더 준비 — analyze_edit_points 의 sceneCatalog 에서 고른 컷들로 구성한 컷 세트를 서버에 보내 TTS 합성 + 렌더 타임라인을 만듭니다. 반환된 payloadPath 를 render_start 에 넘기면 이 PC 에서 원본 다운로드 + 렌더가 수행됩니다. [N]/[SN] 행의 audioContent 는 유료 TTS 로 합성되므로 실행 전 사용자 확인을 받으세요.",
1236
+ {
1237
+ title: z.string().optional().describe("영상 상단 타이틀 텍스트 (없으면 생략)"),
1238
+ sourceVideos: z.array(z.object({
1239
+ id: z.string().describe("YouTube video id (11자)"),
1240
+ title: z.string().optional(),
1241
+ burnedSubtitle: z.object({
1242
+ present: z.boolean(),
1243
+ position: z.string().optional().describe("bottom/top/center/none"),
1244
+ bandPercent: z.number().optional().describe("자막 세로 점유율(%) — 보통 12~22"),
1245
+ }).optional().describe("analyze_edit_points 가 반환한 관측값을 그대로 — subtitleCropMode:auto 판단 근거"),
1246
+ })).min(1).max(5).describe("컷을 가져올 원본 영상 목록"),
1247
+ rows: z.array(z.object({
1248
+ mode: z.enum(["N", "S", "A", "SN"]).describe("N=TTS 내레이션(원본 무음) / S=원본 대사 유지 / A=현장음 유지 / SN=원본 대사+TTS"),
1249
+ durationSec: z.number().describe("행 길이(초) — S/A 는 클립 자연 길이가 우선"),
1250
+ audioContent: z.string().optional().describe("N/SN 의 내레이션 텍스트 (하단 자막으로도 burn-in)"),
1251
+ effectSubtitle: z.string().optional().describe("효과 자막 (짧은 박스 자막, 예: 팩트 폭행)"),
1252
+ clipRefs: z.array(z.object({
1253
+ videoId: z.string().describe("sourceVideos 의 id"),
1254
+ startMs: z.number().describe("원본에서 자를 시작(ms) — sceneCatalog 의 startMs"),
1255
+ endMs: z.number().describe("원본에서 자를 끝(ms)"),
1256
+ })).min(1).max(6).describe("이 행에 이어 붙일 원본 구간들"),
1257
+ })).min(1).max(40).describe("타임라인 행 — 순서대로 이어 붙습니다"),
1258
+ provider: z.enum(["typecast", "elevenlabs", "supertone"]).optional().describe("TTS 공급자, 기본 typecast (recommend_voice 로 보이스를 먼저 고르세요)"),
1259
+ voiceId: z.string().optional().describe("보이스 ID (생략 시 공급자 기본)"),
1260
+ aspectRatio: z.enum(["9:16", "16:9", "1:1"]).optional().describe("기본 9:16"),
1261
+ subtitleCropMode: z.enum(["off", "always", "auto"]).optional().describe("원본 박힌 자막 하단 크롭 — auto 는 burnedSubtitle.present 소스만"),
1262
+ },
1263
+ wrapCloudHandler(config, async ({ title = "", sourceVideos, rows, provider = "typecast", voiceId = "", aspectRatio = "9:16", subtitleCropMode = "off" }) => {
1264
+ const sourceIds = new Set(sourceVideos.map((source) => source.id));
1265
+ const badRef = rows.flatMap((row) => row.clipRefs).find((ref) => !sourceIds.has(ref.videoId));
1266
+ if (badRef) {
1267
+ return textResult(`clipRefs 의 videoId "${badRef.videoId}" 가 sourceVideos 에 없습니다.`, { isError: true });
1268
+ }
1269
+ const version = {
1270
+ id: "mcp-remake",
1271
+ title,
1272
+ sourceVideos,
1273
+ rows: rows.map((row) => ({
1274
+ ...row,
1275
+ clipRefs: row.clipRefs.map((ref) => ({
1276
+ ...ref,
1277
+ durationSec: Math.max(0.3, (ref.endMs - ref.startMs) / 1000),
1278
+ })),
1279
+ })),
1280
+ };
1281
+ const payload = await api.request("/api/tracker/remake/prepare", {
1282
+ body: {
1283
+ aspectRatio,
1284
+ costEventId: createUsageEventId("mcp-remake-tts"),
1285
+ provider,
1286
+ subtitleCropMode,
1287
+ version,
1288
+ voiceId,
1289
+ },
1290
+ method: "POST",
1291
+ timeoutMs: 300_000,
1292
+ });
1293
+ if (!payload?.payload) {
1294
+ return textResult(`짜집기 준비 실패: ${payload?.error || "타임라인이 비어 있습니다."}`, { isError: true });
1295
+ }
1296
+ const handle = savePayloadHandle(config.stateDir, payload.payload, "remake");
1297
+ return jsonResult({
1298
+ costUsd: payload.costUsd ?? 0,
1299
+ next: "render_start 에 payloadPath 를 넘겨 이 PC 에서 렌더하세요 (원본 다운로드 포함 — 수 분 걸릴 수 있음).",
1300
+ payloadPath: handle.filePath,
1301
+ sizeBytes: handle.bytes,
1302
+ totalDurationMs: payload.totalDurationMs ?? 0,
1303
+ ttsAttempted: payload.ttsAttempted ?? 0,
1304
+ ttsCharCount: payload.ttsCharCount ?? 0,
1305
+ ...(payload.ttsLastError ? { ttsLastError: payload.ttsLastError } : {}),
1306
+ ttsSucceeded: payload.ttsSucceeded ?? 0,
1307
+ });
1308
+ }),
1309
+ );
1310
+
1057
1311
  // ── 시간 동기 자막 TTS — 쇼츠 하단자막용 ─────────────────────────────────────
1058
1312
  server.tool(
1059
1313
  "tts_narration_with_captions",
@@ -1182,6 +1436,205 @@ export function registerCloudTools(server, config, api) {
1182
1436
  }),
1183
1437
  );
1184
1438
 
1439
+ server.tool(
1440
+ "measure_channel_spec",
1441
+ "벤치마크 채널의 영상 표본(유튜브 URL ≤5)을 실측해 채널 규격 지문(길이·컷간격·컷/분·라우드니스·무음비율·자막 위치·말끝 스타일)을 만듭니다. 컴패니언 앱이 다운로드·샷감지·오디오 추출을 수행하고 서버가 집계합니다. 반환된 fingerprint 를 plan_shorts_video 의 channelFingerprint 로 넘기면 대본이 그 규격을 강제합니다.",
1442
+ {
1443
+ videoUrls: z.array(z.string()).min(1).max(5).describe("표본 유튜브 URL 또는 11자 ID (조회수 상위 영상 권장)"),
1444
+ },
1445
+ wrapCloudHandler(config, async ({ videoUrls }) => {
1446
+ const specs = [];
1447
+ const notes = [];
1448
+ // 서버 채널지문 라우트의 본문 상한은 16MB. 표본당 120초 WAV(16kHz·16bit 모노)는
1449
+ // base64 ~5.1MB 라 표본 3개만 넘어도 합산이 상한을 넘겨 전체 413 으로 죽는다.
1450
+ // 표본 수에 맞춰 오디오 트림 길이를 나눠 갖고, 누적 크기도 방어적으로 감시한다.
1451
+ const audioBudgetChars = 9_000_000;
1452
+ const base64CharsPerAudioSecond = Math.ceil(COMPANION_AUDIO_BYTES_PER_SECOND * 4 / 3);
1453
+ const audioTrimSeconds = Math.max(20, Math.min(120, Math.floor(audioBudgetChars / videoUrls.length / base64CharsPerAudioSecond)));
1454
+ let audioCharsUsed = 0;
1455
+ for (const [index, rawUrl] of videoUrls.entries()) {
1456
+ const watchUrl = canonicalYoutubeWatchUrl(rawUrl);
1457
+ if (!watchUrl) {
1458
+ notes.push(`표본 ${index + 1}: 유튜브 URL 아님 — 건너뜀`);
1459
+ continue;
1460
+ }
1461
+ try {
1462
+ // 샷 감지 (컷수·길이·프레임) — 컴패니언 로컬 ffmpeg.
1463
+ const shotsResp = await fetch(`${config.companionUrl}/api/shots`, {
1464
+ body: JSON.stringify({ maxShots: COMPANION_MAX_SHOTS, minGapMs: 1400, sceneThreshold: 0.22, url: watchUrl, width: 480 }),
1465
+ headers: { "Content-Type": "application/json" },
1466
+ method: "POST",
1467
+ signal: AbortSignal.timeout(240_000),
1468
+ });
1469
+ const shotsPayload = await shotsResp.json().catch(() => null);
1470
+ if (!shotsResp.ok || !Array.isArray(shotsPayload?.shots) || !shotsPayload.shots.length) {
1471
+ notes.push(`표본 ${index + 1}: 샷 감지 실패(${shotsPayload?.error || shotsResp.status})`);
1472
+ continue;
1473
+ }
1474
+ const shots = shotsPayload.shots;
1475
+ const durationSec = Math.round((shots.at(-1)?.endMs || 0) / 100) / 10;
1476
+ const frames = shots
1477
+ .filter((shot) => shot?.imageBase64)
1478
+ .slice(0, 8)
1479
+ .map((shot) => `data:${shot?.mimeType || "image/jpeg"};base64,${shot.imageBase64}`);
1480
+ // 컴패니언은 감지된 컷을 maxShots 개로 다운샘플해 돌려준다(downsampleShotStarts).
1481
+ // 상한에 걸린 목록의 길이는 컷 수의 하한일 뿐이라 "실측"으로 쓸 수 없다 —
1482
+ // 빠른 컷 채널일수록 컷/분이 심하게 낮게 잡혀 대본 지시문을 오염시킨다.
1483
+ // 지어낸 숫자를 보내느니 축을 비우고 사유를 남긴다.
1484
+ const cutCountClamped = shots.length >= COMPANION_MAX_SHOTS;
1485
+ if (cutCountClamped) {
1486
+ notes.push(`표본 ${index + 1}: 감지 컷이 컴패니언 상한(${COMPANION_MAX_SHOTS})에 도달 — 컷 수는 하한값이라 컷 리듬 축에서 제외`);
1487
+ }
1488
+ // 오디오 — 라우드니스/무음은 서버가 검증된 DSP 로 실측.
1489
+ let audioBase64 = "";
1490
+ try {
1491
+ const wav = await extractCompanionAudio(config.companionUrl, { url: watchUrl });
1492
+ const encoded = trimWavToSeconds(wav, audioTrimSeconds).toString("base64");
1493
+ if (audioCharsUsed + encoded.length > audioBudgetChars) {
1494
+ notes.push(`표본 ${index + 1}: 본문 예산 초과로 오디오 제외 — 라우드니스 축 제외`);
1495
+ } else {
1496
+ audioBase64 = encoded;
1497
+ audioCharsUsed += encoded.length;
1498
+ }
1499
+ } catch (error) {
1500
+ notes.push(`표본 ${index + 1}: 오디오 추출 실패(${String(error?.message || error).slice(0, 80)}) — 라우드니스 축 제외`);
1501
+ }
1502
+ specs.push({
1503
+ audioBase64,
1504
+ ...(cutCountClamped ? {} : { cutCount: shots.length }),
1505
+ durationSec,
1506
+ frames,
1507
+ videoId: watchUrl.slice(-11),
1508
+ });
1509
+ } catch (error) {
1510
+ notes.push(`표본 ${index + 1}: ${String(error?.message || error).slice(0, 100)}`);
1511
+ }
1512
+ }
1513
+ if (!specs.length) {
1514
+ return textResult(`표본 실측에 모두 실패했습니다. 컴패니언 앱 실행 여부를 확인하세요.\n${notes.join("\n")}`, { isError: true });
1515
+ }
1516
+ const payload = await api.request("/api/tracker/gemini/channel-fingerprint", {
1517
+ body: { measuredAt: new Date().toISOString(), videos: specs },
1518
+ method: "POST",
1519
+ timeoutMs: 300_000,
1520
+ });
1521
+ if (payload?.ok === false || !payload?.fingerprint) {
1522
+ return textResult(`지문 집계 실패: ${payload?.error || "알 수 없는 오류"}`, { isError: true });
1523
+ }
1524
+ return jsonResult({
1525
+ fingerprint: payload.fingerprint,
1526
+ next: "이 fingerprint 객체를 plan_shorts_video 의 channelFingerprint 로 그대로 넘기세요 — 대본이 실측 규격(길이·컷·말속도·말끝)을 따르게 됩니다.",
1527
+ notes: notes.length ? notes : undefined,
1528
+ sampleCount: specs.length,
1529
+ });
1530
+ }),
1531
+ );
1532
+
1533
+ server.tool(
1534
+ "verify_scene_image",
1535
+ "생성된 키프레임 이미지를 서버 비전(Gemini)이 QC 판정합니다 — 캐릭터 참조 대조·아티팩트·프롬프트 이행. 이미지 블록을 직접 볼 수 없는 클라이언트의 폴백이며, 판정 JSON(pass/violations/regenerationHint)만 읽으면 됩니다. 불합격이면 regenerationHint 를 프롬프트에 반영해 generate_scene_image 를 재호출하세요.",
1536
+ {
1537
+ imageUrl: z.string().optional().describe("generate_scene_image 가 반환한 imageUrl"),
1538
+ imagePath: z.string().optional().describe("imageUrl 이 없을 때 — 생성 결과의 savedPath (로컬 파일)"),
1539
+ prompt: z.string().optional().describe("기대 내용(생성에 쓴 프롬프트 요약)"),
1540
+ referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 캐릭터 이미지 로컬 경로 (생성에 쓴 것과 동일하게)"),
1541
+ checklist: z.array(z.string()).max(8).optional().describe("추가 검수 항목 (예: 후드티는 남색)"),
1542
+ },
1543
+ wrapCloudHandler(config, async ({ imageUrl = "", imagePath = "", prompt = "", referenceImagePaths = [], checklist = [] }) => {
1544
+ if (!imageUrl && !imagePath) {
1545
+ return textResult("imageUrl 또는 imagePath 중 하나는 필요합니다.", { isError: true });
1546
+ }
1547
+ if (imagePath) {
1548
+ assertAllowedInputFile(imagePath, ALLOWED_IMAGE_EXTS, { kind: "검증 대상 이미지" });
1549
+ }
1550
+ for (const filePath of referenceImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 이미지" });
1551
+ assertEncodedBudget([imagePath, ...referenceImagePaths].filter(Boolean), { context: "검증 이미지" });
1552
+ const candidateUrl = imageUrl || fileToDataUrl(imagePath, { allowedExts: ALLOWED_IMAGE_EXTS, kind: "검증 대상 이미지" });
1553
+ const payload = await api.request("/api/tracker/gemini/verify-scene", {
1554
+ body: {
1555
+ checklist,
1556
+ imageUrl: candidateUrl,
1557
+ kind: "image",
1558
+ prompt,
1559
+ referenceImages: referenceImagePaths.map((filePath) => ({
1560
+ name: path.basename(filePath),
1561
+ previewUrl: fileToDataUrl(filePath, { allowedExts: ALLOWED_IMAGE_EXTS, kind: "참조 이미지" }),
1562
+ })),
1563
+ },
1564
+ method: "POST",
1565
+ timeoutMs: 300_000,
1566
+ });
1567
+ if (payload?.ok === false) {
1568
+ return textResult(`씬 검증 실패: ${payload?.error || "알 수 없는 오류"}\n${payload?.guidance || ""}`.trim(), { isError: true });
1569
+ }
1570
+ return jsonResult({ referenceLoaded: payload?.referenceLoaded, verdict: payload?.verdict || null });
1571
+ }),
1572
+ );
1573
+
1574
+ server.tool(
1575
+ "verify_scene_video",
1576
+ "생성된 씬 영상을 서버가 대표 프레임으로 QC 판정합니다 (Gemini 비전) — 캐릭터 참조 대조·붕괴/왜곡·프롬프트 이행. 프레임 블록을 직접 볼 수 없는 클라이언트의 폴백. 불합격이면 regenerationHint 를 반영해 generate_scene_video 를 재호출하세요.",
1577
+ {
1578
+ videoUrl: z.string().describe("generate_scene_video 가 반환한 videoUrl"),
1579
+ prompt: z.string().optional().describe("기대 내용(생성에 쓴 프롬프트 요약)"),
1580
+ referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 캐릭터 이미지 로컬 경로"),
1581
+ checklist: z.array(z.string()).max(8).optional().describe("추가 검수 항목"),
1582
+ frameCount: z.number().int().min(2).max(4).optional().describe("판정에 쓸 프레임 수, 기본 3"),
1583
+ },
1584
+ wrapCloudHandler(config, async ({ videoUrl, prompt = "", referenceImagePaths = [], checklist = [], frameCount = 3 }) => {
1585
+ for (const filePath of referenceImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 이미지" });
1586
+ assertEncodedBudget(referenceImagePaths, { context: "참조 이미지" });
1587
+ const payload = await api.request("/api/tracker/gemini/verify-scene", {
1588
+ body: {
1589
+ checklist,
1590
+ frameCount,
1591
+ kind: "video",
1592
+ prompt,
1593
+ referenceImages: referenceImagePaths.map((filePath) => ({
1594
+ name: path.basename(filePath),
1595
+ previewUrl: fileToDataUrl(filePath, { allowedExts: ALLOWED_IMAGE_EXTS, kind: "참조 이미지" }),
1596
+ })),
1597
+ videoUrl,
1598
+ },
1599
+ method: "POST",
1600
+ timeoutMs: 600_000, // 다운로드 120s + ffmpeg + Gemini 최악 경로 여유
1601
+ });
1602
+ if (payload?.ok === false) {
1603
+ return textResult(`씬 검증 실패: ${payload?.error || "알 수 없는 오류"}\n${payload?.guidance || ""}`.trim(), { isError: true });
1604
+ }
1605
+ return jsonResult({ referenceLoaded: payload?.referenceLoaded, verdict: payload?.verdict || null });
1606
+ }),
1607
+ );
1608
+
1609
+ server.tool(
1610
+ "verify_render_darkness",
1611
+ "렌더된 mp4 의 검은 프레임(검은컷)을 휘도 실측으로 잡습니다 — 컷 경계 시각들을 넘기면 그 지점만, 생략하면 1초 간격 전수 샘플. darkCount>0 이면 해당 시각의 씬 전환/소스를 점검하세요. AI 호출 없음(무과금).",
1612
+ {
1613
+ videoUrl: z.string().describe("검사할 mp4 URL (렌더 결과 업로드본 또는 씬 videoUrl)"),
1614
+ timestamps: z.array(z.number().min(0)).max(120).optional().describe("검사 시각(초) — 컷 경계 ±0.15s 권장. 생략 시 1초 간격 자동"),
1615
+ darkThreshold: z.number().int().min(1).max(80).optional().describe("검정 판정 휘도 상한(0~255), 기본 16"),
1616
+ },
1617
+ wrapCloudHandler(config, async ({ videoUrl, timestamps = [], darkThreshold = 16 }) => {
1618
+ const payload = await api.request("/api/tracker/video/darkness", {
1619
+ body: { darkThreshold, timestamps, videoUrl },
1620
+ method: "POST",
1621
+ timeoutMs: 300_000,
1622
+ });
1623
+ if (payload?.ok === false) {
1624
+ return textResult(`휘도 측정 실패: ${payload?.error || "알 수 없는 오류"}`, { isError: true });
1625
+ }
1626
+ return jsonResult({
1627
+ darkCount: payload?.darkCount,
1628
+ darkSamples: payload?.darkSamples,
1629
+ pass: (payload?.darkCount || 0) === 0,
1630
+ sampleCount: payload?.sampleCount,
1631
+ verdictNote: (payload?.darkCount || 0) > 0
1632
+ ? "검은 프레임 검출 — 해당 시각의 씬 소스/전환을 점검하고 재스티치하세요 (transitionMode fade 사용 여부 확인)."
1633
+ : "검은컷 없음.",
1634
+ });
1635
+ }),
1636
+ );
1637
+
1185
1638
  // ── 상세페이지(PDP) ─────────────────────────────────────────────────────────
1186
1639
  server.tool(
1187
1640
  "generate_pdp",
@@ -42,6 +42,36 @@ export function describeCompanionState(triage, remoteEnv) {
42
42
  return `컴패니언은 실행 중이지만 렌더 런타임이 없습니다 (${triage.detail}). 컴패니언 앱에서 렌더 런타임 설치를 실행하세요.`;
43
43
  }
44
44
 
45
+ // 렌더 영수증 대조 — 브리지가 실측한 summary.measured 를 요청 규격과 비교해 불일치를 나열한다.
46
+ // 구버전 컴패니언(브리지에 measured 없음)은 빈 배열 + receipt 없음으로 안전 강등.
47
+ function computeRenderMismatches(summary) {
48
+ const requested = summary?.requested;
49
+ const measured = summary?.measured;
50
+ if (!requested || !measured) return [];
51
+ const mismatches = [];
52
+ const reqDuration = Number(requested.durationSec);
53
+ const gotDuration = Number(measured.durationSec);
54
+ if (Number.isFinite(reqDuration) && Number.isFinite(gotDuration)
55
+ && Math.abs(reqDuration - gotDuration) > Math.max(0.5, reqDuration * 0.03)) {
56
+ mismatches.push(`길이 불일치: 요청 ${reqDuration}s vs 실측 ${gotDuration}s`);
57
+ }
58
+ for (const axis of ["width", "height"]) {
59
+ if (Number.isFinite(Number(measured[axis])) && Number(requested[axis]) !== Number(measured[axis])) {
60
+ mismatches.push(`${axis} 불일치: 요청 ${requested[axis]} vs 실측 ${measured[axis]}`);
61
+ }
62
+ }
63
+ if (requested.hasAudio === true && measured.hasAudio === false) {
64
+ mismatches.push("오디오 누락: 오디오 트랙을 요청했지만 산출물에 없습니다");
65
+ }
66
+ if (summary?.decodeClean === false) {
67
+ mismatches.push(`디코드 오류 감지${measured.decodeErrorSample ? `: ${measured.decodeErrorSample}` : ""}`);
68
+ }
69
+ if (Number(summary?.fileBytes) === 0) {
70
+ mismatches.push("산출물 파일이 0바이트입니다");
71
+ }
72
+ return mismatches;
73
+ }
74
+
45
75
  export function registerCompanionTools(server, config) {
46
76
  const remoteEnv = detectRemoteEnvironment();
47
77
 
@@ -209,11 +239,21 @@ export function registerCompanionTools(server, config) {
209
239
  } catch {
210
240
  // 영속화 실패는 무시
211
241
  }
242
+ const receipt = job.status === "succeeded" && job.summary
243
+ ? { mismatches: computeRenderMismatches(job.summary), summary: job.summary }
244
+ : null;
212
245
  return textResult(JSON.stringify({
213
246
  etaSec: job.progress?.etaSec ?? null,
214
247
  percent: job.progress?.percent ?? 0,
215
248
  stage: job.stage || "",
216
249
  status: job.status || "",
250
+ ...(receipt ? {
251
+ ...(receipt.mismatches.length ? {
252
+ mismatches: receipt.mismatches,
253
+ warning: "렌더 영수증 불일치 — 저장 전 원인을 확인하고 필요하면 다시 렌더하세요.",
254
+ } : {}),
255
+ summary: receipt.summary,
256
+ } : {}),
217
257
  }, null, 2));
218
258
  },
219
259
  );
@@ -226,6 +266,13 @@ export function registerCompanionTools(server, config) {
226
266
  fileName: z.string().optional().describe("저장할 파일명 (생략 시 서버 제안 이름)"),
227
267
  },
228
268
  async ({ jobId, fileName }) => {
269
+ // 출력은 저장 성공 시 컴패니언에서 삭제되므로 영수증은 다운로드 전에 읽는다.
270
+ let receiptSummary = null;
271
+ try {
272
+ receiptSummary = (await getRenderJob(config.companionUrl, jobId))?.payload?.summary || null;
273
+ } catch {
274
+ // 영수증 조회 실패가 다운로드를 막지 않는다 (구버전 컴패니언 호환).
275
+ }
229
276
  const saved = await downloadRenderOutput(config.companionUrl, jobId, config.outputDir, {
230
277
  suggestedName: fileName || "",
231
278
  });
@@ -242,10 +289,16 @@ export function registerCompanionTools(server, config) {
242
289
  } catch {
243
290
  // 영속화 실패는 무시
244
291
  }
292
+ const mismatches = receiptSummary ? computeRenderMismatches(receiptSummary) : [];
245
293
  return textResult(JSON.stringify({
246
294
  bytes: saved.bytes,
247
295
  filePath: saved.filePath,
296
+ ...(mismatches.length ? {
297
+ mismatches,
298
+ warning: "렌더 영수증 불일치 — 파일을 확인하고 필요하면 다시 렌더하세요.",
299
+ } : {}),
248
300
  note: "파일은 이 PC에 저장되었습니다.",
301
+ ...(receiptSummary ? { summary: receiptSummary } : {}),
249
302
  }, null, 2));
250
303
  },
251
304
  );
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.6.0";
5
+ export const MCP_PROXY_VERSION = "0.8.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.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "AImakeAll MCP 서버 — Claude Code/Codex에서 자연어로 영상 기획·생성·렌더·퍼블리시 (렌더는 로컬 컴패니언)",
5
5
  "type": "module",
6
6
  "bin": {