aimakeall-mcp 0.7.0 → 0.9.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.9.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
 
@@ -23,12 +23,16 @@ import {
23
23
  import { mp3DurationSec } from "./mp3-duration.mjs";
24
24
  import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
25
25
  import { createUsageEventId } from "./usage-event.mjs";
26
+ import { loudnessDbfsFromWav, silenceRatioFromWav } from "./wav-dsp.mjs";
26
27
  import { trimWavToSeconds } from "./wav-trim.mjs";
27
28
 
28
29
  // 여러 파일을 base64 로 싣는 요청의 인코딩 크기 합이 서버 JSON 상한을 넘지 않게 사전 검사.
29
30
  // 서버가 전체 업로드를 받은 뒤 413 을 내는 것을 막고 행동 가능한 한국어 안내를 준다.
30
31
  const BODY_BUDGET_BYTES = SERVER_JSON_BODY_LIMIT_BYTES - 512 * 1024; // 바디의 나머지 필드 여유
31
32
 
33
+ // 컴패니언 계약 상수 — companion/index.cjs 의 MAX_SHOT_COUNT 와 맞춘다.
34
+ const COMPANION_MAX_SHOTS = 18;
35
+
32
36
  function assertEncodedBudget(filePaths, { context = "요청" } = {}) {
33
37
  let total = 0;
34
38
  for (const filePath of filePaths.filter(Boolean)) {
@@ -173,6 +177,9 @@ function trimPlanScenes(scenes) {
173
177
  ratio: scene?.ratio,
174
178
  sceneNarration: scene?.sceneNarration,
175
179
  scenePurpose: scene?.scenePurpose,
180
+ visibleCharacterSlots: Array.isArray(scene?.visibleCharacterSlots) && scene.visibleCharacterSlots.length
181
+ ? scene.visibleCharacterSlots
182
+ : undefined,
176
183
  }));
177
184
  }
178
185
 
@@ -191,6 +198,51 @@ export function registerCloudTools(server, config, api) {
191
198
  wrapCloudHandler(config, async () => jsonResult(await api.request("/api/tracker/usage"))),
192
199
  );
193
200
 
