aimakeall-mcp 0.10.0 → 0.11.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.10.0"],
19
+ "args": ["-y", "aimakeall-mcp@0.11.0"],
20
20
  "env": { "AIMAKEALL_PAT": "aio_pat_..." }
21
21
  }
22
22
  }
@@ -36,7 +36,9 @@ Claude Code·Codex 같은 MCP 클라이언트에서 자연어로 AImakeAll 영
36
36
 
37
37
  ```
38
38
  plan_shorts_video → (씬마다) generate_scene_image → get_generation_job(completed까지 조회) → [QC: 동봉 이미지 직접 확인 또는 verify_scene_image] → generate_scene_video_prompt → generate_scene_video → get_generation_job(completed까지 조회) → [QC: 동봉 프레임 확인 또는 verify_scene_video]
39
- → (보이스 미지정 시) recommend_voice → tts_narration_with_captions(하단자막·강조용) 또는 tts_narration → stitch_timeline → render_start → render_status(폴링) → render_result
39
+ → (보이스 미지정 시) recommend_voice → tts_narration_with_captions(하단자막·강조용) 또는 tts_narration
40
+ → list_composer_presets + search_composer_assets / recommend_composer_overlays
41
+ → stitch_timeline → review_production → render_start → render_status(폴링) → render_result → 최종 프레임·음성 검토
40
42
  → ai_publish_metadata → publish_youtube
41
43
  ```
42
44
 
