aimakeall-mcp 0.7.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.7.0"],
19
+ "args": ["-y", "aimakeall-mcp@0.8.0"],
20
20
  "env": { "AIMAKEALL_PAT": "aio_pat_..." }
21
21
  }
22
22
  }
@@ -46,6 +46,29 @@ plan_shorts_video → (씬마다) generate_scene_image → [QC: 동봉 이미지
46
46
  - `recommend_voice`는 주제·샘플 영상(유튜브)에 맞는 보이스를 자동 선정합니다 — 샘플 화자 분석은 컴패니언 앱이 실행 중일 때만 동작합니다.
47
47
  - 자체검증(QC): 씬 이미지/영상 결과에 이미지·프레임 블록이 동봉되어 에이전트가 직접 보고 판정합니다. 비전 미지원 클라이언트는 `verify_scene_image`/`verify_scene_video`(서버 Gemini 판정)를 쓰세요 — 불합격 시 `regenerationHint`를 반영해 재생성.
48
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의 컴패니언이 수행합니다.
49
72
 
50
73
  ## 한도
51
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)) {
@@ -173,6 +178,9 @@ function trimPlanScenes(scenes) {
173
178
  ratio: scene?.ratio,
174
179
  sceneNarration: scene?.sceneNarration,
175
180
  scenePurpose: scene?.scenePurpose,
181
+ visibleCharacterSlots: Array.isArray(scene?.visibleCharacterSlots) && scene.visibleCharacterSlots.length
182
+ ? scene.visibleCharacterSlots
183
+ : undefined,
176
184
  }));
177
185
  }
178
186
 
@@ -191,6 +199,51 @@ export function registerCloudTools(server, config, api) {
191
199
  wrapCloudHandler(config, async () => jsonResult(await api.request("/api/tracker/usage"))),
192
200
  );
193
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
+
194
247
  server.tool(
195
248
  "plan_shorts_video",
196
249
  "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조).",
@@ -201,17 +254,40 @@ export function registerCloudTools(server, config, api) {
201
254
  targetCustomer: z.string().optional(),
202
255
  tone: z.string().optional().describe("기본 '자동 추천'"),
203
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 에 강제 주입해 캐릭터 일관성을 잠급니다"),
204
278
  },
205
- 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 }) => {
206
280
  if (!String(topic).trim() && !String(copy).trim()) {
207
281
  return textResult("topic 또는 copy 중 하나는 입력해야 합니다.", { isError: true });
208
282
  }
209
283
  const payload = await api.request("/api/tracker/shorts-video/plan", {
210
284
  body: {
211
285
  categoryId,
286
+ channelFingerprint,
212
287
  copy,
213
288
  operationId: createUsageEventId("shorts-plan"),
214
289
  sceneCount,
290
+ characters,
215
291
  selectedCharacters: [],
216
292
  skillOverride: null,
217
293
  targetCustomer,
@@ -222,12 +298,14 @@ export function registerCloudTools(server, config, api) {
222
298
  timeoutMs: 240_000,
223
299
  });
224
300
  return jsonResult({
301
+ charactersUsed: Array.isArray(payload?.charactersUsed) ? payload.charactersUsed : undefined,
225
302
  ctaText: payload?.ctaText,
226
303
  emphasisKeywords: Array.isArray(payload?.emphasisKeywords) ? payload.emphasisKeywords : [],
227
304
  narrationScript: payload?.narrationScript,
228
305
  ok: payload?.ok,
229
306
  scenes: trimPlanScenes(payload?.scenes),
230
307
  skill: payload?.skill,
308
+ specWarnings: Array.isArray(payload?.specWarnings) && payload.specWarnings.length ? payload.specWarnings : undefined,
231
309
  tagline: payload?.tagline,
232
310
  });
233
311
  }),
@@ -323,8 +401,15 @@ export function registerCloudTools(server, config, api) {
323
401
  categoryId: z.string().optional().describe("기본 ecommerce"),
324
402
  sceneCount: z.number().int().min(1).max(12).optional().describe("기본 6"),
325
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 에 강제 주입해 캐릭터 일관성을 잠급니다"),
326
411
  },
327
- 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 = [] }) => {
328
413
  // 확장자 검증(임의 파일 업로드 차단) + 합산 크기 예산(서버 413 사전 차단).
329
414
  for (const filePath of productImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "이미지" });
330
415
  if (modelImagePath) assertAllowedInputFile(modelImagePath, ALLOWED_IMAGE_EXTS, { kind: "모델 이미지" });