201
+ server.tool(
202
+ "get_cost_report",
203
+ "내 계정의 AI 원가 원장을 요약 조회합니다 — 총 지출, 모델별 상위 지출, 최근 이벤트. 여러 씬을 생성/재생성하기 전후로 호출해 실제 지출 델타를 확인하세요 (추측 과금 방지).",
204
+ {
205
+ limit: z.number().int().min(10).max(120).optional().describe("최근 이벤트 조회 수, 기본 60"),
206
+ },
207
+ wrapCloudHandler(config, async ({ limit = 60 }) => {
208
+ const payload = await api.request(`/api/tracker/account/cost-ledger?limit=${limit}`, { method: "GET", timeoutMs: 30_000 });
209
+ const ai = payload?.productionCost?.ai || {};
210
+ const byModel = ai?.byModel && typeof ai.byModel === "object" ? ai.byModel : {};
211
+ const topModels = Object.entries(byModel)
212
+ .map(([modelKey, row]) => ({ calls: row?.calls || 0, modelKey, usd: row?.usd || 0 }))
213
+ .sort((left, right) => right.usd - left.usd)
214
+ .slice(0, 8);
215
+ const recent = (Array.isArray(ai?.history) ? ai.history : []).slice(0, 15)
216
+ .map((event) => ({ at: event?.createdAt, kind: event?.kind, label: event?.label, modelKey: event?.modelKey, usd: event?.usd }));
217
+ return jsonResult({
218
+ recent,
219
+ topModels,
220
+ totalCalls: ai?.calls ?? payload?.summary?.calls,
221
+ totalUsd: ai?.totalUsd ?? payload?.summary?.totalUsd,
222
+ });
223
+ }),
224
+ );
225
+
226
+ server.tool(
227
+ "estimate_video_cost",
228
+ "생성 파이프라인 비용을 실행 전에 견적냅니다 (공급자 호출·과금 없음, 단가표 계산). 씬 수를 늘리거나 재생성 루프를 돌기 전에 호출해 예상 지출을 확인하세요.",
229
+ {
230
+ sceneCount: z.number().int().min(0).max(60).describe("씬 수"),
231
+ imageModel: z.string().optional().describe("기본 gpt-image-2-beta"),
232
+ imagesPerScene: z.number().int().min(0).max(4).optional().describe("씬당 이미지 수, 기본 1"),
233
+ referenceImageCount: z.number().int().min(0).max(6).optional().describe("이미지당 참조 장수 (일부 모델 단가 가산)"),
234
+ videoModel: z.string().optional().describe("기본 kie-grok-imagine"),
235
+ videoSecondsPerScene: z.number().min(0).max(30).optional().describe("씬당 영상 초 (0이면 영상 미포함)"),
236
+ videoQuality: z.string().optional().describe("기본 720p"),
237
+ ttsProvider: z.enum(["typecast", "elevenlabs", "supertone"]).optional().describe("기본 typecast"),
238
+ ttsChars: z.number().int().min(0).max(20000).optional().describe("내레이션 글자 수"),
239
+ },
240
+ wrapCloudHandler(config, async (input) => {
241
+ const payload = await api.request("/api/tracker/cost/estimate", { body: input, method: "POST", timeoutMs: 30_000 });
242
+ return jsonResult(payload);
243
+ }),
244
+ );
245
+
194
246
  server.tool(
195
247
  "plan_shorts_video",
196
248
  "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조).",
@@ -200,18 +252,41 @@ export function registerCloudTools(server, config, api) {
200
252
  categoryId: z.enum(["community-shorts", "viral-shorts", "insight"]).optional().describe("기본 community-shorts. insight=명언(통찰) 쇼츠"),
201
253
  targetCustomer: z.string().optional(),
202
254
  tone: z.string().optional().describe("기본 '자동 추천'"),
203
- sceneCount: z.number().int().min(1).max(10).optional().describe("기본 3"),
255
+ sceneCount: z.number().int().min(1).max(10).optional().describe("씬 수. 생략하면 channelFingerprint 의 목표 길이로 자동 산출(지문도 없으면 3)"),
256
+ channelFingerprint: z.object({
257
+ sampleSize: z.number().optional(),
258
+ durationTargetSec: z.number().nullable().optional(),
259
+ cutIntervalSec: z.number().nullable().optional(),
260
+ cutsPerMin: z.number().nullable().optional(),
261
+ cpsTarget: z.number().nullable().optional(),
262
+ loudnessDbfs: z.number().nullable().optional(),
263
+ silenceRatio: z.number().nullable().optional(),
264
+ subtitleYPct: z.number().nullable().optional(),
265
+ subtitleLines: z.number().nullable().optional(),
266
+ endingStyle: z.string().nullable().optional(),
267
+ emphasisCount: z.number().nullable().optional(),
268
+ hasBgm: z.boolean().nullable().optional(),
269
+ }).passthrough().optional().describe("measure_channel_spec 이 반환한 fingerprint — 대본이 실측 규격을 강제하게 함"),
270
+ characters: z.array(z.object({
271
+ name: z.string().describe("캐릭터 이름"),
272
+ appearance: z.string().optional().describe("외모 앵커 (얼굴형·헤어·피부톤·의상 등)"),
273
+ role: z.string().optional(),
274
+ outfitLock: z.string().optional().describe("의상 고정 (예: navy hoodie)"),
275
+ forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
276
+ })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
204
277
  },
205
- wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 3 }) => {
278
+ wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 0, characters = [], channelFingerprint = null }) => {
206
279
  if (!String(topic).trim() && !String(copy).trim()) {
207
280
  return textResult("topic 또는 copy 중 하나는 입력해야 합니다.", { isError: true });
208
281
  }
209
282
  const payload = await api.request("/api/tracker/shorts-video/plan", {
210
283
  body: {
211
284
  categoryId,
285
+ channelFingerprint,
212
286
  copy,
213
287
  operationId: createUsageEventId("shorts-plan"),
214
288
  sceneCount,
289
+ characters,
215
290
  selectedCharacters: [],
216
291
  skillOverride: null,
217
292
  targetCustomer,
@@ -222,12 +297,14 @@ export function registerCloudTools(server, config, api) {
222
297
  timeoutMs: 240_000,
223
298
  });
224
299
  return jsonResult({
300
+ charactersUsed: Array.isArray(payload?.charactersUsed) ? payload.charactersUsed : undefined,
225
301
  ctaText: payload?.ctaText,
226
302
  emphasisKeywords: Array.isArray(payload?.emphasisKeywords) ? payload.emphasisKeywords : [],
227
303
  narrationScript: payload?.narrationScript,
228
304
  ok: payload?.ok,
229
305
  scenes: trimPlanScenes(payload?.scenes),
230
306
  skill: payload?.skill,
307
+ specWarnings: Array.isArray(payload?.specWarnings) && payload.specWarnings.length ? payload.specWarnings : undefined,
231
308
  tagline: payload?.tagline,
232
309
  });
233
310
  }),
@@ -323,8 +400,15 @@ export function registerCloudTools(server, config, api) {
323
400
  categoryId: z.string().optional().describe("기본 ecommerce"),
324
401
  sceneCount: z.number().int().min(1).max(12).optional().describe("기본 6"),
325
402
  researchBrief: z.string().optional().describe("research_product 결과의 planBrief — 불만·후킹·경쟁공백이 기획에 반영됨"),
403
+ characters: z.array(z.object({
404
+ name: z.string().describe("캐릭터 이름"),
405
+ appearance: z.string().optional().describe("외모 앵커 (얼굴형·헤어·피부톤·의상 등)"),
406
+ role: z.string().optional(),
407
+ outfitLock: z.string().optional().describe("의상 고정 (예: navy hoodie)"),
408
+ forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
409
+ })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
326
410
  },
327
- wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" }) => {
411
+ wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" , characters = [] }) => {
328
412
  // 확장자 검증(임의 파일 업로드 차단) + 합산 크기 예산(서버 413 사전 차단).
329
413
  for (const filePath of productImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "이미지" });
330
414
  if (modelImagePath) assertAllowedInputFile(modelImagePath, ALLOWED_IMAGE_EXTS, { kind: "모델 이미지" });
@@ -340,6 +424,7 @@ export function registerCloudTools(server, config, api) {
340
424
  productName,
341
425
  researchBrief,
342
426
  sceneCount,
427
+ characters,
343
428
  selectedCharacters: [],
344
429
  skillOverride: null,
345
430
  targetCustomer,
@@ -367,11 +452,15 @@ export function registerCloudTools(server, config, api) {
367
452
  aspectRatio: z.string().optional().describe("기본 9:16"),
368
453
  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
454
  returnImage: z.boolean().optional().describe("기본 true — 결과 이미지를 직접 보고 QC 할 수 있게 이미지 블록으로 함께 반환"),
455
+ identityLock: z.string().optional().describe("plan 응답 charactersUsed[].identityLock 을 그대로 — 프롬프트 말미에 리터럴 부착해 캐릭터 일관성 잠금 (paraphrase 금지)"),
370
456
  resolution: z.string().optional().describe("기본 1K"),
371
457
  referenceImageUrls: z.array(z.string()).max(6).optional().describe("참조 이미지 URL (씬1 앵커 등)"),
372
458
  referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 이미지 로컬 경로 (제품 사진 등)"),
373
459
  },
374
- wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true }) => {
460
+ wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true, identityLock = "" }) => {
461
+ // identity_lock 리터럴 부착 — 이미 포함돼 있으면 재부착하지 않는다(멱등: 프롬프트 캐시 보존).
462
+ const lock = String(identityLock || "").trim();
463
+ if (lock && !prompt.includes(lock)) prompt = `${prompt.trim()}, ${lock}`;
375
464
  for (const filePath of referenceImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 이미지" });
376
465
  assertEncodedBudget(referenceImagePaths, { context: "참조 이미지" });
377
466
  const referenceImages = [
@@ -690,9 +779,14 @@ export function registerCloudTools(server, config, api) {
690
779
  emphasisKeywords: z.array(z.string()).max(12).optional().describe("강조 단어 목록 — 자막 라인 안에서 해당 단어만 다른 색/크기로 렌더 (plan_shorts_video의 emphasisKeywords 를 그대로 전달)"),
691
780
  emphasisColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional().describe("강조 색 #RRGGBB (기본 #facc15 노랑)"),
692
781
  emphasisSizeScale: z.number().optional().describe("강조 크기 배율 0.5~2 (기본 1.2)"),
782
+ channelFingerprint: z.object({
783
+ subtitleYPct: z.number().nullable().optional(),
784
+ subtitleLines: z.number().nullable().optional(),
785
+ hasBgm: z.boolean().nullable().optional(),
786
+ }).passthrough().optional().describe("measure_channel_spec 이 반환한 fingerprint — 자막 세로 위치·BGM 유무를 참고 채널에 맞춥니다"),
693
787
  transitionMode: z.enum(["fade", "none"]).optional().describe("기본 fade"),
694
788
  },
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" }) => {
789
+ 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", channelFingerprint = null }) => {
696
790
  // 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
697
791
  if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
698
792
  if (bgmAudioPath) assertAllowedInputFile(bgmAudioPath, new Set([".mp3", ".wav"]), { kind: "BGM 오디오" });
@@ -717,6 +811,7 @@ export function registerCloudTools(server, config, api) {
717
811
  })),
718
812
  emphasisColor,
719
813
  emphasisKeywords,
814
+ channelFingerprint,
720
815
  emphasisSizeScale,
721
816
  subtitleLines,
722
817
  titleText,
@@ -854,6 +949,17 @@ export function registerCloudTools(server, config, api) {
854
949
  christianContentType: z.enum(["youtube-narration", "prayer", "devotional", "comfort-message"]).optional().describe("styleId=christian일 때 유형"),
855
950
  insightFigureNames: z.string().optional().describe("styleId=insight일 때 인물명(쉼표 구분)"),
856
951
  userOpinion: z.string().optional().describe("본문에 자연스럽게 녹일 작성자 의견"),
952
+ channelFingerprint: z.object({
953
+ durationTargetSec: z.number().nullable().optional(),
954
+ cutIntervalSec: z.number().nullable().optional(),
955
+ cpsTarget: z.number().nullable().optional(),
956
+ subtitleYPct: z.number().nullable().optional(),
957
+ subtitleLines: z.number().nullable().optional(),
958
+ endingStyle: z.string().nullable().optional(),
959
+ emphasisCount: z.number().nullable().optional(),
960
+ hasBgm: z.boolean().nullable().optional(),
961
+ sampleSize: z.number().optional(),
962
+ }).passthrough().optional().describe("measure_channel_spec 의 fingerprint — 실측 규격(길이·컷·말끝)을 대본에 강제합니다"),
857
963
  },
858
964
  wrapCloudHandler(config, async ({
859
965
  title,
@@ -867,12 +973,15 @@ export function registerCloudTools(server, config, api) {
867
973
  christianContentType = "youtube-narration",
868
974
  insightFigureNames = "",
869
975
  userOpinion = "",
976
+ channelFingerprint = null,
870
977
  }) => {
871
978
  const SCRIPT_MODEL_MAP = { gemini: "gemini-3.5-flash", opus: "claude-opus-4-8", sonnet: "claude-sonnet-5" };
872
979
  const payload = await api.request("/api/tracker/ai/script", {
873
980
  body: {
874
981
  apiModel: SCRIPT_MODEL_MAP[model] || SCRIPT_MODEL_MAP.gemini,
875
- channelStyleProfile: null,
982
+ // 지문이 있으면 styleProfile 껍데기에 실어 보낸다 — 서버 script-prompts 가
983
+ // channelStyleProfile.fingerprint 에서 실측 규격 지시문을 뽑는다.
984
+ channelStyleProfile: channelFingerprint ? { fingerprint: channelFingerprint } : null,
876
985
  channelStyleReferenceText: "",
877
986
  christianContentType,
878
987
  contentFormat,
@@ -1015,9 +1124,16 @@ export function registerCloudTools(server, config, api) {
1015
1124
  sceneSeconds: z.number().int().min(3).max(8).optional().describe("씬 길이(초), 기본 5"),
1016
1125
  splitMode: z.enum(["fixed", "auto"]).optional().describe("씬 분할, 기본 fixed"),
1017
1126
  videoStylePreset: z.string().optional().describe("기본 seedance-music-video"),
1127
+ characters: z.array(z.object({
1128
+ name: z.string().describe("캐릭터 이름"),
1129
+ appearance: z.string().optional().describe("외모 앵커 (얼굴형·헤어·피부톤·의상 등)"),
1130
+ role: z.string().optional(),
1131
+ outfitLock: z.string().optional().describe("의상 고정 (예: navy hoodie)"),
1132
+ forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
1133
+ })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
1018
1134
  aspectRatio: z.string().optional().describe("기본 9:16"),
1019
1135
  },
1020
- wrapCloudHandler(config, async ({ durationSec, lyrics = "", conceptBrief = "", instrumental = false, sceneSeconds = 5, splitMode = "fixed", videoStylePreset = "seedance-music-video", aspectRatio = "9:16" }) => {
1136
+ wrapCloudHandler(config, async ({ durationSec, lyrics = "", conceptBrief = "", instrumental = false, sceneSeconds = 5, splitMode = "fixed", videoStylePreset = "seedance-music-video", aspectRatio = "9:16" , characters = [] }) => {
1021
1137
  if (!instrumental && !String(lyrics).trim()) {
1022
1138
  return textResult("lyrics를 입력하거나 instrumental=true로 지정하세요.", { isError: true });
1023
1139
  }
@@ -1030,6 +1146,7 @@ export function registerCloudTools(server, config, api) {
1030
1146
  instrumental,
1031
1147
  lyrics,
1032
1148
  sceneSeconds,
1149
+ characters,
1033
1150
  selectedCharacters: [],
1034
1151
  songMeta: {},
1035
1152
  splitMode,
@@ -1105,6 +1222,9 @@ export function registerCloudTools(server, config, api) {
1105
1222
  id: video?.id,
1106
1223
  keywords: video?.keywords,
1107
1224
  recommendedPreset: video?.recommendedPreset,
1225
+ // 박힌(하드섭) 자막 관측 — prepare_remake_timeline 의 sourceVideos[].burnedSubtitle 에
1226
+ // 그대로 넘기면 subtitleCropMode:"auto" 가 해당 소스만 하단 크롭한다.
1227
+ burnedSubtitle: video?.burnedSubtitle,
1108
1228
  sceneCatalog: (video?.sceneCatalog || []).map((scene) => ({
1109
1229
  actionTags: scene?.actionTags,
1110
1230
  ambienceScore: scene?.ambienceScore,
@@ -1128,6 +1248,85 @@ export function registerCloudTools(server, config, api) {
1128
1248
  }),
1129
1249
  );
1130
1250
 
1251
+ // ── 짜집기 렌더 준비 — 컷 세트 → TTS + 타임라인 페이로드 ────────────────────
1252
+ server.tool(
1253
+ "prepare_remake_timeline",
1254
+ "짜집기(리메이크) 렌더 준비 — analyze_edit_points 의 sceneCatalog 에서 고른 컷들로 구성한 컷 세트를 서버에 보내 TTS 합성 + 렌더 타임라인을 만듭니다. 반환된 payloadPath 를 render_start 에 넘기면 이 PC 에서 원본 다운로드 + 렌더가 수행됩니다. [N]/[SN] 행의 audioContent 는 유료 TTS 로 합성되므로 실행 전 사용자 확인을 받으세요.",
1255
+ {
1256
+ title: z.string().optional().describe("영상 상단 타이틀 텍스트 (없으면 생략)"),
1257
+ sourceVideos: z.array(z.object({
1258
+ id: z.string().describe("YouTube video id (11자)"),
1259
+ title: z.string().optional(),
1260
+ burnedSubtitle: z.object({
1261
+ present: z.boolean(),
1262
+ position: z.string().optional().describe("bottom/top/center/none"),
1263
+ bandPercent: z.number().optional().describe("자막 세로 점유율(%) — 보통 12~22"),
1264
+ }).optional().describe("analyze_edit_points 가 반환한 관측값을 그대로 — subtitleCropMode:auto 판단 근거"),
1265
+ })).min(1).max(5).describe("컷을 가져올 원본 영상 목록"),
1266
+ rows: z.array(z.object({
1267
+ mode: z.enum(["N", "S", "A", "SN"]).describe("N=TTS 내레이션(원본 무음) / S=원본 대사 유지 / A=현장음 유지 / SN=원본 대사+TTS"),
1268
+ durationSec: z.number().describe("행 길이(초) — S/A 는 클립 자연 길이가 우선"),
1269
+ audioContent: z.string().optional().describe("N/SN 의 내레이션 텍스트 (하단 자막으로도 burn-in)"),
1270
+ effectSubtitle: z.string().optional().describe("효과 자막 (짧은 박스 자막, 예: 팩트 폭행)"),
1271
+ clipRefs: z.array(z.object({
1272
+ videoId: z.string().describe("sourceVideos 의 id"),
1273
+ startMs: z.number().describe("원본에서 자를 시작(ms) — sceneCatalog 의 startMs"),
1274
+ endMs: z.number().describe("원본에서 자를 끝(ms)"),
1275
+ })).min(1).max(6).describe("이 행에 이어 붙일 원본 구간들"),
1276
+ })).min(1).max(40).describe("타임라인 행 — 순서대로 이어 붙습니다"),
1277
+ provider: z.enum(["typecast", "elevenlabs", "supertone"]).optional().describe("TTS 공급자, 기본 typecast (recommend_voice 로 보이스를 먼저 고르세요)"),
1278
+ voiceId: z.string().optional().describe("보이스 ID (생략 시 공급자 기본)"),
1279
+ aspectRatio: z.enum(["9:16", "16:9", "1:1"]).optional().describe("기본 9:16"),
1280
+ subtitleCropMode: z.enum(["off", "always", "auto"]).optional().describe("원본 박힌 자막 하단 크롭 — auto 는 burnedSubtitle.present 소스만"),
1281
+ },
1282
+ wrapCloudHandler(config, async ({ title = "", sourceVideos, rows, provider = "typecast", voiceId = "", aspectRatio = "9:16", subtitleCropMode = "off" }) => {
1283
+ const sourceIds = new Set(sourceVideos.map((source) => source.id));
1284
+ const badRef = rows.flatMap((row) => row.clipRefs).find((ref) => !sourceIds.has(ref.videoId));
1285
+ if (badRef) {
1286
+ return textResult(`clipRefs 의 videoId "${badRef.videoId}" 가 sourceVideos 에 없습니다.`, { isError: true });
1287
+ }
1288
+ const version = {
1289
+ id: "mcp-remake",
1290
+ title,
1291
+ sourceVideos,
1292
+ rows: rows.map((row) => ({
1293
+ ...row,
1294
+ clipRefs: row.clipRefs.map((ref) => ({
1295
+ ...ref,
1296
+ durationSec: Math.max(0.3, (ref.endMs - ref.startMs) / 1000),
1297
+ })),
1298
+ })),
1299
+ };
1300
+ const payload = await api.request("/api/tracker/remake/prepare", {
1301
+ body: {
1302
+ aspectRatio,
1303
+ costEventId: createUsageEventId("mcp-remake-tts"),
1304
+ provider,
1305
+ subtitleCropMode,
1306
+ version,
1307
+ voiceId,
1308
+ },
1309
+ method: "POST",
1310
+ timeoutMs: 300_000,
1311
+ });
1312
+ if (!payload?.payload) {
1313
+ return textResult(`짜집기 준비 실패: ${payload?.error || "타임라인이 비어 있습니다."}`, { isError: true });
1314
+ }
1315
+ const handle = savePayloadHandle(config.stateDir, payload.payload, "remake");
1316
+ return jsonResult({
1317
+ costUsd: payload.costUsd ?? 0,
1318
+ next: "render_start 에 payloadPath 를 넘겨 이 PC 에서 렌더하세요 (원본 다운로드 포함 — 수 분 걸릴 수 있음).",
1319
+ payloadPath: handle.filePath,
1320
+ sizeBytes: handle.bytes,
1321
+ totalDurationMs: payload.totalDurationMs ?? 0,
1322
+ ttsAttempted: payload.ttsAttempted ?? 0,
1323
+ ttsCharCount: payload.ttsCharCount ?? 0,
1324
+ ...(payload.ttsLastError ? { ttsLastError: payload.ttsLastError } : {}),
1325
+ ttsSucceeded: payload.ttsSucceeded ?? 0,
1326
+ });
1327
+ }),
1328
+ );
1329
+
1131
1330
  // ── 시간 동기 자막 TTS — 쇼츠 하단자막용 ─────────────────────────────────────
1132
1331
  server.tool(
1133
1332
  "tts_narration_with_captions",
@@ -1256,6 +1455,97 @@ export function registerCloudTools(server, config, api) {
1256
1455
  }),
1257
1456
  );
1258
1457
 
1458
+ server.tool(
1459
+ "measure_channel_spec",
1460
+ "벤치마크 채널의 영상 표본(유튜브 URL ≤5)을 실측해 채널 규격 지문(길이·컷간격·컷/분·라우드니스·무음비율·자막 위치·말끝 스타일)을 만듭니다. 컴패니언 앱이 다운로드·샷감지·오디오 추출을 수행하고 서버가 집계합니다. 반환된 fingerprint 를 plan_shorts_video 의 channelFingerprint 로 넘기면 대본이 그 규격을 강제합니다.",
1461
+ {
1462
+ videoUrls: z.array(z.string()).min(1).max(5).describe("표본 유튜브 URL 또는 11자 ID (조회수 상위 영상 권장)"),
1463
+ },
1464
+ wrapCloudHandler(config, async ({ videoUrls }) => {
1465
+ const specs = [];
1466
+ const notes = [];
1467
+ // 라우드니스·무음은 여기서 재고 "숫자만" 보낸다. 오디오를 서버로 올리면 표본 3편만
1468
+ // 넘어도 서버 본문 상한(16MB)을 넘겨 요청 전체가 죽고, 그걸 피하려 트림을 줄이면
1469
+ // 표본이 많을수록 측정 구간이 짧아진다. 웹(src/lib/channelFingerprint.js)도 같은 이유로
1470
+ // 브라우저에서 재고 숫자만 보낸다.
1471
+ const audioTrimSeconds = 120;
1472
+ for (const [index, rawUrl] of videoUrls.entries()) {
1473
+ const watchUrl = canonicalYoutubeWatchUrl(rawUrl);
1474
+ if (!watchUrl) {
1475
+ notes.push(`표본 ${index + 1}: 유튜브 URL 아님 — 건너뜀`);
1476
+ continue;
1477
+ }
1478
+ try {
1479
+ // 샷 감지 (컷수·길이·프레임) — 컴패니언 로컬 ffmpeg.
1480
+ const shotsResp = await fetch(`${config.companionUrl}/api/shots`, {
1481
+ body: JSON.stringify({ maxShots: COMPANION_MAX_SHOTS, minGapMs: 1400, sceneThreshold: 0.22, url: watchUrl, width: 480 }),
1482
+ headers: { "Content-Type": "application/json" },
1483
+ method: "POST",
1484
+ signal: AbortSignal.timeout(240_000),
1485
+ });
1486
+ const shotsPayload = await shotsResp.json().catch(() => null);
1487
+ if (!shotsResp.ok || !Array.isArray(shotsPayload?.shots) || !shotsPayload.shots.length) {
1488
+ notes.push(`표본 ${index + 1}: 샷 감지 실패(${shotsPayload?.error || shotsResp.status})`);
1489
+ continue;
1490
+ }
1491
+ const shots = shotsPayload.shots;
1492
+ const durationSec = Math.round((shots.at(-1)?.endMs || 0) / 100) / 10;
1493
+ const frames = shots
1494
+ .filter((shot) => shot?.imageBase64)
1495
+ .slice(0, 8)
1496
+ .map((shot) => `data:${shot?.mimeType || "image/jpeg"};base64,${shot.imageBase64}`);
1497
+ // 컴패니언은 감지된 컷을 maxShots 개로 다운샘플해 돌려준다(downsampleShotStarts).
1498
+ // 상한에 걸린 목록의 길이는 컷 수의 하한일 뿐이라 "실측"으로 쓸 수 없다 —
1499
+ // 빠른 컷 채널일수록 컷/분이 심하게 낮게 잡혀 대본 지시문을 오염시킨다.
1500
+ // 지어낸 숫자를 보내느니 축을 비우고 사유를 남긴다.
1501
+ const cutCountClamped = shots.length >= COMPANION_MAX_SHOTS;
1502
+ if (cutCountClamped) {
1503
+ notes.push(`표본 ${index + 1}: 감지 컷이 컴패니언 상한(${COMPANION_MAX_SHOTS})에 도달 — 컷 수는 하한값이라 컷 리듬 축에서 제외`);
1504
+ }
1505
+ // 오디오 — 여기서 실측해 숫자만 싣는다(서버 DSP 와 동일 알고리즘, wav-dsp.mjs).
1506
+ let loudnessDbfs = null;
1507
+ let silenceRatio = null;
1508
+ try {
1509
+ const wav = await extractCompanionAudio(config.companionUrl, { url: watchUrl });
1510
+ const trimmed = trimWavToSeconds(wav, audioTrimSeconds);
1511
+ loudnessDbfs = loudnessDbfsFromWav(trimmed);
1512
+ silenceRatio = silenceRatioFromWav(trimmed);
1513
+ if (loudnessDbfs === null) notes.push(`표본 ${index + 1}: WAV 파싱 실패 — 라우드니스 축 제외`);
1514
+ } catch (error) {
1515
+ notes.push(`표본 ${index + 1}: 오디오 추출 실패(${String(error?.message || error).slice(0, 80)}) — 라우드니스 축 제외`);
1516
+ }
1517
+ specs.push({
1518
+ ...(cutCountClamped ? {} : { cutCount: shots.length }),
1519
+ durationSec,
1520
+ frames,
1521
+ ...(loudnessDbfs !== null ? { loudnessDbfs } : {}),
1522
+ ...(silenceRatio !== null ? { silenceRatio } : {}),
1523
+ videoId: watchUrl.slice(-11),
1524
+ });
1525
+ } catch (error) {
1526
+ notes.push(`표본 ${index + 1}: ${String(error?.message || error).slice(0, 100)}`);
1527
+ }
1528
+ }
1529
+ if (!specs.length) {
1530
+ return textResult(`표본 실측에 모두 실패했습니다. 컴패니언 앱 실행 여부를 확인하세요.\n${notes.join("\n")}`, { isError: true });
1531
+ }
1532
+ const payload = await api.request("/api/tracker/gemini/channel-fingerprint", {
1533
+ body: { measuredAt: new Date().toISOString(), videos: specs },
1534
+ method: "POST",
1535
+ timeoutMs: 300_000,
1536
+ });
1537
+ if (payload?.ok === false || !payload?.fingerprint) {
1538
+ return textResult(`지문 집계 실패: ${payload?.error || "알 수 없는 오류"}`, { isError: true });
1539
+ }
1540
+ return jsonResult({
1541
+ fingerprint: payload.fingerprint,
1542
+ next: "이 fingerprint 객체를 plan_shorts_video 의 channelFingerprint 로 그대로 넘기세요 — 대본이 실측 규격(길이·컷·말속도·말끝)을 따르게 됩니다.",
1543
+ notes: notes.length ? notes : undefined,
1544
+ sampleCount: specs.length,
1545
+ });
1546
+ }),
1547
+ );
1548
+
1259
1549
  server.tool(
1260
1550
  "verify_scene_image",
1261
1551
  "생성된 키프레임 이미지를 서버 비전(Gemini)이 QC 판정합니다 — 캐릭터 참조 대조·아티팩트·프롬프트 이행. 이미지 블록을 직접 볼 수 없는 클라이언트의 폴백이며, 판정 JSON(pass/violations/regenerationHint)만 읽으면 됩니다. 불합격이면 regenerationHint 를 프롬프트에 반영해 generate_scene_image 를 재호출하세요.",
@@ -1332,6 +1622,35 @@ export function registerCloudTools(server, config, api) {
1332
1622
  }),
1333
1623
  );
1334
1624
 
1625
+ server.tool(
1626
+ "verify_render_darkness",
1627
+ "렌더된 mp4 의 검은 프레임(검은컷)을 휘도 실측으로 잡습니다 — 컷 경계 시각들을 넘기면 그 지점만, 생략하면 1초 간격 전수 샘플. darkCount>0 이면 해당 시각의 씬 전환/소스를 점검하세요. AI 호출 없음(무과금).",
1628
+ {
1629
+ videoUrl: z.string().describe("검사할 mp4 URL (렌더 결과 업로드본 또는 씬 videoUrl)"),
1630
+ timestamps: z.array(z.number().min(0)).max(120).optional().describe("검사 시각(초) — 컷 경계 ±0.15s 권장. 생략 시 1초 간격 자동"),
1631
+ darkThreshold: z.number().int().min(1).max(80).optional().describe("검정 판정 휘도 상한(0~255), 기본 16"),
1632
+ },
1633
+ wrapCloudHandler(config, async ({ videoUrl, timestamps = [], darkThreshold = 16 }) => {
1634
+ const payload = await api.request("/api/tracker/video/darkness", {
1635
+ body: { darkThreshold, timestamps, videoUrl },
1636
+ method: "POST",
1637
+ timeoutMs: 300_000,
1638
+ });
1639
+ if (payload?.ok === false) {
1640
+ return textResult(`휘도 측정 실패: ${payload?.error || "알 수 없는 오류"}`, { isError: true });
1641
+ }
1642
+ return jsonResult({
1643
+ darkCount: payload?.darkCount,
1644
+ darkSamples: payload?.darkSamples,
1645
+ pass: (payload?.darkCount || 0) === 0,
1646
+ sampleCount: payload?.sampleCount,
1647
+ verdictNote: (payload?.darkCount || 0) > 0
1648
+ ? "검은 프레임 검출 — 해당 시각의 씬 소스/전환을 점검하고 재스티치하세요 (transitionMode fade 사용 여부 확인)."
1649
+ : "검은컷 없음.",
1650
+ });
1651
+ }),
1652
+ );
1653
+
1335
1654
  // ── 상세페이지(PDP) ─────────────────────────────────────────────────────────
1336
1655
  server.tool(
1337
1656
  "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.9.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";
@@ -0,0 +1,82 @@
1
+ // WAV 라우드니스·무음 실측 — server/channel-fingerprint.mjs 의 동일 알고리즘을 MCP 로 이식한 것.
2
+ // 이식하는 이유: 지문 측정에서 오디오를 서버로 올리면 표본 3편만 넘어도 서버 본문 상한(16MB)을
3
+ // 넘겨 요청 전체가 죽는다. 웹(src/lib/channelFingerprint.js)도 같은 이유로 브라우저에서 재고
4
+ // 숫자만 보낸다. MCP 는 별도 npm 패키지라 server/ 를 import 할 수 없어 코드를 복제하되,
5
+ // wav-dsp.test.mjs 가 서버 구현과의 수치 일치를 계약으로 고정한다.
6
+ //
7
+ // 세 구현(서버·웹·MCP)은 반드시 같은 숫자를 내야 한다. 한쪽을 고치면 나머지도 같이 고칠 것.
8
+
9
+ export function parseWavPcm(buffer) {
10
+ if (!Buffer.isBuffer(buffer) || buffer.length < 44) return null;
11
+ if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") return null;
12
+ let offset = 12;
13
+ let fmt = null;
14
+ let dataOffset = -1;
15
+ let dataLen = 0;
16
+ while (offset + 8 <= buffer.length) {
17
+ const id = buffer.toString("ascii", offset, offset + 4);
18
+ const size = buffer.readUInt32LE(offset + 4);
19
+ const body = offset + 8;
20
+ // fmt 청크 헤더는 있으나 본문(16바이트)이 잘린 버퍼는 건너뛴다 — 읽기 범위 초과 대신 null.
21
+ if (id === "fmt " && body + 16 <= buffer.length) {
22
+ fmt = {
23
+ audioFormat: buffer.readUInt16LE(body),
24
+ channels: buffer.readUInt16LE(body + 2),
25
+ sampleRate: buffer.readUInt32LE(body + 4),
26
+ bitsPerSample: buffer.readUInt16LE(body + 14),
27
+ };
28
+ } else if (id === "data") {
29
+ dataOffset = body;
30
+ dataLen = Math.min(size, buffer.length - body);
31
+ }
32
+ offset = body + size + (size % 2); // chunks are word-aligned
33
+ }
34
+ if (!fmt || dataOffset < 0 || fmt.bitsPerSample !== 16 || fmt.audioFormat !== 1) return null;
35
+ return { ...fmt, dataOffset, dataLen };
36
+ }
37
+
38
+ // 16-bit PCM 표본을 순회하며 콜백. 첫 채널만 사용(모노 가정, 스테레오면 좌채널).
39
+ function forEachSample(buffer, wav, fn) {
40
+ const step = 2 * Math.max(1, wav.channels);
41
+ for (let i = wav.dataOffset; i + 1 < wav.dataOffset + wav.dataLen; i += step) {
42
+ fn(buffer.readInt16LE(i) / 32768);
43
+ }
44
+ }
45
+
46
+ // RMS → dBFS. 무음(전부 0)이면 -Infinity 대신 -100 으로 바닥 처리.
47
+ export function loudnessDbfsFromWav(buffer) {
48
+ const wav = parseWavPcm(buffer);
49
+ if (!wav) return null;
50
+ let sumSq = 0;
51
+ let n = 0;
52
+ forEachSample(buffer, wav, (s) => { sumSq += s * s; n += 1; });
53
+ if (!n) return null;
54
+ const rms = Math.sqrt(sumSq / n);
55
+ if (rms <= 0) return -100;
56
+ return Math.max(-100, Math.round(20 * Math.log10(rms) * 10) / 10);
57
+ }
58
+
59
+ // 무음 비율 — 짧은 창(20ms)의 RMS 가 임계(dBFS) 아래인 시간 비율. 0~1.
60
+ export function silenceRatioFromWav(buffer, { thresholdDb = -45, windowMs = 20 } = {}) {
61
+ const wav = parseWavPcm(buffer);
62
+ if (!wav) return null;
63
+ const win = Math.max(1, Math.floor((wav.sampleRate * windowMs) / 1000));
64
+ const thresholdRms = Math.pow(10, thresholdDb / 20);
65
+ let windows = 0;
66
+ let silent = 0;
67
+ let sumSq = 0;
68
+ let count = 0;
69
+ forEachSample(buffer, wav, (s) => {
70
+ sumSq += s * s;
71
+ count += 1;
72
+ if (count >= win) {
73
+ const rms = Math.sqrt(sumSq / count);
74
+ windows += 1;
75
+ if (rms < thresholdRms) silent += 1;
76
+ sumSq = 0;
77
+ count = 0;
78
+ }
79
+ });
80
+ if (!windows) return null;
81
+ return Math.round((silent / windows) * 1000) / 1000;
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimakeall-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "AImakeAll MCP 서버 — Claude Code/Codex에서 자연어로 영상 기획·생성·렌더·퍼블리시 (렌더는 로컬 컴패니언)",
5
5
  "type": "module",
6
6
  "bin": {