@@ -46,11 +48,24 @@ plan_shorts_video → (씬마다) generate_scene_image → get_generation_job(co
46
48
  - 대용량 산출물(TTS mp3, 스티치 매니페스트)은 파일 경로로 주고받아 모델 컨텍스트를 오염시키지 않습니다.
47
49
  - `recommend_voice`는 주제·샘플 영상(유튜브)에 맞는 보이스를 자동 선정합니다 — 샘플 화자 분석은 컴패니언 앱이 실행 중일 때만 동작합니다.
48
50
  - 자체검증(QC): 씬 이미지/영상 결과에 이미지·프레임 블록이 동봉되어 에이전트가 직접 보고 판정합니다. 비전 미지원 클라이언트는 `verify_scene_image`/`verify_scene_video`(서버 Gemini 판정)를 쓰세요 — 불합격 시 `regenerationHint`를 반영해 재생성.
49
- - 하단자막 강조: `tts_narration_with_captions`의 `subtitleLines`와 `plan_shorts_video`의 `emphasisKeywords`를 `stitch_timeline`에 함께 넘기면 자막 안 해당 단어만 노랑/큰 글씨로 강조됩니다(최신 컴패니언 런타임 필요 — 구버전은 일반 자막으로 안전 강등).
51
+ - 하단자막 강조: `subtitleLines`와 `emphasisKeywords`를 함께 넘기거나 제작 핸들을 사용하면 해당 단어만 색/크기로 강조됩니다. 새 타임라인은 필요한 렌더 기능을 명시하며, 구형 런타임에서는 조용히 무시하지 않고 업데이트를 요청합니다.
50
52
  - 렌더 영수증: `render_status`(완료 시)와 `render_result`가 산출물 실측(`summary.measured` — 길이·해상도·오디오·디코드 청결성·파일 크기)을 요청 규격과 대조해 `mismatches`로 알려줍니다. 불일치가 있으면 저장 전 원인을 확인하세요.
51
53
  - 원가 가시화: 생성 전 `estimate_video_cost`로 견적을 내고, 작업 후 `get_cost_report`로 실제 지출을 확인하세요. 어두운/검은 컷 의심 시 `verify_render_darkness`로 완성본 휘도를 실측할 수 있습니다.
52
54
  - 캐릭터 일관성: `plan_*`에 `characters`(이름·외모 앵커·의상 고정)를 넘기면 씬마다 identity lock 이 프롬프트에 강제 주입되고, `generate_scene_image`의 `identityLock`으로 재생성 시에도 유지됩니다.
53
55
 
56
+ ## 제작 스타일 유지와 컷컴포저 편집 (0.11.0)
57
+
58
+ 이 절의 도구는 MCP 0.11.0과 대응 서버·컴패니언 런타임이 필요합니다. 기존 MCP 설정의 버전을 `aimakeall-mcp@0.11.0`으로 바꾸고 클라이언트를 다시 연결하세요. 컴패니언에서 렌더 기능 업데이트를 요청하면 최신 설치 프로그램으로 업데이트하세요.
59
+
60
+ - `plan_shorts_video({categoryId:"viral-shorts",durationTargetSec:40,...})`는 `productionProfile`과 `productionPath`를 반환합니다. 이후 추천·TTS·스티치에 **가장 최근 반환된 `productionPath`**를 전달하세요. 제작별 불변 파일이라 동시에 만든 다른 영상의 스타일과 섞이지 않습니다.
61
+ - 바이럴·커뮤니티 프로필은 `casual-social`을 사용합니다. 사건 소재라도 뉴스 브리핑형으로 자동 변경하지 않습니다. TTS의 `text`를 생략하면 저장된 기획 대본을 사용하며, 분명한 뉴스체 회귀는 과금 전 `422 NARRATION_STYLE_DRIFT`로 중단합니다. 자막은 실제 발화와 일치시켜야 하며 자극성을 위해 사실·인용·피해 내용을 꾸미면 안 됩니다.
62
+ - 프로필이 있으면 보이스 추천 기본 공급자는 ElevenLabs입니다. 카탈로그에서 대화형/소셜 특성을 우선하되 실제 청취 결과라고 주장하지 않습니다. `speed`, `stability`, `style`, `similarityBoost`, `speakerBoost`, `modelId`는 명시적으로 조절할 수 있습니다.
63
+ - `list_composer_presets`에서 실제 편집기의 프리셋과 모션을 조회하세요. `titleStylePresetId`, `subtitleStylePresetId`, `subtitleLines[].stylePresetId`, `titleStyle`, `subtitleStyle`, `subtitleLines[].style`로 서로 다른 제목/본문 계층과 장면별 강조를 설정합니다. 명시 스타일 > 선택 프리셋 > 프로필 기본값 순서입니다.
64
+ - `search_composer_assets` 또는 무료 `recommend_composer_overlays`로 관련 소재를 검토하고 선택한 `assetId`에 `startSec/endSec`, `xPct/yPct`, `widthPct`를 붙여 `stitch_timeline.overlays`로 전달하세요. 좌표는 화면의 백분율 중심점이며 `opacity`는 0~100입니다. SVG 아이콘·이모지, GIF/비디오, 효과음을 타임라인 레이어로 처리합니다. 사건·피해자에 조롱 밈을 강제로 붙이지 않으며, 출처·라이선스 확인이 필요한 소재는 공개 전에 검토해야 합니다.
65
+ - 목표 길이는 실제 TTS 길이로 덮어쓰지 않습니다. 허용 오차 `max(2초, 목표의 10%)`를 벗어나면 경고하고 렌더 전에 차단합니다. 자동 유료 재합성을 하지 않으며, 무음이나 억지 영상 늘이기로 통과시키지 마세요.
66
+ - 커뮤니티/바이럴 제작은 라이브러리 검토 없이 기본 자막만으로 끝내지 않습니다. 오버레이가 없으면 `decorationRationale`에 적합한 소재를 생략한 이유를 기록해야 렌더할 수 있습니다. 관련 없는 장식이나 부적절한 밈을 억지로 넣는 것은 해결이 아닙니다.
67
+ - `review_production`은 구조만 점검합니다. `render_result`의 `qualityStatus`가 `needs-visual-and-listening-review`이면 아직 시각·청취 검증 전입니다. 최종 영상에서 강조색/글자 크기/모션/소재 겹침과 목소리를 직접 확인한 뒤에만 완성 품질을 보고하세요.
68
+
54
69
  ## 채널 규격 지문 (벤치마킹)
55
70
 
56
71
  ```
@@ -26,6 +26,9 @@ import { createUsageEventId } from "./usage-event.mjs";
26
26
  import { fetchGenerationJob, GENERATION_JOB_PATH, submitGenerationJob } from "./generation-jobs.mjs";
27
27
  import { loudnessDbfsFromWav, silenceRatioFromWav } from "./wav-dsp.mjs";
28
28
  import { trimWavToSeconds } from "./wav-trim.mjs";
29
+ import { registerComposerTools } from "./composer-tools.mjs";
30
+ import { productionFields, composerTextStyle, composerOverlay } from "./production-schemas.mjs";
31
+ import { saveProductionContext, readProductionContext, durationReview, reviewProductionPayload } from "./production-context.mjs";
29
32
 
30
33
  // 여러 파일을 base64 로 싣는 요청의 인코딩 크기 합이 서버 JSON 상한을 넘지 않게 사전 검사.
31
34
  // 서버가 전체 업로드를 받은 뒤 413 을 내는 것을 막고 행동 가능한 한국어 안내를 준다.
@@ -180,6 +183,7 @@ function trimAnalysis(analysis) {
180
183
  }
181
184
 
182
185
  export function registerCloudTools(server, config, api) {
186
+ registerComposerTools(server, config, api);
183
187
  async function completedGenerationResult(job, includePreview) {
184
188
  const payload = job.result || {};
185
189
  const resultBody = { generationJobId: job.id, status: job.status, kind: job.kind,
@@ -294,6 +298,8 @@ export function registerCloudTools(server, config, api) {
294
298
  "plan_shorts_video",
295
299
  "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조).",
296
300
  {
301
+ productionProfile: productionFields.productionProfile,
302
+ durationTargetSec: z.number().positive().max(180).optional().describe("사용자가 요청한 목표 길이(예:40). 음성 길이로 덮어쓰지 않습니다."),
297
303
  topic: z.string().optional().describe("영상 주제"),
298
304
  copy: z.string().optional().describe("핵심 카피/대사"),
299
305
  categoryId: z.enum(["community-shorts", "viral-shorts", "insight"]).optional().describe("기본 community-shorts. insight=명언(통찰) 쇼츠"),
@@ -322,13 +328,15 @@ export function registerCloudTools(server, config, api) {
322
328
  forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
323
329
  })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
324
330
  },
325
- wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 0, characters = [], channelFingerprint = null }) => {
331
+ wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 0, characters = [], channelFingerprint = null, productionProfile, durationTargetSec }) => {
326
332
  if (!String(topic).trim() && !String(copy).trim()) {
327
333
  return textResult("topic 또는 copy 중 하나는 입력해야 합니다.", { isError: true });
328
334
  }
329
335
  const payload = await api.request("/api/tracker/shorts-video/plan", {
330
336
  body: {
331
337
  categoryId,
338
+ productionProfile,
339
+ durationTargetSec,
332
340
  channelFingerprint,
333
341
  copy,
334
342
  operationId: createUsageEventId("shorts-plan"),
@@ -343,7 +351,16 @@ export function registerCloudTools(server, config, api) {
343
351
  method: "POST",
344
352
  timeoutMs: 240_000,
345
353
  });
354
+ const productionPath = saveProductionContext(config.stateDir, {
355
+ topic, productionProfile: payload?.productionProfile, narrationScript: payload?.narrationScript,
356
+ narrationSource: payload?.narrationSource, tagline: payload?.tagline, emphasisKeywords: payload?.emphasisKeywords || [],
357
+ channelFingerprint, scenes: trimPlanScenes(payload?.scenes),
358
+ });
346
359
  return jsonResult({
360
+ productionPath,
361
+ productionProfile: payload?.productionProfile,
362
+ narrationSource: payload?.narrationSource,
363
+ next: "productionPath를 recommend_voice → tts_narration_with_captions → stitch_timeline에 전달하세요. 대본을 뉴스체로 바꾸지 말고, list_composer_presets/search_composer_assets로 편집 소재도 선택하세요.",
347
364
  charactersUsed: Array.isArray(payload?.charactersUsed) ? payload.charactersUsed : undefined,
348
365
  ctaText: payload?.ctaText,
349
366
  emphasisKeywords: Array.isArray(payload?.emphasisKeywords) ? payload.emphasisKeywords : [],
@@ -615,14 +632,26 @@ export function registerCloudTools(server, config, api) {
615
632
  "tts_narration",
616
633
  "내레이션 TTS를 합성해 이 PC에 mp3로 저장하고 파일 경로를 반환합니다. stitch_timeline의 ttsAudioPath로 쓰세요. 사용자가 보이스를 직접 지정하지 않았다면 먼저 recommend_voice로 주제·샘플 영상에 맞는 보이스를 자동 선정해 voice에 넣으세요.",
617
634
  {
618
- text: z.string().describe("내레이션 텍스트"),
635
+ ...productionFields,
636
+ text: z.string().optional().describe("생략 시 productionPath의 기획 대본"),
619
637
  provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
620
638
  voice: z.string().optional().describe("보이스 이름(라벨)"),
621
639
  speed: z.number().optional().describe("기본 1"),
640
+ stability: z.number().min(0).max(1).optional(), style: z.number().min(0).max(1).optional(),
641
+ modelId: z.string().optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
622
642
  },
623
- wrapCloudHandler(config, async ({ text, provider = "typecast", voice = "", speed = 1 }) => {
643
+ wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, stability, style, modelId, similarityBoost, speakerBoost }) => {
644
+ const context = readProductionContext(config.stateDir, productionPath);
645
+ productionProfile = context?.productionProfile || productionProfile;
646
+ text ??= context?.narrationScript;
647
+ provider ||= context?.voice?.provider || (productionProfile ? "elevenlabs" : "typecast");
648
+ if (!voice && context?.voice?.provider && context.voice.provider !== provider) return textResult("보이스 공급자가 다릅니다. 선택한 provider용 voice를 다시 선정하세요.", { isError: true });
649
+ voice ||= context?.voice?.voiceId || "";
650
+ if (!text?.trim()) return textResult("대본(text/productionPath)이 필요합니다.", { isError: true });
651
+ if (productionProfile?.deliveryStyle === "casual-social" && !voice) return textResult("먼저 recommend_voice에 productionPath를 전달해 커뮤니티형 보이스를 선정하세요.", { isError: true });
624
652
  const body = provider === "typecast"
625
653
  ? {
654
+ productionProfile,
626
655
  breath: 0.3,
627
656
  costEventId: createUsageEventId("mcp-tts-typecast"),
628
657
  emotion: "neutral",
@@ -637,15 +666,16 @@ export function registerCloudTools(server, config, api) {
637
666
  volume: 100,
638
667
  }
639
668
  : {
669
+ productionProfile,
640
670
  costEventId: createUsageEventId("mcp-tts-elevenlabs"),
641
671
  fileName: "mcp-narration",
642
672
  languageCode: "ko",
643
- modelId: "",
644
- similarityBoost: 0.8,
645
- speakerBoost: true,
673
+ modelId,
674
+ similarityBoost,
675
+ speakerBoost,
646
676
  speed,
647
- stability: 0.45,
648
- style: 0.35,
677
+ stability,
678
+ style,
649
679
  text,
650
680
  voice,
651
681
  };
@@ -655,10 +685,13 @@ export function registerCloudTools(server, config, api) {
655
685
  timeoutMs: 120_000,
656
686
  });
657
687
  const saved = saveMediaBuffer(config.stateDir, "narration.mp3", result.buffer, { contentType: result.contentType });
688
+ const durationSec = audioDurationSecFromFile(saved.filePath);
658
689
  return jsonResult({
690
+ productionPath: saveProductionContext(config.stateDir, { ...context, productionProfile, narrationScript: text, tts: { filePath: saved.filePath, durationSec, voiceId: voice } }),
691
+ durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec),
659
692
  bytes: saved.bytes,
660
693
  // 실측 mp3 길이 — stitch_timeline 의 audioDurationSec 로 그대로 쓰면 타임라인이 정확하다.
661
- durationSec: audioDurationSecFromFile(saved.filePath),
694
+ durationSec,
662
695
  filePath: saved.filePath,
663
696
  voiceName: decodeHeaderValue(
664
697
  result.headers.get("x-typecast-voice-name") || result.headers.get("x-elevenlabs-voice-name") || "",
@@ -737,6 +770,12 @@ export function registerCloudTools(server, config, api) {
737
770
  "stitch_timeline",
738
771
  "씬 영상들과 오디오를 서버에서 타임라인 매니페스트로 합칩니다. 결과는 파일 핸들(payloadPath)로 반환되며, 이를 render_start에 넘기면 이 PC에서 mp4가 렌더됩니다. 서버는 렌더하지 않습니다. 하단자막 강조: emphasisKeywords 를 주면 자막 라인 안의 해당 단어만 다른 색/크기로 강조되고, subtitleLines[].style 로 라인별 디자인도 바꿀 수 있습니다.",
739
772
  {
773
+ ...productionFields,
774
+ titleStylePresetId: z.string().optional(), subtitleStylePresetId: z.string().optional(),
775
+ titleStyle: composerTextStyle.optional(), subtitleStyle: composerTextStyle.optional(),
776
+ titleSegments: z.array(z.object({ text: z.string(), color: z.string().optional() })).max(20).optional(),
777
+ overlays: z.array(composerOverlay).max(40).optional().describe("검색한 소재를 장면에 맞는 시간/위치에 배치. 배경 이미지에 그려진 문양은 편집 소재 사용으로 세지 않습니다."),
778
+ decorationRationale: z.string().max(500).optional().describe("소재를 생략한 이유. 피해자 희화화·권리 위험이면 밈 대신 중립적 아이콘을 검토하세요."),
740
779
  sceneVideos: z.array(z.object({
741
780
  durationSec: z.number(),
742
781
  idx: z.number().int(),
@@ -756,17 +795,8 @@ export function registerCloudTools(server, config, api) {
756
795
  endSec: z.number(),
757
796
  startSec: z.number(),
758
797
  text: z.string(),
759
- style: z.object({
760
- fontSize: z.number().optional(),
761
- fontWeight: z.enum(["bold", "normal"]).optional(),
762
- strokeColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
763
- strokeWidth: z.number().optional(),
764
- textBackgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
765
- textBackgroundEnabled: z.boolean().optional(),
766
- textBackgroundOpacity: z.number().optional(),
767
- textColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
768
- textEffectVariant: z.string().optional(),
769
- }).optional().describe("이 라인만의 디자인 오버라이드 (내용에 따라 라인별 디자인을 달리할 때)"),
798
+ stylePresetId: z.string().optional(),
799
+ style: composerTextStyle.optional(),
770
800
  })).optional().describe("자막 타이밍 (쇼츠 하단자막) — tts_narration_with_captions의 subtitleLines를 그대로 사용"),
771
801
  emphasisKeywords: z.array(z.string()).max(12).optional().describe("강조 단어 목록 — 자막 라인 안에서 해당 단어만 다른 색/크기로 렌더 (plan_shorts_video의 emphasisKeywords 를 그대로 전달)"),
772
802
  emphasisColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional().describe("강조 색 #RRGGBB (기본 #facc15 노랑)"),
@@ -778,7 +808,15 @@ export function registerCloudTools(server, config, api) {
778
808
  }).passthrough().optional().describe("measure_channel_spec 이 반환한 fingerprint — 자막 세로 위치·BGM 유무를 참고 채널에 맞춥니다"),
779
809
  transitionMode: z.enum(["fade", "none"]).optional().describe("기본 none(하드컷). fade 는 씬이 맞대기로 붙는 배치에서 컷마다 검은 딥을 만든다 — 명시 요청 시에만 쓸 것"),
780
810
  },
781
- 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 = "none", channelFingerprint = null }) => {
811
+ wrapCloudHandler(config, async ({ sceneVideos, featureKey, projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines, emphasisKeywords, emphasisColor = "", emphasisSizeScale = 0, transitionMode = "none", channelFingerprint = null, productionPath, productionProfile, titleStylePresetId, subtitleStylePresetId, titleStyle, subtitleStyle, titleSegments, overlays, decorationRationale }) => {
812
+ const context = readProductionContext(config.stateDir, productionPath);
813
+ productionProfile = context?.productionProfile || productionProfile;
814
+ ttsAudioPath ||= context?.tts?.filePath || "";
815
+ titleText ||= context?.tagline || "";
816
+ subtitleLines ??= context?.tts?.subtitleLines || [];
817
+ emphasisKeywords ??= context?.emphasisKeywords || [];
818
+ featureKey ||= productionProfile?.categoryId?.endsWith("shorts") ? "shortsVideo" : "commerceVideo";
819
+ channelFingerprint ||= context?.channelFingerprint;
782
820
  // 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
783
821
  if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
784
822
  if (bgmAudioPath) assertAllowedInputFile(bgmAudioPath, new Set([".mp3", ".wav"]), { kind: "BGM 오디오" });
@@ -788,6 +826,7 @@ export function registerCloudTools(server, config, api) {
788
826
  ? audioDurationSec
789
827
  : (ttsAudioPath ? audioDurationSecFromFile(ttsAudioPath) : 0);
790
828
  const body = {
829
+ productionProfile, titleStylePresetId, subtitleStylePresetId, titleStyle, subtitleStyle, titleSegments, overlays,
791
830
  aspectRatio,
792
831
  audioDurationSec: resolvedAudioDurationSec,
793
832
  bgmAudioUrl: bgmAudioPath ? fileToDataUrl(bgmAudioPath) : "",
@@ -818,11 +857,15 @@ export function registerCloudTools(server, config, api) {
818
857
  if (!response?.ok || !response?.payload) {
819
858
  return textResult(`스티치 실패: ${response?.error || "매니페스트가 비어 있습니다."}`, { isError: true });
820
859
  }
860
+ if (context || productionProfile) response.payload.productionContext = { ...context, productionProfile, decorationRationale };
861
+ const qualityReview = reviewProductionPayload(response.payload);
821
862
  const handle = savePayloadHandle(config.stateDir, response.payload, "stitch");
822
863
  return jsonResult({
823
864
  assetCount: Array.isArray(response.payload.assets) ? response.payload.assets.length : 0,
824
865
  clipCount: Array.isArray(response.payload.studio?.clips) ? response.payload.studio.clips.length : 0,
825
- next: "render_start에 payloadPath를 넘겨 이 PC에서 렌더하세요.",
866
+ next: qualityReview.pass ? "qualityReview 경고를 검토한 뒤 render_start에 payloadPath를 전달하세요. 최종 영상 프레임과 음성도 검토해야 합니다." : "qualityReview.errors의 길이·소재 선택 검토 문제를 먼저 해결하세요. render_start는 제작 규격 불일치를 차단합니다.",
867
+ qualityReview,
868
+ compositionReport: response.payload.compositionReport,
826
869
  payloadPath: handle.filePath,
827
870
  projectTitle: response.payload.projectTitle,
828
871
  sizeBytes: handle.bytes,
@@ -1329,13 +1372,28 @@ export function registerCloudTools(server, config, api) {
1329
1372
  "tts_narration_with_captions",
1330
1373
  "ElevenLabs TTS를 char-level 타이밍과 함께 합성해 mp3 파일 + 시간 동기 자막 라인(subtitleLines)을 만듭니다. 쇼츠 하단자막이 필요하면 tts_narration 대신 이걸 쓰고, 결과 subtitleLines·durationSec·filePath를 stitch_timeline에 그대로 넘기세요 (웹 쇼츠 스튜디오와 동일한 정렬 방식). 사용자가 보이스를 지정하지 않았다면 먼저 recommend_voice(provider=elevenlabs)로 자동 선정하세요.",
1331
1374
  {
1332
- text: z.string().describe("내레이션 텍스트"),
1375
+ ...productionFields,
1376
+ text: z.string().optional().describe("생략 시 제작 핸들의 기획 대본 그대로 합성. 사실·인용을 바꾸거나 뉴스체로 재작성하지 마세요."),
1333
1377
  voice: z.string().optional().describe("보이스 이름 또는 ID (생략 시 기본 보이스)"),
1334
1378
  speed: z.number().optional().describe("기본 1 (0.7~1.2)"),
1379
+ modelId: z.string().optional(), stability: z.number().min(0).max(1).optional(),
1380
+ style: z.number().min(0).max(1).optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
1335
1381
  },
1336
- wrapCloudHandler(config, async ({ text, voice = "", speed = 1 }) => {
1382
+ wrapCloudHandler(config, async ({ text, voice = "", speed, productionPath, productionProfile, modelId, stability, style, similarityBoost, speakerBoost }) => {
1383
+ const context = readProductionContext(config.stateDir, productionPath);
1384
+ productionProfile = context?.productionProfile || productionProfile;
1385
+ text ??= context?.narrationScript;
1386
+ if (!voice && context?.voice?.provider && context.voice.provider !== "elevenlabs") {
1387
+ return textResult("이 제작 핸들의 보이스는 ElevenLabs용이 아닙니다. recommend_voice(provider=elevenlabs)로 다시 선정하거나 ElevenLabs voice를 명시하세요.", { isError: true });
1388
+ }
1389
+ voice ||= context?.voice?.voiceId || "";
1390
+ if (!text?.trim()) return textResult("text 또는 기획 대본이 있는 productionPath가 필요합니다.", { isError: true });
1391
+ if (productionProfile?.deliveryStyle === "casual-social" && !voice) {
1392
+ return textResult("커뮤니티형 음성을 위해 먼저 recommend_voice에 productionPath를 전달하고, 반환된 productionPath 또는 보이스 ID를 사용하세요.", { isError: true });
1393
+ }
1337
1394
  const payload = await api.request("/api/tracker/tts/elevenlabs-with-timestamps", {
1338
1395
  body: {
1396
+ productionProfile, modelId, stability, style, similarityBoost, speakerBoost,
1339
1397
  costEventId: createUsageEventId("mcp-tts-elevenlabs-ts"),
1340
1398
  languageCode: "ko",
1341
1399
  speed,
@@ -1361,7 +1419,16 @@ export function registerCloudTools(server, config, api) {
1361
1419
  const durationSec = Array.isArray(charEnds) && charEnds.length
1362
1420
  ? Math.round(Number(charEnds[charEnds.length - 1]) * 10) / 10
1363
1421
  : audioDurationSecFromFile(saved.filePath);
1422
+ const updatedProductionPath = saveProductionContext(config.stateDir, {
1423
+ ...context, productionProfile, narrationScript: text,
1424
+ tts: { durationSec, filePath: saved.filePath, subtitleLines, voiceId: payload?.voiceId || voice, deliverySettings: payload?.deliverySettings },
1425
+ });
1364
1426
  return jsonResult({
1427
+ productionPath: updatedProductionPath,
1428
+ productionProfile,
1429
+ deliverySettings: payload?.deliverySettings,
1430
+ durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec),
1431
+ next: "이 productionPath를 stitch_timeline으로 전달하세요. 목표보다 짧으면 자동 재과금 없이 먼저 대본/말속도와 재합성 비용을 검토하세요. 자막은 실제 발화에 맞추고 강조는 편집 스타일로 적용하세요.",
1365
1432
  durationSec,
1366
1433
  filePath: saved.filePath,
1367
1434
  lineCount: subtitleLines.length,
@@ -1396,13 +1463,19 @@ export function registerCloudTools(server, config, api) {
1396
1463
  "recommend_voice",
1397
1464
  "주제·대본과 사용자가 샘플로 지정한 영상(유튜브)에 어울리는 TTS 보이스를 LLM이 자동 선정합니다. 사용자가 보이스를 직접 지정하지 않았다면 tts_narration/tts_narration_with_captions 전에 이 도구를 호출해 recommendations[0].voiceId를 voice로 쓰세요. 샘플 영상이 있으면 컴패니언 앱이 화자 음성을 추출·분석해 가장 비슷한 보이스를 우선합니다(컴패니언 미실행 시 주제 기반으로만 추천).",
1398
1465
  {
1466
+ ...productionFields,
1399
1467
  topic: z.string().optional().describe("영상 주제·타깃 시청층 (예: 시니어 건강 정보, 커머스 쇼츠)"),
1400
1468
  scriptExcerpt: z.string().optional().describe("대본 앞부분 발췌 (선택)"),
1401
1469
  sampleVideoUrl: z.string().optional().describe("사용자가 샘플로 지정한 유튜브 URL 또는 11자 영상 ID"),
1402
1470
  provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
1403
1471
  sampleVoiceHints: z.string().optional().describe("사용자가 말한 보이스 요구 (예: 차분한 중년 남성)"),
1404
1472
  },
1405
- wrapCloudHandler(config, async ({ topic = "", scriptExcerpt = "", sampleVideoUrl = "", provider = "typecast", sampleVoiceHints = "" }) => {
1473
+ wrapCloudHandler(config, async ({ topic = "", scriptExcerpt = "", sampleVideoUrl = "", provider, sampleVoiceHints = "", productionPath, productionProfile }) => {
1474
+ const context = readProductionContext(config.stateDir, productionPath);
1475
+ productionProfile = context?.productionProfile || productionProfile;
1476
+ provider ||= productionProfile ? "elevenlabs" : "typecast";
1477
+ topic ||= context?.topic || "";
1478
+ scriptExcerpt ||= context?.narrationScript?.slice(0, 2000) || "";
1406
1479
  const notes = [];
1407
1480
  let sampleAudioBase64 = "";
1408
1481
  const rawSample = String(sampleVideoUrl || "").trim();
@@ -1431,7 +1504,7 @@ export function registerCloudTools(server, config, api) {
1431
1504
  return textResult("topic, scriptExcerpt, sampleVoiceHints, sampleVideoUrl 중 하나는 필요합니다.", { isError: true });
1432
1505
  }
1433
1506
  const payload = await api.request("/api/tracker/tts/recommend-voice", {
1434
- body: { provider, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic },
1507
+ body: { provider, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic, productionProfile },
1435
1508
  method: "POST",
1436
1509
  timeoutMs: 180_000,
1437
1510
  });
@@ -1442,6 +1515,8 @@ export function registerCloudTools(server, config, api) {
1442
1515
  );
1443
1516
  }
1444
1517
  return jsonResult({
1518
+ productionPath: context || productionProfile ? saveProductionContext(config.stateDir, { ...context, productionProfile, voice: { ...payload?.recommendations?.[0], provider } }) : undefined,
1519
+ productionProfile,
1445
1520
  next: "recommendations[0].voiceId를 tts_narration 또는 tts_narration_with_captions의 voice로 사용하세요.",
1446
1521
  notes: [...notes, ...(Array.isArray(payload?.notes) ? payload.notes : [])],
1447
1522
  provider: payload?.provider || provider,
@@ -67,6 +67,7 @@ export async function triageCompanion(companionUrl) {
67
67
  detail: `moviepy ${moviepy.payload.moviepy.version} (${moviepy.payload.moviepy.label})`,
68
68
  state: "ready",
69
69
  version: String(health.payload?.version || ""),
70
+ capabilities: moviepy.payload?.capabilities || {},
70
71
  };
71
72
  }
72
73
 
@@ -7,6 +7,7 @@ import {
7
7
  triageCompanion,
8
8
  } from "./companion-client.mjs";
9
9
  import { detectRemoteEnvironment } from "./config.mjs";
10
+ import { durationReview, reviewProductionPayload } from "./production-context.mjs";
10
11
  import { readPayloadHandle, saveMediaBuffer } from "./media-store.mjs";
11
12
  import {
12
13
  checkRenderPayloadSize,
@@ -75,6 +76,16 @@ function computeRenderMismatches(summary) {
75
76
  export function registerCompanionTools(server, config) {
76
77
  const remoteEnv = detectRemoteEnvironment();
77
78
 
79
+ server.tool("review_production",
80
+ "타임라인의 요청 길이·강조·애니메이션·소재 배치를 무료로 사전 점검합니다. 음성 청취나 최종 프레임의 시각 검증을 대신하지 않습니다. 오류를 고친 다음 렌더하고 결과를 실제 확인하세요.",
81
+ { payloadPath: z.string() },
82
+ async ({ payloadPath }) => {
83
+ try {
84
+ const report = reviewProductionPayload(readPayloadHandle(payloadPath, { stateDir: config.stateDir }));
85
+ return textResult(JSON.stringify(report, null, 2), { isError: !report.pass });
86
+ } catch (error) { return textResult(String(error?.message || error), { isError: true }); }
87
+ });
88
+
78
89
  server.tool(
79
90
  "companion_status",
80
91
  "로컬 렌더 컴패니언(사용자 PC)의 상태를 진단합니다. 렌더 작업 전에 먼저 호출하세요.",
@@ -165,6 +176,14 @@ export function registerCompanionTools(server, config) {
165
176
  }
166
177
 
167
178
  const { payload: cleaned, warnings } = sanitizeRenderPayload(resolvedPayload);
179
+ const productionReview = reviewProductionPayload(cleaned);
180
+ if (!productionReview.pass) {
181
+ return textResult(JSON.stringify({ error: "PRODUCTION_SPEC_MISMATCH", ...productionReview }, null, 2), { isError: true });
182
+ }
183
+ const missingCapabilities = (cleaned.compositionReport?.requiredCapabilities || []).filter(name => triage.capabilities?.[name] !== true);
184
+ if (missingCapabilities.length) {
185
+ return textResult(JSON.stringify({ error: "COMPANION_UPDATE_REQUIRED", missingCapabilities, message: "이 타임라인의 강조/애니메이션/소재 렌더 기능을 지원하는 로컬 처리기 런타임으로 업데이트하세요. 기능을 무시하고 렌더하지 않습니다." }), { isError: true });
186
+ }
168
187
  const size = checkRenderPayloadSize(cleaned);
169
188
  if (!size.ok) {
170
189
  return textResult(
@@ -200,6 +219,7 @@ export function registerCompanionTools(server, config) {
200
219
  projectTitle: projectTitle || String(resolvedPayload?.projectTitle || ""),
201
220
  statusUrl: result.payload.statusUrl || "",
202
221
  });
222
+ updateRenderJob(config.stateDir, result.payload.jobId, { productionReview });
203
223
  } catch {
204
224
  // 영속화 실패는 무시
205
225
  }
@@ -209,6 +229,7 @@ export function registerCompanionTools(server, config) {
209
229
  next: "render_status로 진행률을 폴링하세요 (2~5초 간격 권장).",
210
230
  status: result.payload.status || "queued",
211
231
  warnings,
232
+ productionReview,
212
233
  }, null, 2));
213
234
  },
214
235
  );
@@ -290,6 +311,9 @@ export function registerCompanionTools(server, config) {
290
311
  // 영속화 실패는 무시
291
312
  }
292
313
  const mismatches = receiptSummary ? computeRenderMismatches(receiptSummary) : [];
314
+ const plannedReview = listRenderJobs(config.stateDir).find(job => job.jobId === jobId)?.productionReview;
315
+ const originalTargetMismatch = durationReview(plannedReview?.targetDurationSec, receiptSummary?.measured?.durationSec);
316
+ if (originalTargetMismatch) mismatches.push(originalTargetMismatch.message);
293
317
  return textResult(JSON.stringify({
294
318
  bytes: saved.bytes,
295
319
  filePath: saved.filePath,
@@ -297,7 +321,9 @@ export function registerCompanionTools(server, config) {
297
321
  mismatches,
298
322
  warning: "렌더 영수증 불일치 — 파일을 확인하고 필요하면 다시 렌더하세요.",
299
323
  } : {}),
300
- note: "파일은 이 PC에 저장되었습니다.",
324
+ productionReview: plannedReview,
325
+ qualityStatus: mismatches.length ? "spec-mismatch" : "needs-visual-and-listening-review",
326
+ note: "파일은 이 PC에 저장되었습니다. 최종 영상의 자막 크기·강조 색·소재 위치를 프레임으로 보고, TTS를 직접 들어보기 전에는 요청 스타일 검증 완료로 보고하지 마세요.",
301
327
  ...(receiptSummary ? { summary: receiptSummary } : {}),
302
328
  }, null, 2));
303
329
  },
@@ -0,0 +1,88 @@
1
+ import { z } from "zod";
2
+
3
+ const PRESETS_PATH = "/api/tracker/mcp/composer/presets";
4
+ const ASSETS_PATH = "/api/tracker/mcp/composer/assets";
5
+ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
6
+ const ASSET_TYPES = ["all", "image", "video", "gif", "icon", "emoji", "meme", "illustration", "sfx"];
7
+ const SENSITIVE_TOPIC = /폭력|피해|실신|마비|응급|사망|살인|성폭행|성폭력|학대|재난|추모|장례|폭행|살해|\b(?:violence|victim|death|murder|abuse|assault|disaster|funeral)\b/iu;
8
+
9
+ function jsonResult(value) {
10
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
11
+ }
12
+
13
+ function withCatalogAuth(config, handler) {
14
+ return async (args) => {
15
+ if (!config.pat) return { isError: true, content: [{ type: "text", text: "AIMAKEALL_PAT가 설정되지 않았습니다. aimakeall.com의 MCP 토큰을 설정하세요." }] };
16
+ try { return jsonResult(await handler(args)); }
17
+ catch (error) { return { isError: true, content: [{ type: "text", text: `라이브러리 요청 실패: ${String(error?.message || error)}` }] }; }
18
+ };
19
+ }
20
+
21
+ // Suggestions only: no media is downloaded, no payload is changed, no render or
22
+ // generation is started. The caller still decides whether each cue is relevant.
23
+ export async function recommendComposerOverlays(api, { subtitleLines, context = "", tone = "auto", type, limit = 4 }) {
24
+ const serious = tone === "serious" || SENSITIVE_TOPIC.test(`${context} ${subtitleLines.map((line) => line.text).join(" ")}`);
25
+ const selectedType = serious ? "icon" : type || "emoji";
26
+ const lines = subtitleLines
27
+ .filter((line) => Number.isFinite(line.startSec) && Number.isFinite(line.endSec) && line.startSec >= 0 && line.endSec > line.startSec && line.text.trim())
28
+ .sort((a, b) => a.startSec - b.startSec);
29
+ const selected = [];
30
+ for (const line of lines) {
31
+ if (selected.length && line.startSec < selected.at(-1).endSec) continue;
32
+ selected.push(line);
33
+ if (selected.length >= Math.min(limit, 6)) break;
34
+ }
35
+ const results = await Promise.all(selected.map(async (line) => {
36
+ const result = await api.request(ASSETS_PATH, { method: "POST", body: {
37
+ query: line.text, type: selectedType, source: serious ? "lucide" : "all", limit: 6,
38
+ }, timeoutMs: 30_000 });
39
+ return { line, assets: Array.isArray(result?.assets) ? result.assets : [] };
40
+ }));
41
+ const used = new Set();
42
+ const overlays = [];
43
+ const skipped = [];
44
+ for (const { line, assets } of results) {
45
+ const asset = assets.find((entry) => entry.assetId && entry.score > 0 && !used.has(entry.assetId)
46
+ && (selectedType === "meme" || !entry.requiresRightsReview));
47
+ if (!asset) { skipped.push({ startSec: line.startSec, text: line.text, reason: "관련성이 확인되는 새 자산이 없어 생략했습니다." }); continue; }
48
+ used.add(asset.assetId);
49
+ overlays.push({
50
+ assetId: asset.assetId, kind: asset.kind, url: asset.url, sourceUrl: asset.sourceUrl,
51
+ title: asset.title, source: asset.source, license: asset.license, attribution: asset.attribution,
52
+ requiresRightsReview: asset.requiresRightsReview,
53
+ startSec: line.startSec, endSec: Math.min(line.endSec, line.startSec + (serious ? 2 : 1.5)),
54
+ xPct: overlays.length % 2 ? 78 : 22, yPct: 55, widthPct: serious ? 14 : 18,
55
+ opacity: 100, animationIntroPreset: serious ? "fadeIn" : "popIn",
56
+ reason: `자막 '${line.text}'와 카탈로그 키워드가 일치합니다. 배경의 얼굴·중요 정보와 겹치는지 확인하세요.`,
57
+ });
58
+ }
59
+ return {
60
+ overlays, skipped, tone: serious ? "serious" : tone, automaticApplication: false,
61
+ nextTool: "stitch_timeline",
62
+ guidance: "관련성이 있는 제안만 선택해 stitch_timeline.overlays로 전달하세요. xPct/yPct는 화면 중앙 기준 좌표(0~100)이며 자막·얼굴을 가리지 않도록 프리뷰로 조정하세요. 라이선스와 출처 표기를 보존하세요.",
63
+ warnings: serious ? ["피해·폭력·재난 등 민감한 맥락에서는 조롱/웃음 밈을 추천하지 않고 설명용 아이콘만 검색했습니다."] : [],
64
+ };
65
+ }
66
+
67
+ export function registerComposerTools(server, config, api) {
68
+ server.tool("list_composer_presets",
69
+ "Cut Composer의 실제 제목·자막 디자인 프리셋과 애니메이션을 조회합니다(유료 생성 없음). 반환된 preset ID/style을 stitch_timeline의 제목·자막 설정에 사용하세요. 단일 기본 폰트 대신 요청 톤에 맞는 서로 다른 제목/자막 프리셋을 선택하고 가독성을 검토하세요.",
70
+ { query: z.string().max(500).optional(), category: z.string().max(80).optional(), limit: z.number().int().min(1).max(100).optional() },
71
+ READ_ONLY, withCatalogAuth(config, (args) => api.request(PRESETS_PATH, { method: "POST", body: args, timeoutMs: 30_000 })));
72
+
73
+ server.tool("search_composer_assets",
74
+ "Cut Composer 정적 라이브러리의 밈/GIF·일러스트·아이콘·이모지·효과음을 한국어/영어 키워드 또는 기존 assetIds로 검색합니다(유료 API 없음). assetId, kind, URL, 출처·라이선스를 반환합니다. 선택한 결과에 startSec/endSec 및 위치를 붙여 stitch_timeline.overlays로 사용하세요. source는 응답 sources의 ID만 가능하며 파일경로/외부 URL을 받지 않습니다. 권리 미확인 자산은 공개 전 확인하세요.",
75
+ {
76
+ query: z.string().max(500).optional(), type: z.enum(ASSET_TYPES).optional(), source: z.string().max(40).optional(),
77
+ limit: z.number().int().min(1).max(40).optional(),
78
+ assetIds: z.array(z.string().min(1).max(512)).min(1).max(40).optional().describe("검색 결과에서 받은 정확한 카탈로그 ID. 임의 URL/경로는 불가"),
79
+ }, READ_ONLY, withCatalogAuth(config, (args) => api.request(ASSETS_PATH, { method: "POST", body: args, timeoutMs: 30_000 })));
80
+
81
+ server.tool("recommend_composer_overlays",
82
+ "타임코드 자막에 맞는 라이브러리 오버레이와 짧은 등장 모션·위치를 무료 정적 검색으로 제안합니다. 자동 적용/렌더는 하지 않습니다. 민감한 사건은 설명용 아이콘만, 일반 콘텐츠는 이모지를 우선합니다. 관련 없는 결과는 생략하며 밈은 명시적으로 type=meme일 때만 검색합니다. 선택한 overlays를 stitch_timeline에 전달하세요.",
83
+ {
84
+ subtitleLines: z.array(z.object({ text: z.string().min(1).max(500), startSec: z.number().min(0), endSec: z.number().positive() }).refine((line) => line.endSec > line.startSec, "endSec는 startSec보다 커야 합니다.")).min(1).max(60),
85
+ context: z.string().max(1000).optional(), tone: z.enum(["auto", "serious", "playful"]).optional(),
86
+ type: z.enum(["icon", "emoji", "meme"]).optional(), limit: z.number().int().min(1).max(6).optional(),
87
+ }, READ_ONLY, withCatalogAuth(config, (args) => recommendComposerOverlays(api, args)));
88
+ }
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.10.0";
5
+ export const MCP_PROXY_VERSION = "0.11.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,61 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readPayloadHandle, savePayloadHandle } from "./media-store.mjs";
3
+
4
+ // Explicit immutable handles, never a process-global "last production" (parallel jobs).
5
+ export function saveProductionContext(stateDir, context = {}) {
6
+ const value = { ...context, kind: "aimakeall-production", version: 1, productionId: context.productionId || randomUUID() };
7
+ return savePayloadHandle(stateDir, value, "production").filePath;
8
+ }
9
+
10
+ export function readProductionContext(stateDir, productionPath) {
11
+ if (!productionPath) return null;
12
+ const value = readPayloadHandle(productionPath, { stateDir });
13
+ if (value?.kind !== "aimakeall-production" || value.version !== 1 || !value.productionId) {
14
+ throw new Error("productionPath는 plan_shorts_video 또는 TTS가 반환한 제작 핸들이어야 합니다.");
15
+ }
16
+ return value;
17
+ }
18
+
19
+ export function durationReview(target, actual) {
20
+ if (!(Number(target) > 0)) return null;
21
+ if (!(Number(actual) > 0)) return { code: "DURATION_UNMEASURED", message: "실제 길이를 확인할 수 없습니다." };
22
+ const toleranceSec = Math.max(2, Number(target) * 0.1);
23
+ return Math.abs(Number(actual) - Number(target)) > toleranceSec
24
+ ? { code: "DURATION_MISMATCH", message: `목표 ${target}초 / 실제 ${actual}초. 대본·말속도·컷 구성을 검토하세요. 무음이나 영상 늘이기로 목표를 맞추지 마세요.`, targetDurationSec: Number(target), actualDurationSec: Number(actual), toleranceSec }
25
+ : null;
26
+ }
27
+
28
+ export function reviewProductionPayload(payload, { measured = null } = {}) {
29
+ const context = payload?.productionContext;
30
+ const profile = context?.productionProfile || payload?.productionProfile;
31
+ const clips = Array.isArray(payload?.studio?.clips) ? payload.studio.clips : [];
32
+ const fps = Number(payload?.studio?.timeline?.fps || payload?.studio?.exportSettings?.fps || payload?.studio?.fps) || 30;
33
+ const actual = measured ? Number(measured.durationSec) : Math.max(0, ...clips.map(c => ((Number(c.startFrame) || 0) + (Number(c.durationFrames) || 0)) / fps));
34
+ const errors = [];
35
+ const warnings = [];
36
+ const duration = durationReview(profile?.targetDurationSec, actual);
37
+ if (duration) errors.push(duration);
38
+ if (context?.tts?.durationSec) {
39
+ const speechDuration = durationReview(profile?.targetDurationSec, context.tts.durationSec);
40
+ if (speechDuration) errors.push({ ...speechDuration, code: "NARRATION_DURATION_MISMATCH" });
41
+ }
42
+ const textClips = clips.filter(c => c.type === "text" || typeof c.text === "string");
43
+ const emphasisCount = textClips.filter(c => c.textSegments?.some(s => s.color || s.sizeScale > 1)).length;
44
+ const animationCount = clips.filter(c => [c.animationIntroPreset,c.animationEmphasisPreset,c.animationOutroPreset].some(v => v && v !== "none")).length;
45
+ const report = payload?.compositionReport || {};
46
+ if (profile?.deliveryStyle === "casual-social") {
47
+ if (!emphasisCount) warnings.push({ code: "NO_EMPHASIS", message: "강조 단어가 실제 자막과 일치하는지 확인하세요." });
48
+ if (!animationCount) warnings.push({ code: "NO_MOTION", message: "자막·타이틀의 등장/강조 애니메이션이 없습니다." });
49
+ if (!report.overlayCount) {
50
+ warnings.push({ code: "NO_LIBRARY_OVERLAY", message: context?.decorationRationale || "소재 검색 후 문맥에 맞는 아이콘/이모지/밈을 검토하세요. 사건·피해자를 희화화하지 마세요." });
51
+ if (!String(context?.decorationRationale || "").trim()) errors.push({ code: "DECORATION_REVIEW_REQUIRED", message: "라이브러리 소재를 검토하지 않았습니다. recommend_composer_overlays/search_composer_assets로 관련 소재를 선택하거나, 적합한 소재가 없어 생략했다면 stitch_timeline.decorationRationale에 이유를 기록하세요." });
52
+ }
53
+ }
54
+ return {
55
+ pass: errors.length === 0, errors, warnings,
56
+ targetDurationSec: profile?.targetDurationSec || null, actualDurationSec: actual,
57
+ emphasisCount, animationCount, overlayCount: report.overlayCount || 0,
58
+ visualReviewRequired: true, listeningReviewRequired: true,
59
+ note: "구조 검증만 수행했습니다. 최종 영상의 글자 크기·색상·가독성과 음성 말투를 직접 확인하기 전에는 품질 검증 완료로 보고하지 마세요.",
60
+ };
61
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+
3
+ export const productionFields = {
4
+ productionPath: z.string().optional().describe("plan_shorts_video / TTS가 반환한 제작 핸들. 이후 단계에도 반드시 이어서 전달해 스타일·대본·목표 길이를 유지하세요."),
5
+ productionProfile: z.object({
6
+ version: z.literal(1), categoryId: z.string(), tone: z.string().optional(),
7
+ deliveryStyle: z.enum(["casual-social", "standard", "news-briefing"]),
8
+ targetDurationSec: z.number().positive().max(180),
9
+ titleStylePresetId: z.string().optional(), subtitleStylePresetId: z.string().optional(),
10
+ }).optional().describe("제작 스타일 명시. 바이럴/커뮤니티는 casual-social. 핸들이 있으면 저장된 프로필을 사용합니다."),
11
+ };
12
+ export const composerTextStyle = z.record(z.any()).describe("list_composer_presets의 실제 style 속성. fontFamily, 색상, 배경, 외곽선, animationIntroPreset/EmphasisPreset/OutroPreset 등을 지원하며 서버가 검증합니다.");
13
+ export const composerOverlay = z.object({
14
+ assetId: z.string().optional().describe("search_composer_assets 결과의 정확한 assetId. URL 대신 권장"),
15
+ kind: z.enum(["image", "video", "gif", "audio", "sfx"]).optional(),
16
+ url: z.string().optional(), title: z.string().optional(),
17
+ startSec: z.number().min(0), endSec: z.number().positive(), sourceStartSec: z.number().min(0).optional(),
18
+ xPct: z.number().min(0).max(100).optional(), yPct: z.number().min(0).max(100).optional(),
19
+ widthPct: z.number().positive().max(100).optional(), heightPct: z.number().positive().max(100).optional(),
20
+ opacity: z.number().optional(), volume: z.number().optional(),
21
+ animationIntroPreset: z.string().optional(), animationEmphasisPreset: z.string().optional(), animationOutroPreset: z.string().optional(),
22
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimakeall-mcp",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "AImakeAll MCP 서버 — Claude Code/Codex에서 자연어로 영상 기획·생성·렌더·퍼블리시 (렌더는 로컬 컴패니언)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "index.mjs",
11
11
  "lib/",
12
+ "!lib/*.test.mjs",
12
13
  "README.md"
13
14
  ],
14
15
  "engines": {