@@ -340,6 +425,7 @@ export function registerCloudTools(server, config, api) {
340
425
  productName,
341
426
  researchBrief,
342
427
  sceneCount,
428
+ characters,
343
429
  selectedCharacters: [],
344
430
  skillOverride: null,
345
431
  targetCustomer,
@@ -367,11 +453,15 @@ export function registerCloudTools(server, config, api) {
367
453
  aspectRatio: z.string().optional().describe("기본 9:16"),
368
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"),
369
455
  returnImage: z.boolean().optional().describe("기본 true — 결과 이미지를 직접 보고 QC 할 수 있게 이미지 블록으로 함께 반환"),
456
+ identityLock: z.string().optional().describe("plan 응답 charactersUsed[].identityLock 을 그대로 — 프롬프트 말미에 리터럴 부착해 캐릭터 일관성 잠금 (paraphrase 금지)"),
370
457
  resolution: z.string().optional().describe("기본 1K"),
371
458
  referenceImageUrls: z.array(z.string()).max(6).optional().describe("참조 이미지 URL (씬1 앵커 등)"),
372
459
  referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 이미지 로컬 경로 (제품 사진 등)"),
373
460
  },
374
- wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true }) => {
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}`;
375
465
  for (const filePath of referenceImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 이미지" });
376
466
  assertEncodedBudget(referenceImagePaths, { context: "참조 이미지" });
377
467
  const referenceImages = [
@@ -1015,9 +1105,16 @@ export function registerCloudTools(server, config, api) {
1015
1105
  sceneSeconds: z.number().int().min(3).max(8).optional().describe("씬 길이(초), 기본 5"),
1016
1106
  splitMode: z.enum(["fixed", "auto"]).optional().describe("씬 분할, 기본 fixed"),
1017
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 에 강제 주입해 캐릭터 일관성을 잠급니다"),
1018
1115
  aspectRatio: z.string().optional().describe("기본 9:16"),
1019
1116
  },
1020
- 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 = [] }) => {
1021
1118
  if (!instrumental && !String(lyrics).trim()) {
1022
1119
  return textResult("lyrics를 입력하거나 instrumental=true로 지정하세요.", { isError: true });
1023
1120
  }
@@ -1030,6 +1127,7 @@ export function registerCloudTools(server, config, api) {
1030
1127
  instrumental,
1031
1128
  lyrics,
1032
1129
  sceneSeconds,
1130
+ characters,
1033
1131
  selectedCharacters: [],
1034
1132
  songMeta: {},
1035
1133
  splitMode,
@@ -1105,6 +1203,9 @@ export function registerCloudTools(server, config, api) {
1105
1203
  id: video?.id,
1106
1204
  keywords: video?.keywords,
1107
1205
  recommendedPreset: video?.recommendedPreset,
1206
+ // 박힌(하드섭) 자막 관측 — prepare_remake_timeline 의 sourceVideos[].burnedSubtitle 에
1207
+ // 그대로 넘기면 subtitleCropMode:"auto" 가 해당 소스만 하단 크롭한다.
1208
+ burnedSubtitle: video?.burnedSubtitle,
1108
1209
  sceneCatalog: (video?.sceneCatalog || []).map((scene) => ({
1109
1210
  actionTags: scene?.actionTags,
1110
1211
  ambienceScore: scene?.ambienceScore,
@@ -1128,6 +1229,85 @@ export function registerCloudTools(server, config, api) {
1128
1229
  }),
1129
1230
  );
1130
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
+
1131
1311
  // ── 시간 동기 자막 TTS — 쇼츠 하단자막용 ─────────────────────────────────────
1132
1312
  server.tool(
1133
1313
  "tts_narration_with_captions",
@@ -1256,6 +1436,100 @@ export function registerCloudTools(server, config, api) {
1256
1436
  }),
1257
1437
  );
1258
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
+
1259
1533
  server.tool(
1260
1534
  "verify_scene_image",
1261
1535
  "생성된 키프레임 이미지를 서버 비전(Gemini)이 QC 판정합니다 — 캐릭터 참조 대조·아티팩트·프롬프트 이행. 이미지 블록을 직접 볼 수 없는 클라이언트의 폴백이며, 판정 JSON(pass/violations/regenerationHint)만 읽으면 됩니다. 불합격이면 regenerationHint 를 프롬프트에 반영해 generate_scene_image 를 재호출하세요.",
@@ -1332,6 +1606,35 @@ export function registerCloudTools(server, config, api) {
1332
1606
  }),
1333
1607
  );
1334
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
+
1335
1638
  // ── 상세페이지(PDP) ─────────────────────────────────────────────────────────
1336
1639
  server.tool(
1337
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.7.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.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "AImakeAll MCP 서버 — Claude Code/Codex에서 자연어로 영상 기획·생성·렌더·퍼블리시 (렌더는 로컬 컴패니언)",
5
5
  "type": "module",
6
6
  "bin": {