aimakeall-mcp 0.10.0 → 0.12.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.
@@ -26,6 +26,11 @@ 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, visualProductionFields, sceneVisual, composerTextStyle, composerOverlay } from "./production-schemas.mjs";
31
+ import { saveProductionContext, readProductionContext, durationReview, reviewProductionPayload } from "./production-context.mjs";
32
+ import { TTS_PROVIDERS, buildTtsRequestBody, estimateTtsSubtitleLines, requestTtsBinary, resolveTtsSelection } from "./tts-workflow.mjs";
33
+ import { assertI2vAllowed, findSceneWebImage, resolveVisualSettings, sceneWebSearchQuery } from "./visual-production.mjs";
29
34
 
30
35
  // 여러 파일을 base64 로 싣는 요청의 인코딩 크기 합이 서버 JSON 상한을 넘지 않게 사전 검사.
31
36
  // 서버가 전체 업로드를 받은 뒤 413 을 내는 것을 막고 행동 가능한 한국어 안내를 준다.
@@ -162,6 +167,7 @@ function trimPlanScenes(scenes) {
162
167
  durationSec: scene?.durationSec,
163
168
  idx: scene?.idx,
164
169
  imagePrompt: scene?.imagePrompt,
170
+ webSearchQuery: sceneWebSearchQuery(scene),
165
171
  label: scene?.label,
166
172
  ratio: scene?.ratio,
167
173
  sceneNarration: scene?.sceneNarration,
@@ -180,11 +186,13 @@ function trimAnalysis(analysis) {
180
186
  }
181
187
 
182
188
  export function registerCloudTools(server, config, api) {
189
+ registerComposerTools(server, config, api);
183
190
  async function completedGenerationResult(job, includePreview) {
184
191
  const payload = job.result || {};
185
192
  const resultBody = { generationJobId: job.id, status: job.status, kind: job.kind,
186
193
  model: payload.model, taskId: payload.taskId, providerTasks: job.providerTasks };
187
194
  if (job.kind === "image") {
195
+ resultBody.source = "ai";
188
196
  resultBody.imageUrl = payload.imageUrl || undefined;
189
197
  let inlineImage = null;
190
198
  if (!payload.imageUrl && payload.imageDataUrl) {
@@ -281,10 +289,11 @@ export function registerCloudTools(server, config, api) {
281
289
  videoModel: z.string().optional().describe("기본 kie-grok-imagine"),
282
290
  videoSecondsPerScene: z.number().min(0).max(30).optional().describe("씬당 영상 초 (0이면 영상 미포함)"),
283
291
  videoQuality: z.string().optional().describe("기본 720p"),
284
- ttsProvider: z.enum(["typecast", "elevenlabs", "supertone"]).optional().describe("기본 typecast"),
292
+ ttsProvider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 계정별 자동 우선순위. Edge/Supertonic2는 TTS 공급자 비용 0"),
285
293
  ttsChars: z.number().int().min(0).max(20000).optional().describe("내레이션 글자 수"),
286
294
  },
287
295
  wrapCloudHandler(config, async (input) => {
296
+ if (input.ttsChars > 0) input = { ...input, ttsProvider: (await resolveTtsSelection(api, config, { provider: input.ttsProvider })).provider };
288
297
  const payload = await api.request("/api/tracker/cost/estimate", { body: input, method: "POST", timeoutMs: 30_000 });
289
298
  return jsonResult(payload);
290
299
  }),
@@ -294,6 +303,9 @@ export function registerCloudTools(server, config, api) {
294
303
  "plan_shorts_video",
295
304
  "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조).",
296
305
  {
306
+ productionProfile: productionFields.productionProfile,
307
+ ...visualProductionFields,
308
+ durationTargetSec: z.number().positive().max(180).optional().describe("사용자가 요청한 목표 길이(예:40). 음성 길이로 덮어쓰지 않습니다."),
297
309
  topic: z.string().optional().describe("영상 주제"),
298
310
  copy: z.string().optional().describe("핵심 카피/대사"),
299
311
  categoryId: z.enum(["community-shorts", "viral-shorts", "insight"]).optional().describe("기본 community-shorts. insight=명언(통찰) 쇼츠"),
@@ -322,13 +334,17 @@ export function registerCloudTools(server, config, api) {
322
334
  forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
323
335
  })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
324
336
  },
325
- wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 0, characters = [], channelFingerprint = null }) => {
337
+ wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 0, characters = [], channelFingerprint = null, productionProfile, durationTargetSec, imageSourceMode, visualMotionMode }) => {
326
338
  if (!String(topic).trim() && !String(copy).trim()) {
327
339
  return textResult("topic 또는 copy 중 하나는 입력해야 합니다.", { isError: true });
328
340
  }
341
+ const visualSettings = resolveVisualSettings(null, { productionProfile, imageSourceMode, visualMotionMode });
329
342
  const payload = await api.request("/api/tracker/shorts-video/plan", {
330
343
  body: {
344
+ ...visualSettings,
331
345
  categoryId,
346
+ productionProfile,
347
+ durationTargetSec,
332
348
  channelFingerprint,
333
349
  copy,
334
350
  operationId: createUsageEventId("shorts-plan"),
@@ -343,7 +359,18 @@ export function registerCloudTools(server, config, api) {
343
359
  method: "POST",
344
360
  timeoutMs: 240_000,
345
361
  });
362
+ const productionPath = saveProductionContext(config.stateDir, {
363
+ topic, visualSettings, autoCompose: productionProfile?.autoComposeEnabled ?? true,
364
+ productionProfile: payload?.productionProfile, narrationScript: payload?.narrationScript,
365
+ narrationSource: payload?.narrationSource, tagline: payload?.tagline, emphasisKeywords: payload?.emphasisKeywords || [],
366
+ channelFingerprint, scenes: trimPlanScenes(payload?.scenes),
367
+ });
346
368
  return jsonResult({
369
+ productionPath,
370
+ ...visualSettings,
371
+ productionProfile: payload?.productionProfile,
372
+ narrationSource: payload?.narrationSource,
373
+ next: `productionPath를 이미지 준비·보이스 추천·TTS·스티치에 계속 전달하세요. ${visualSettings.visualMotionMode === "image-motion" ? "I2V 프롬프트/영상 생성은 생략하고 이미지 URL을 stitch_timeline.sceneVisuals(kind=image)에 넣으세요." : "이미지 확인 후 I2V 영상 생성으로 진행하세요."} 대본을 뉴스체로 바꾸지 말고 컷컴포저 소재도 활용하세요.`,
347
374
  charactersUsed: Array.isArray(payload?.charactersUsed) ? payload.charactersUsed : undefined,
348
375
  ctaText: payload?.ctaText,
349
376
  emphasisKeywords: Array.isArray(payload?.emphasisKeywords) ? payload.emphasisKeywords : [],
@@ -438,6 +465,8 @@ export function registerCloudTools(server, config, api) {
438
465
  "plan_commerce_video",
439
466
  "제품 홍보 영상 기획을 생성합니다. 제품 사진 파일 경로가 최소 1장 필요합니다 (이 PC의 로컬 경로).",
440
467
  {
468
+ ...visualProductionFields,
469
+ productionProfile: productionFields.productionProfile,
441
470
  productName: z.string().describe("제품명 (필수)"),
442
471
  productImagePaths: z.array(z.string()).min(1).describe("제품/자산 사진의 로컬 파일 경로 (1장 이상)"),
443
472
  modelImagePath: z.string().optional().describe("모델 사진 로컬 경로 (선택)"),
@@ -455,7 +484,8 @@ export function registerCloudTools(server, config, api) {
455
484
  forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
456
485
  })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
457
486
  },
458
- wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" , characters = [] }) => {
487
+ wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" , characters = [], productionProfile, imageSourceMode, visualMotionMode }) => {
488
+ const visualSettings = resolveVisualSettings(null, { productionProfile, imageSourceMode, visualMotionMode });
459
489
  // 확장자 검증(임의 파일 업로드 차단) + 합산 크기 예산(서버 413 사전 차단).
460
490
  for (const filePath of productImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "이미지" });
461
491
  if (modelImagePath) assertAllowedInputFile(modelImagePath, ALLOWED_IMAGE_EXTS, { kind: "모델 이미지" });
@@ -463,6 +493,7 @@ export function registerCloudTools(server, config, api) {
463
493
 
464
494
  const payload = await api.request("/api/tracker/commerce-video/plan", {
465
495
  body: {
496
+ ...visualSettings, productionProfile,
466
497
  categoryId,
467
498
  description,
468
499
  modelImage: modelImagePath ? fileToImagePayload(modelImagePath) : null,
@@ -480,7 +511,14 @@ export function registerCloudTools(server, config, api) {
480
511
  method: "POST",
481
512
  timeoutMs: 300_000,
482
513
  });
514
+ const productionPath = saveProductionContext(config.stateDir, {
515
+ topic: productName, visualSettings, autoCompose: productionProfile?.autoComposeEnabled ?? true,
516
+ productionProfile: payload?.productionProfile || productionProfile,
517
+ tagline: payload?.tagline, narrationScript: payload?.narrationScript,
518
+ scenes: trimPlanScenes(payload?.scenes), featureKey: "commerceVideo",
519
+ });
483
520
  return jsonResult({
521
+ productionPath, ...visualSettings, productionProfile: payload?.productionProfile || productionProfile,
484
522
  analysis: trimAnalysis(payload?.analysis),
485
523
  ctaText: payload?.ctaText,
486
524
  ok: payload?.ok,
@@ -493,8 +531,13 @@ export function registerCloudTools(server, config, api) {
493
531
 
494
532
  server.tool(
495
533
  "generate_scene_image",
496
- "씬 이미지 생성 작업을 접수하고 즉시 generationJobId를 반환합니다. get_generation_job으로 completed 결과의 imageUrl을 받은 뒤 씬 영상 입력에 쓰세요. 첫 씬/캐릭터/제품 이미지를 referenceImageUrls·referenceImagePaths로 전달하면 일관성 유지에 도움이 됩니다. 완료 이미지는 직접 확인하거나 verify_scene_image로 검증하세요.",
534
+ "선택한 방식으로 장면 이미지를 준비합니다. productionPath의 web은 사용자 키로 Pexels/Pixabay 검색만, ai는 AI 생성만, mixed는 웹 검색 후 없을 때 AI 생성합니다. 웹 결과는 즉시 imageUrl·출처를 반환하며 AI만 generationJobId를 get_generation_job으로 조회합니다. image-motion이면 I2V를 생략하고 sceneVisual을 스티치에 사용하세요.",
497
535
  {
536
+ ...productionFields,
537
+ ...visualProductionFields,
538
+ sceneIdx: z.number().int().min(0).optional().describe("기획 장면 idx. 저장된 장면 검색어와 출처를 연결합니다."),
539
+ webSearchQuery: z.string().max(160).optional().describe("장면에 맞는 짧은 웹 검색 키워드. plan.scenes[].webSearchQuery를 사용하세요."),
540
+ excludeImageUrls: z.array(z.string()).max(100).optional().describe("이미 사용한 웹 이미지 URL. 반복 장면을 피합니다."),
498
541
  requestId: z.string().optional().describe("접수 응답 유실 후 같은 작업을 재확인할 때 기존 generationJobId를 지정합니다. 동일 ID·입력은 중복 생성하지 않습니다."),
499
542
  prompt: z.string().describe("이미지 프롬프트 (plan 결과의 imagePrompt)"),
500
543
  aspectRatio: z.string().optional().describe("기본 9:16"),
@@ -505,7 +548,46 @@ export function registerCloudTools(server, config, api) {
505
548
  referenceImageUrls: z.array(z.string()).max(6).optional().describe("참조 이미지 URL (씬1 앵커 등)"),
506
549
  referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 이미지 로컬 경로 (제품 사진 등)"),
507
550
  },
508
- wrapCloudHandler(config, async ({ requestId, prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true, identityLock = "" }) => {
551
+ wrapCloudHandler(config, async ({ requestId, prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true, identityLock = "", productionPath, productionProfile, imageSourceMode, visualMotionMode, sceneIdx = 0, webSearchQuery, excludeImageUrls = [] }) => {
552
+ const context = readProductionContext(config.stateDir, productionPath);
553
+ const visualSettings = resolveVisualSettings(context, { productionProfile, imageSourceMode, visualMotionMode });
554
+ productionProfile = context?.productionProfile || productionProfile;
555
+ const plannedScene = context?.scenes?.find((entry) => entry.idx === sceneIdx) || {};
556
+ const saveSource = (source) => saveProductionContext(config.stateDir, {
557
+ ...context, productionProfile, visualSettings,
558
+ sceneSources: [...(context?.sceneSources || []).filter((entry) => entry.idx !== sceneIdx), { ...source, idx: sceneIdx }],
559
+ });
560
+ let fallbackReason;
561
+ if (visualSettings.imageSourceMode !== "ai") {
562
+ // A retry of a possibly accepted paid job must recover it before another
563
+ // stock selection can hide it. Unknown IDs continue the original flow.
564
+ if (requestId) {
565
+ try {
566
+ const job = await fetchGenerationJob(api, requestId);
567
+ if (job.kind !== "image") throw new Error("requestId는 이미지 생성 작업 ID여야 합니다. 영상 작업은 get_generation_job으로 조회하세요.");
568
+ return job.status === "completed" ? completedGenerationResult(job, returnImage)
569
+ : jsonResult({ generationJobId: job.id, status: job.status, kind: job.kind, existing: true, nextTool: "get_generation_job" });
570
+ } catch (error) { if (Number(error?.status) !== 404) throw error; }
571
+ }
572
+ const query = webSearchQuery || sceneWebSearchQuery(plannedScene) || sceneWebSearchQuery({ imagePrompt: prompt });
573
+ const selected = await findSceneWebImage(api, {
574
+ query,
575
+ excludeImageUrls: [...excludeImageUrls, ...(context?.sceneSources || []).filter((entry) => entry.idx !== sceneIdx).map((entry) => entry.url).filter(Boolean)],
576
+ });
577
+ if (selected) {
578
+ const visual = { ...selected, idx: sceneIdx, label: plannedScene.label || selected.label, durationSec: Number(plannedScene.durationSec) || 6 };
579
+ return jsonResult({ ok: true, status: "completed", ...visualSettings, source: "web", imageUrl: selected.url,
580
+ sceneVisual: visual, sourcePage: selected.sourcePage, attribution: selected.attribution, license: selected.license,
581
+ productionPath: saveSource(visual),
582
+ guidance: visualSettings.visualMotionMode === "image-motion" ? "sceneVisual을 stitch_timeline.sceneVisuals에 넣으세요. I2V 호출은 하지 않습니다." : "웹 이미지의 내용·출처를 확인한 뒤 I2V 입력으로 사용하세요. 생성 영상에도 이 출처 정보를 보존하세요.",
583
+ });
584
+ }
585
+ if (visualSettings.imageSourceMode === "web") {
586
+ return { ...jsonResult({ ok: false, code: "WEB_IMAGE_NOT_FOUND", ...visualSettings,
587
+ message: "사용 가능한 새 웹 이미지를 찾지 못했습니다. 스톡 검색 키·검색어를 확인하거나 직접 이미지를 선택하세요. 웹만 선택했으므로 AI 생성은 실행하지 않았습니다." }), isError: true };
588
+ }
589
+ fallbackReason = "혼합 모드: 사용 가능한 새 웹 이미지가 없어 선택한 AI 생성으로 진행합니다.";
590
+ }
509
591
  // identity_lock 리터럴 부착 — 이미 포함돼 있으면 재부착하지 않는다(멱등: 프롬프트 캐시 보존).
510
592
  const lock = String(identityLock || "").trim();
511
593
  if (lock && !prompt.includes(lock)) prompt = `${prompt.trim()}, ${lock}`;
@@ -519,18 +601,22 @@ export function registerCloudTools(server, config, api) {
519
601
  ...referenceImageUrls.map((url, index) => ({ name: `ref-${index + 1}`, previewUrl: url })),
520
602
  ].slice(0, 6);
521
603
 
522
- return jsonResult(await submitGenerationJob(api, {
604
+ const job = await submitGenerationJob(api, {
523
605
  kind: "image", includePreview: returnImage, requestId,
524
606
  input: {
607
+ productionProfile, ...visualSettings,
525
608
  aspectRatio,
526
609
  model,
527
610
  prompt,
528
611
  referenceImages,
529
- referenceSearchEnabled: true,
612
+ referenceSearchEnabled: false,
530
613
  resolution,
531
- webSearchEnabled: true,
614
+ webSearchEnabled: false,
532
615
  },
533
- }));
616
+ });
617
+ return jsonResult({ ...job, ...visualSettings, source: "ai", fallbackReason,
618
+ productionPath: saveSource({ source: "ai", generationJobId: job.generationJobId }),
619
+ });
534
620
  }),
535
621
  );
536
622
 
@@ -538,6 +624,8 @@ export function registerCloudTools(server, config, api) {
538
624
  "generate_scene_video_prompt",
539
625
  "씬 이미지 기반 영상 프롬프트를 생성합니다. 결과 videoPrompt를 generate_scene_video의 prompt로 넘기세요.",
540
626
  {
627
+ ...productionFields,
628
+ ...visualProductionFields,
541
629
  imagePrompt: z.string().describe("씬의 이미지 프롬프트"),
542
630
  sceneImageUrl: z.string().optional().describe("generate_scene_image가 반환한 imageUrl"),
543
631
  sceneScript: z.string().optional().describe("씬 대사/설명 (plan 결과의 label)"),
@@ -548,9 +636,13 @@ export function registerCloudTools(server, config, api) {
548
636
  kind: z.enum(["shorts_scene", "commerce_video_scene", "music_video_scene"]).optional(),
549
637
  modelId: z.string().optional().describe("대상 영상 모델 (기본 kie-grok-imagine — generate_scene_video와 동일하게 맞추세요)"),
550
638
  },
551
- wrapCloudHandler(config, async ({ imagePrompt, sceneImageUrl = "", sceneScript = "", sceneTitle = "", aspectRatio = "9:16", durationSec = 8, videoStylePreset = "", kind = "shorts_scene", modelId = "kie-grok-imagine" }) => {
639
+ wrapCloudHandler(config, async ({ imagePrompt, sceneImageUrl = "", sceneScript = "", sceneTitle = "", aspectRatio = "9:16", durationSec = 8, videoStylePreset = "", kind = "shorts_scene", modelId = "kie-grok-imagine", productionPath, productionProfile, imageSourceMode, visualMotionMode }) => {
640
+ const context = readProductionContext(config.stateDir, productionPath);
641
+ const visualSettings = resolveVisualSettings(context, { productionProfile, imageSourceMode, visualMotionMode });
642
+ assertI2vAllowed(visualSettings);
552
643
  const payload = await api.request("/api/tracker/gemini/storyboard-video-prompt", {
553
644
  body: {
645
+ productionProfile: context?.productionProfile || productionProfile, ...visualSettings,
554
646
  aspectRatio,
555
647
  durationLabel: `${Math.max(1, Math.round(durationSec))}s`,
556
648
  generationPlan: {
@@ -579,6 +671,8 @@ export function registerCloudTools(server, config, api) {
579
671
  "generate_scene_video",
580
672
  "씬 영상 생성 작업을 접수하고 즉시 generationJobId를 반환합니다. get_generation_job으로 completed가 될 때까지 조회한 뒤 videoUrl을 stitch_timeline에 넣으세요. 접수만 된 상태에서 새로 생성하지 마세요.",
581
673
  {
674
+ ...productionFields,
675
+ ...visualProductionFields,
582
676
  requestId: z.string().optional().describe("접수 응답 유실 후 동일 입력을 재접수할 때 기존 generationJobId를 지정합니다."),
583
677
  prompt: z.string().describe("영상 프롬프트 (generate_scene_video_prompt의 videoPrompt)"),
584
678
  sceneImageUrl: z.string().optional().describe("씬 이미지 URL (i2v 입력)"),
@@ -588,10 +682,14 @@ export function registerCloudTools(server, config, api) {
588
682
  quality: z.string().optional().describe("기본 720p"),
589
683
  returnFrames: z.boolean().optional().describe("기본 true — 결과 영상의 대표 프레임 3장을 직접 보고 QC 할 수 있게 이미지 블록으로 반환"),
590
684
  },
591
- wrapCloudHandler(config, async ({ requestId, prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p", returnFrames = true }) => {
685
+ wrapCloudHandler(config, async ({ requestId, prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p", returnFrames = true, productionPath, productionProfile, imageSourceMode, visualMotionMode }) => {
686
+ const context = readProductionContext(config.stateDir, productionPath);
687
+ const visualSettings = resolveVisualSettings(context, { productionProfile, imageSourceMode, visualMotionMode });
688
+ assertI2vAllowed(visualSettings);
592
689
  return jsonResult(await submitGenerationJob(api, {
593
690
  kind: "video", includePreview: returnFrames, requestId,
594
691
  input: {
692
+ productionProfile: context?.productionProfile || productionProfile, ...visualSettings,
595
693
  aspectRatio,
596
694
  durationLabel: `${Math.max(1, Math.ceil(durationSec))}s`,
597
695
  generationPlan: {
@@ -613,55 +711,41 @@ export function registerCloudTools(server, config, api) {
613
711
 
614
712
  server.tool(
615
713
  "tts_narration",
616
- "내레이션 TTS를 합성해 이 PC에 mp3로 저장하고 파일 경로를 반환합니다. stitch_timeline의 ttsAudioPath로 쓰세요. 사용자가 보이스를 직접 지정하지 않았다면 먼저 recommend_voice로 주제·샘플 영상에 맞는 보이스를 자동 선정해 voice에 넣으세요.",
714
+ "내레이션 TTS를 합성해 이 PC에 오디오 파일로 저장합니다. 자동 순서: Typecast → ElevenLabs → Supertone → Edge TTS → Supertonic2. 키/런타임이 없을 때 다음 공급자를 고려하며, 합성 실패 후 다른 유료 공급자로 재시도하지 않습니다. 먼저 recommend_voice로 제작 톤에 맞는 보이스를 선택하세요.",
617
715
  {
618
- text: z.string().describe("내레이션 텍스트"),
619
- provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
716
+ ...productionFields,
717
+ text: z.string().optional().describe("생략 시 productionPath의 기획 대본"),
718
+ provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 명시 선택/저장된 보이스는 유지"),
620
719
  voice: z.string().optional().describe("보이스 이름(라벨)"),
621
720
  speed: z.number().optional().describe("기본 1"),
721
+ stability: z.number().min(0).max(1).optional(), style: z.number().min(0).max(1).optional(),
722
+ modelId: z.string().optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
622
723
  },
623
- wrapCloudHandler(config, async ({ text, provider = "typecast", voice = "", speed = 1 }) => {
624
- const body = provider === "typecast"
625
- ? {
626
- breath: 0.3,
627
- costEventId: createUsageEventId("mcp-tts-typecast"),
628
- emotion: "neutral",
629
- fileName: "mcp-narration",
630
- language: "kor",
631
- modelId: "",
632
- pitch: 0,
633
- smartEmotion: true,
634
- speed,
635
- text,
636
- voice,
637
- volume: 100,
638
- }
639
- : {
640
- costEventId: createUsageEventId("mcp-tts-elevenlabs"),
641
- fileName: "mcp-narration",
642
- languageCode: "ko",
643
- modelId: "",
644
- similarityBoost: 0.8,
645
- speakerBoost: true,
646
- speed,
647
- stability: 0.45,
648
- style: 0.35,
649
- text,
650
- voice,
651
- };
652
- const result = await api.requestBinary(`/api/tracker/tts/${provider}`, {
653
- body,
654
- method: "POST",
655
- timeoutMs: 120_000,
656
- });
657
- const saved = saveMediaBuffer(config.stateDir, "narration.mp3", result.buffer, { contentType: result.contentType });
724
+ wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, stability, style, modelId, similarityBoost, speakerBoost }) => {
725
+ const context = readProductionContext(config.stateDir, productionPath);
726
+ productionProfile = context?.productionProfile || productionProfile;
727
+ text ??= context?.narrationScript;
728
+ const selection = await resolveTtsSelection(api, config, { provider, context });
729
+ provider = selection.provider;
730
+ if (!voice && context?.voice?.provider && context.voice.provider !== provider) return textResult("보이스 공급자가 다릅니다. 선택한 provider용 voice를 다시 선정하세요.", { isError: true });
731
+ voice ||= context?.voice?.voiceId || "";
732
+ if (!text?.trim()) return textResult("대본(text/productionPath)이 필요합니다.", { isError: true });
733
+ if (productionProfile?.deliveryStyle === "casual-social" && !voice) return textResult("먼저 recommend_voice에 productionPath를 전달해 커뮤니티형 보이스를 선정하세요.", { isError: true });
734
+ const body = buildTtsRequestBody(provider, { productionProfile, costEventId: createUsageEventId(`mcp-tts-${provider}`), modelId, similarityBoost, speakerBoost, speed, stability, style, text, voice });
735
+ const result = await requestTtsBinary(api, config, selection, body);
736
+ const saved = saveMediaBuffer(config.stateDir, provider === "supertonic2" ? "narration.wav" : "narration.mp3", result.buffer, { contentType: result.contentType });
737
+ const durationSec = audioDurationSecFromFile(saved.filePath);
658
738
  return jsonResult({
739
+ productionPath: saveProductionContext(config.stateDir, { ...context, productionProfile, narrationScript: text, tts: { provider, filePath: saved.filePath, durationSec, voiceId: voice } }),
740
+ provider,
741
+ executionTarget: selection.executionTarget,
742
+ durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec),
659
743
  bytes: saved.bytes,
660
744
  // 실측 mp3 길이 — stitch_timeline 의 audioDurationSec 로 그대로 쓰면 타임라인이 정확하다.
661
- durationSec: audioDurationSecFromFile(saved.filePath),
745
+ durationSec,
662
746
  filePath: saved.filePath,
663
747
  voiceName: decodeHeaderValue(
664
- result.headers.get("x-typecast-voice-name") || result.headers.get("x-elevenlabs-voice-name") || "",
748
+ result.headers.get("x-typecast-voice-name") || result.headers.get("x-elevenlabs-voice-name") || result.headers.get("x-supertone-voice-name") || result.headers.get("x-edge-tts-voice-name") || result.headers.get("x-supertonic2-voice-name") || "",
665
749
  ),
666
750
  });
667
751
  }),
@@ -735,14 +819,23 @@ export function registerCloudTools(server, config, api) {
735
819
 
736
820
  server.tool(
737
821
  "stitch_timeline",
738
- "씬 영상들과 오디오를 서버에서 타임라인 매니페스트로 합칩니다. 결과는 파일 핸들(payloadPath)로 반환되며, 이를 render_start에 넘기면 이 PC에서 mp4가 렌더됩니다. 서버는 렌더하지 않습니다. 하단자막 강조: emphasisKeywords 를 주면 자막 라인 안의 해당 단어만 다른 색/크기로 강조되고, subtitleLines[].style 로 라인별 디자인도 바꿀 수 있습니다.",
822
+ "씬 이미지/영상과 오디오를 타임라인 매니페스트로 합칩니다. image-motion은 sceneVisuals(kind=image)에 인스펙터 imageMotionPreset/Intensity를 적용해 I2V 없이 렌더합니다. autoCompose 기본 true는 문맥별 모션·자막 디자인·적합한 라이브러리 소재를 배치하며 명시 편집을 우선합니다. 결과 payloadPath를 로컬 render_start에 넘기세요.",
739
823
  {
824
+ ...productionFields,
825
+ ...visualProductionFields,
826
+ autoCompose: z.boolean().optional().describe("기본 true. 실제 인스펙터 모션·자막 계층·문맥별 라이브러리 자동 편집. false는 수동 편집을 유지합니다."),
827
+ sceneVisuals: z.array(sceneVisual).min(1).max(200).optional().describe("장면 이미지/영상과 출처 메타데이터. sceneVideos와 동시에 전달하지 마세요."),
828
+ titleStylePresetId: z.string().optional(), subtitleStylePresetId: z.string().optional(),
829
+ titleStyle: composerTextStyle.optional(), subtitleStyle: composerTextStyle.optional(),
830
+ titleSegments: z.array(z.object({ text: z.string(), color: z.string().optional() })).max(20).optional(),
831
+ overlays: z.array(composerOverlay).max(40).optional().describe("검색한 소재를 장면에 맞는 시간/위치에 배치. 배경 이미지에 그려진 문양은 편집 소재 사용으로 세지 않습니다."),
832
+ decorationRationale: z.string().max(500).optional().describe("소재를 생략한 이유. 피해자 희화화·권리 위험이면 밈 대신 중립적 아이콘을 검토하세요."),
740
833
  sceneVideos: z.array(z.object({
741
834
  durationSec: z.number(),
742
835
  idx: z.number().int(),
743
836
  label: z.string().optional(),
744
837
  url: z.string(),
745
- })).min(1).describe("씬 순서대로 idx=0부터. url은 generate_scene_video의 videoUrl"),
838
+ })).min(1).optional().describe("구버전 영상 전용 입력. 새 제작은 sceneVisuals를 권장합니다. image-motion에서는 사용할 수 없습니다."),
746
839
  featureKey: z.enum(["shortsVideo", "commerceVideo", "musicVideo"]).optional().describe("기본 commerceVideo"),
747
840
  projectTitle: z.string().optional(),
748
841
  aspectRatio: z.string().optional().describe("기본 9:16"),
@@ -756,17 +849,8 @@ export function registerCloudTools(server, config, api) {
756
849
  endSec: z.number(),
757
850
  startSec: z.number(),
758
851
  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("이 라인만의 디자인 오버라이드 (내용에 따라 라인별 디자인을 달리할 때)"),
852
+ stylePresetId: z.string().optional(),
853
+ style: composerTextStyle.optional(),
770
854
  })).optional().describe("자막 타이밍 (쇼츠 하단자막) — tts_narration_with_captions의 subtitleLines를 그대로 사용"),
771
855
  emphasisKeywords: z.array(z.string()).max(12).optional().describe("강조 단어 목록 — 자막 라인 안에서 해당 단어만 다른 색/크기로 렌더 (plan_shorts_video의 emphasisKeywords 를 그대로 전달)"),
772
856
  emphasisColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional().describe("강조 색 #RRGGBB (기본 #facc15 노랑)"),
@@ -778,7 +862,23 @@ export function registerCloudTools(server, config, api) {
778
862
  }).passthrough().optional().describe("measure_channel_spec 이 반환한 fingerprint — 자막 세로 위치·BGM 유무를 참고 채널에 맞춥니다"),
779
863
  transitionMode: z.enum(["fade", "none"]).optional().describe("기본 none(하드컷). fade 는 씬이 맞대기로 붙는 배치에서 컷마다 검은 딥을 만든다 — 명시 요청 시에만 쓸 것"),
780
864
  },
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 }) => {
865
+ wrapCloudHandler(config, async ({ sceneVideos, sceneVisuals, autoCompose, imageSourceMode, visualMotionMode, 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 }) => {
866
+ const context = readProductionContext(config.stateDir, productionPath);
867
+ productionProfile = context?.productionProfile || productionProfile;
868
+ const visualSettings = resolveVisualSettings(context, { productionProfile, imageSourceMode, visualMotionMode });
869
+ if ((!sceneVisuals?.length && !sceneVideos?.length) || (sceneVisuals?.length && sceneVideos?.length)) {
870
+ return textResult("sceneVisuals 또는 구버전 sceneVideos 중 하나만 전달하세요.", { isError: true });
871
+ }
872
+ if (visualSettings.visualMotionMode === "image-motion" && (sceneVideos?.length || sceneVisuals.some((scene) => scene.kind !== "image"))) {
873
+ return textResult("IMAGE_MOTION_ONLY: 이미지 모션 제작에는 sceneVisuals(kind=image)만 사용할 수 있습니다.", { isError: true });
874
+ }
875
+ autoCompose ??= context?.autoCompose ?? productionProfile?.autoComposeEnabled ?? true;
876
+ ttsAudioPath ||= context?.tts?.filePath || "";
877
+ titleText ||= context?.tagline || "";
878
+ subtitleLines ??= context?.tts?.subtitleLines || [];
879
+ emphasisKeywords ??= context?.emphasisKeywords || [];
880
+ featureKey ||= context?.featureKey || (productionProfile?.categoryId?.endsWith("shorts") ? "shortsVideo" : "commerceVideo");
881
+ channelFingerprint ||= context?.channelFingerprint;
782
882
  // 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
783
883
  if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
784
884
  if (bgmAudioPath) assertAllowedInputFile(bgmAudioPath, new Set([".mp3", ".wav"]), { kind: "BGM 오디오" });
@@ -788,6 +888,8 @@ export function registerCloudTools(server, config, api) {
788
888
  ? audioDurationSec
789
889
  : (ttsAudioPath ? audioDurationSecFromFile(ttsAudioPath) : 0);
790
890
  const body = {
891
+ ...visualSettings, autoCompose,
892
+ productionProfile, titleStylePresetId, subtitleStylePresetId, titleStyle, subtitleStyle, titleSegments, overlays,
791
893
  aspectRatio,
792
894
  audioDurationSec: resolvedAudioDurationSec,
793
895
  bgmAudioUrl: bgmAudioPath ? fileToDataUrl(bgmAudioPath) : "",
@@ -795,12 +897,14 @@ export function registerCloudTools(server, config, api) {
795
897
  featureKey,
796
898
  muteVideoAudio,
797
899
  projectTitle,
798
- sceneVideos: sceneVideos.map((scene, index) => ({
900
+ ...(sceneVisuals ? { sceneVisuals: sceneVisuals.map((scene, index) => ({
901
+ ...scene, idx: index, label: scene.label || `scene-${index + 1}`,
902
+ })) } : { sceneVideos: sceneVideos.map((scene, index) => ({
799
903
  durationSec: scene.durationSec,
800
904
  idx: index,
801
905
  label: scene.label || `scene-${index + 1}`,
802
906
  url: scene.url,
803
- })),
907
+ })) }),
804
908
  emphasisColor,
805
909
  emphasisKeywords,
806
910
  channelFingerprint,
@@ -818,11 +922,19 @@ export function registerCloudTools(server, config, api) {
818
922
  if (!response?.ok || !response?.payload) {
819
923
  return textResult(`스티치 실패: ${response?.error || "매니페스트가 비어 있습니다."}`, { isError: true });
820
924
  }
925
+ const autoComposition = response.payload.compositionReport?.autoComposition;
926
+ if (!decorationRationale && autoComposition?.enabled && autoComposition?.omissions?.length) {
927
+ decorationRationale = autoComposition.omissions.map((item) => typeof item === "string" ? item : item?.reason || item?.message || "").filter(Boolean).join(" ").slice(0, 500);
928
+ }
929
+ if (context || productionProfile) response.payload.productionContext = { ...context, productionProfile, visualSettings, decorationRationale };
930
+ const qualityReview = reviewProductionPayload(response.payload);
821
931
  const handle = savePayloadHandle(config.stateDir, response.payload, "stitch");
822
932
  return jsonResult({
823
933
  assetCount: Array.isArray(response.payload.assets) ? response.payload.assets.length : 0,
824
934
  clipCount: Array.isArray(response.payload.studio?.clips) ? response.payload.studio.clips.length : 0,
825
- next: "render_start에 payloadPath를 넘겨 이 PC에서 렌더하세요.",
935
+ next: qualityReview.pass ? "qualityReview 경고를 검토한 뒤 render_start에 payloadPath를 전달하세요. 최종 영상 프레임과 음성도 검토해야 합니다." : "qualityReview.errors의 길이·소재 선택 검토 문제를 먼저 해결하세요. render_start는 제작 규격 불일치를 차단합니다.",
936
+ qualityReview,
937
+ compositionReport: response.payload.compositionReport,
826
938
  payloadPath: handle.filePath,
827
939
  projectTitle: response.payload.projectTitle,
828
940
  sizeBytes: handle.bytes,
@@ -1114,6 +1226,8 @@ export function registerCloudTools(server, config, api) {
1114
1226
  "plan_music_video",
1115
1227
  "뮤직비디오 씬 플랜을 생성합니다. suno_music_download로 받은 곡의 가사·길이(durationSec)를 입력하세요. 결과 scenes의 imagePrompt는 generate_scene_image로 이어집니다.",
1116
1228
  {
1229
+ ...visualProductionFields,
1230
+ productionProfile: productionFields.productionProfile,
1117
1231
  durationSec: z.number().min(10).describe("곡 길이(초) — suno_music_download 결과의 durationSec"),
1118
1232
  lyrics: z.string().optional().describe("가사 전체 (instrumental이면 생략 가능)"),
1119
1233
  conceptBrief: z.string().optional().describe("영상 컨셉 브리프"),
@@ -1130,12 +1244,14 @@ export function registerCloudTools(server, config, api) {
1130
1244
  })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
1131
1245
  aspectRatio: z.string().optional().describe("기본 9:16"),
1132
1246
  },
1133
- wrapCloudHandler(config, async ({ durationSec, lyrics = "", conceptBrief = "", instrumental = false, sceneSeconds = 5, splitMode = "fixed", videoStylePreset = "seedance-music-video", aspectRatio = "9:16" , characters = [] }) => {
1247
+ wrapCloudHandler(config, async ({ durationSec, lyrics = "", conceptBrief = "", instrumental = false, sceneSeconds = 5, splitMode = "fixed", videoStylePreset = "seedance-music-video", aspectRatio = "9:16" , characters = [], productionProfile, imageSourceMode, visualMotionMode }) => {
1134
1248
  if (!instrumental && !String(lyrics).trim()) {
1135
1249
  return textResult("lyrics를 입력하거나 instrumental=true로 지정하세요.", { isError: true });
1136
1250
  }
1251
+ const visualSettings = resolveVisualSettings(null, { productionProfile, imageSourceMode, visualMotionMode });
1137
1252
  const payload = await api.request("/api/tracker/music-video/plan", {
1138
1253
  body: {
1254
+ ...visualSettings, productionProfile,
1139
1255
  alignedLines: [],
1140
1256
  aspectRatio,
1141
1257
  conceptBrief,
@@ -1154,7 +1270,13 @@ export function registerCloudTools(server, config, api) {
1154
1270
  });
1155
1271
  // usage/원장 이벤트 등 컨텍스트 낭비 필드는 제거하고 기획 본문만 돌려준다.
1156
1272
  const { accountCostEvents, providerUsage, usage, ...plan } = payload && typeof payload === "object" ? payload : {};
1157
- return jsonResult(plan);
1273
+ const scenes = trimPlanScenes(plan.scenes);
1274
+ const productionPath = saveProductionContext(config.stateDir, {
1275
+ topic: conceptBrief, visualSettings, autoCompose: productionProfile?.autoComposeEnabled ?? true,
1276
+ productionProfile: plan.productionProfile || productionProfile,
1277
+ scenes, featureKey: "musicVideo",
1278
+ });
1279
+ return jsonResult({ ...plan, scenes, ...visualSettings, productionPath });
1158
1280
  }),
1159
1281
  );
1160
1282
 
@@ -1271,17 +1393,31 @@ export function registerCloudTools(server, config, api) {
1271
1393
  endMs: z.number().describe("원본에서 자를 끝(ms)"),
1272
1394
  })).min(1).max(6).describe("이 행에 이어 붙일 원본 구간들"),
1273
1395
  })).min(1).max(40).describe("타임라인 행 — 순서대로 이어 붙습니다"),
1274
- provider: z.enum(["typecast", "elevenlabs", "supertone"]).optional().describe("TTS 공급자, 기본 typecast (recommend_voice 로 보이스를 먼저 고르세요)"),
1396
+ provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2. recommend_voice로 보이스를 먼저 고르세요."),
1275
1397
  voiceId: z.string().optional().describe("보이스 ID (생략 시 공급자 기본)"),
1276
1398
  aspectRatio: z.enum(["9:16", "16:9", "1:1"]).optional().describe("기본 9:16"),
1277
1399
  subtitleCropMode: z.enum(["off", "always", "auto"]).optional().describe("원본 박힌 자막 하단 크롭 — auto 는 burnedSubtitle.present 소스만"),
1278
1400
  },
1279
- wrapCloudHandler(config, async ({ title = "", sourceVideos, rows, provider = "typecast", voiceId = "", aspectRatio = "9:16", subtitleCropMode = "off" }) => {
1401
+ wrapCloudHandler(config, async ({ title = "", sourceVideos, rows, provider, voiceId = "", aspectRatio = "9:16", subtitleCropMode = "off" }) => {
1280
1402
  const sourceIds = new Set(sourceVideos.map((source) => source.id));
1281
1403
  const badRef = rows.flatMap((row) => row.clipRefs).find((ref) => !sourceIds.has(ref.videoId));
1282
1404
  if (badRef) {
1283
1405
  return textResult(`clipRefs 의 videoId "${badRef.videoId}" 가 sourceVideos 에 없습니다.`, { isError: true });
1284
1406
  }
1407
+ const hasNarration = rows.some((row) => ["N", "SN"].includes(row.mode) && row.audioContent?.trim());
1408
+ const selection = hasNarration ? await resolveTtsSelection(api, config, { provider }) : { provider: "typecast", executionTarget: "server" };
1409
+ provider = selection.provider;
1410
+ let preSynthesizedNarrations;
1411
+ if (selection.executionTarget === "companion") {
1412
+ const narrations = {};
1413
+ for (const [rowIndex, row] of rows.entries()) {
1414
+ if (!["N", "SN"].includes(row.mode) || !row.audioContent?.trim()) continue;
1415
+ const text = row.audioContent.trim();
1416
+ const result = await requestTtsBinary(api, config, selection, buildTtsRequestBody(provider, { text, voice: voiceId }));
1417
+ narrations[rowIndex] = { dataUrl: `data:${result.contentType};base64,${result.buffer.toString("base64")}`, charCount: Array.from(text).length, voiceId };
1418
+ }
1419
+ preSynthesizedNarrations = { narrations, attempted: Object.keys(narrations).length, succeeded: Object.keys(narrations).length, lastError: "" };
1420
+ }
1285
1421
  const version = {
1286
1422
  id: "mcp-remake",
1287
1423
  title,
@@ -1299,6 +1435,7 @@ export function registerCloudTools(server, config, api) {
1299
1435
  aspectRatio,
1300
1436
  costEventId: createUsageEventId("mcp-remake-tts"),
1301
1437
  provider,
1438
+ ...(preSynthesizedNarrations ? { preSynthesizedNarrations } : {}),
1302
1439
  subtitleCropMode,
1303
1440
  version,
1304
1441
  voiceId,
@@ -1327,15 +1464,50 @@ export function registerCloudTools(server, config, api) {
1327
1464
  // ── 시간 동기 자막 TTS — 쇼츠 하단자막용 ─────────────────────────────────────
1328
1465
  server.tool(
1329
1466
  "tts_narration_with_captions",
1330
- "ElevenLabs TTS를 char-level 타이밍과 함께 합성해 mp3 파일 + 시간 동기 자막 라인(subtitleLines)을 만듭니다. 쇼츠 하단자막이 필요하면 tts_narration 대신 이걸 쓰고, 결과 subtitleLines·durationSec·filePath를 stitch_timeline에 그대로 넘기세요 (웹 쇼츠 스튜디오와 동일한 정렬 방식). 사용자가 보이스를 지정하지 않았다면 먼저 recommend_voice(provider=elevenlabs)로 자동 선정하세요.",
1467
+ "TTS 오디오와 자막을 만듭니다. 자동 순서: Typecast→ElevenLabs→Supertone→Edge→Supertonic2, 명시 보이스/공급자는 유지. ElevenLabs는 실제 char-level 타이밍, 나머지는 실측 오디오 길이에 비례한 추정 자막(timingSource 확인)입니다. 먼저 recommend_voice로 제작 톤에 맞는 보이스를 고르세요. 자동 추가 STT 과금이나 다른 공급자로의 재합성은 하지 않습니다.",
1331
1468
  {
1332
- text: z.string().describe("내레이션 텍스트"),
1469
+ ...productionFields,
1470
+ text: z.string().optional().describe("생략 시 제작 핸들의 기획 대본 그대로 합성. 사실·인용을 바꾸거나 뉴스체로 재작성하지 마세요."),
1471
+ provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동 선택 또는 제작 핸들에 선정된 공급자"),
1333
1472
  voice: z.string().optional().describe("보이스 이름 또는 ID (생략 시 기본 보이스)"),
1334
1473
  speed: z.number().optional().describe("기본 1 (0.7~1.2)"),
1474
+ modelId: z.string().optional(), stability: z.number().min(0).max(1).optional(),
1475
+ style: z.number().min(0).max(1).optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
1335
1476
  },
1336
- wrapCloudHandler(config, async ({ text, voice = "", speed = 1 }) => {
1477
+ wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, modelId, stability, style, similarityBoost, speakerBoost }) => {
1478
+ const context = readProductionContext(config.stateDir, productionPath);
1479
+ productionProfile = context?.productionProfile || productionProfile;
1480
+ text ??= context?.narrationScript;
1481
+ const selection = await resolveTtsSelection(api, config, { provider, context });
1482
+ provider = selection.provider;
1483
+ if (!voice && context?.voice?.provider && context.voice.provider !== provider) {
1484
+ return textResult("보이스 공급자가 다릅니다. 선택한 provider용 voice를 다시 선정하세요.", { isError: true });
1485
+ }
1486
+ voice ||= context?.voice?.voiceId || "";
1487
+ if (!text?.trim()) return textResult("text 또는 기획 대본이 있는 productionPath가 필요합니다.", { isError: true });
1488
+ if (productionProfile?.deliveryStyle === "casual-social" && !voice) {
1489
+ return textResult("커뮤니티형 음성을 위해 먼저 recommend_voice에 productionPath를 전달하고, 반환된 productionPath 또는 보이스 ID를 사용하세요.", { isError: true });
1490
+ }
1491
+ if (provider !== "elevenlabs") {
1492
+ const body = buildTtsRequestBody(provider, { productionProfile, text, voice, speed, modelId, costEventId: createUsageEventId(`mcp-tts-${provider}-captions`) });
1493
+ const result = await requestTtsBinary(api, config, selection, body);
1494
+ const saved = saveMediaBuffer(config.stateDir, provider === "supertonic2" ? "narration-captions.wav" : "narration-captions.mp3", result.buffer, { contentType: result.contentType });
1495
+ const durationSec = audioDurationSecFromFile(saved.filePath);
1496
+ const subtitleLines = estimateTtsSubtitleLines(text, durationSec);
1497
+ const timingSource = "estimated-from-audio-duration";
1498
+ const updatedProductionPath = saveProductionContext(config.stateDir, { ...context, productionProfile, narrationScript: text,
1499
+ tts: { provider, durationSec, filePath: saved.filePath, subtitleLines, voiceId: voice, timingSource },
1500
+ });
1501
+ return jsonResult({ productionPath: updatedProductionPath, productionProfile, provider, executionTarget: selection.executionTarget,
1502
+ durationSec, durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec), filePath: saved.filePath,
1503
+ lineCount: subtitleLines.length, subtitleLines, timingSource,
1504
+ warnings: [durationSec > 0 ? "자막은 실측 오디오 길이 기준 추정입니다. 최종 렌더 전 발화와 동기를 확인하세요." : "오디오 길이를 측정하지 못해 자막을 만들지 않았습니다. 저장된 오디오를 확인하세요. 자동 재합성하지 않습니다."],
1505
+ next: "이 productionPath를 stitch_timeline에 전달하세요. 추정 자막의 발화 동기는 최종 미리보기에서 확인하세요.",
1506
+ });
1507
+ }
1337
1508
  const payload = await api.request("/api/tracker/tts/elevenlabs-with-timestamps", {
1338
1509
  body: {
1510
+ productionProfile, modelId, stability, style, similarityBoost, speakerBoost,
1339
1511
  costEventId: createUsageEventId("mcp-tts-elevenlabs-ts"),
1340
1512
  languageCode: "ko",
1341
1513
  speed,
@@ -1361,7 +1533,18 @@ export function registerCloudTools(server, config, api) {
1361
1533
  const durationSec = Array.isArray(charEnds) && charEnds.length
1362
1534
  ? Math.round(Number(charEnds[charEnds.length - 1]) * 10) / 10
1363
1535
  : audioDurationSecFromFile(saved.filePath);
1536
+ const updatedProductionPath = saveProductionContext(config.stateDir, {
1537
+ ...context, productionProfile, narrationScript: text,
1538
+ tts: { provider, durationSec, filePath: saved.filePath, subtitleLines, voiceId: payload?.voiceId || voice, deliverySettings: payload?.deliverySettings, timingSource: "provider-character-alignment" },
1539
+ });
1364
1540
  return jsonResult({
1541
+ productionPath: updatedProductionPath,
1542
+ provider,
1543
+ timingSource: "provider-character-alignment",
1544
+ productionProfile,
1545
+ deliverySettings: payload?.deliverySettings,
1546
+ durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec),
1547
+ next: "이 productionPath를 stitch_timeline으로 전달하세요. 목표보다 짧으면 자동 재과금 없이 먼저 대본/말속도와 재합성 비용을 검토하세요. 자막은 실제 발화에 맞추고 강조는 편집 스타일로 적용하세요.",
1365
1548
  durationSec,
1366
1549
  filePath: saved.filePath,
1367
1550
  lineCount: subtitleLines.length,
@@ -1375,10 +1558,12 @@ export function registerCloudTools(server, config, api) {
1375
1558
  "tts_list_voices",
1376
1559
  "TTS 보이스 목록을 조회합니다. 사용자가 목록에서 직접 고르고 싶어할 때 쓰세요 — 주제·샘플 영상 기반 자동 선정은 recommend_voice가 담당합니다. (voice를 생략하면 서버가 기본 보이스로 합성합니다)",
1377
1560
  {
1378
- provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
1561
+ provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2"),
1379
1562
  },
1380
- wrapCloudHandler(config, async ({ provider = "typecast" }) => {
1381
- const payload = await api.request(`/api/tracker/tts/${provider}/voices`, { method: "GET", timeoutMs: 30_000 });
1563
+ wrapCloudHandler(config, async ({ provider }) => {
1564
+ const selection = await resolveTtsSelection(api, config, { provider });
1565
+ provider = selection.provider;
1566
+ const payload = selection.localVoices ? { voices: selection.localVoices } : await api.request(`/api/tracker/tts/${provider}/voices`, { method: "GET", timeoutMs: 30_000 });
1382
1567
  // ElevenLabs 는 gender/age 가 labels 객체 안에 있다 — 두 공급자 형태 모두 지원.
1383
1568
  const voices = (Array.isArray(payload?.voices) ? payload.voices : []).slice(0, 80).map((voice) => ({
1384
1569
  age: voice?.age || voice?.labels?.age,
@@ -1388,7 +1573,7 @@ export function registerCloudTools(server, config, api) {
1388
1573
  nameKo: voice?.voiceNameKo,
1389
1574
  voiceId: voice?.voiceId,
1390
1575
  }));
1391
- return jsonResult({ count: voices.length, provider, voices });
1576
+ return jsonResult({ count: voices.length, provider, executionTarget: selection.executionTarget, voices });
1392
1577
  }),
1393
1578
  );
1394
1579
 
@@ -1396,13 +1581,20 @@ export function registerCloudTools(server, config, api) {
1396
1581
  "recommend_voice",
1397
1582
  "주제·대본과 사용자가 샘플로 지정한 영상(유튜브)에 어울리는 TTS 보이스를 LLM이 자동 선정합니다. 사용자가 보이스를 직접 지정하지 않았다면 tts_narration/tts_narration_with_captions 전에 이 도구를 호출해 recommendations[0].voiceId를 voice로 쓰세요. 샘플 영상이 있으면 컴패니언 앱이 화자 음성을 추출·분석해 가장 비슷한 보이스를 우선합니다(컴패니언 미실행 시 주제 기반으로만 추천).",
1398
1583
  {
1584
+ ...productionFields,
1399
1585
  topic: z.string().optional().describe("영상 주제·타깃 시청층 (예: 시니어 건강 정보, 커머스 쇼츠)"),
1400
1586
  scriptExcerpt: z.string().optional().describe("대본 앞부분 발췌 (선택)"),
1401
1587
  sampleVideoUrl: z.string().optional().describe("사용자가 샘플로 지정한 유튜브 URL 또는 11자 영상 ID"),
1402
- provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
1588
+ provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 키가 없으면 다음 후보, 합성 실패 후 자동 재과금 없음"),
1403
1589
  sampleVoiceHints: z.string().optional().describe("사용자가 말한 보이스 요구 (예: 차분한 중년 남성)"),
1404
1590
  },
1405
- wrapCloudHandler(config, async ({ topic = "", scriptExcerpt = "", sampleVideoUrl = "", provider = "typecast", sampleVoiceHints = "" }) => {
1591
+ wrapCloudHandler(config, async ({ topic = "", scriptExcerpt = "", sampleVideoUrl = "", provider, sampleVoiceHints = "", productionPath, productionProfile }) => {
1592
+ const context = readProductionContext(config.stateDir, productionPath);
1593
+ productionProfile = context?.productionProfile || productionProfile;
1594
+ const selection = await resolveTtsSelection(api, config, { provider, context });
1595
+ provider = selection.provider;
1596
+ topic ||= context?.topic || "";
1597
+ scriptExcerpt ||= context?.narrationScript?.slice(0, 2000) || "";
1406
1598
  const notes = [];
1407
1599
  let sampleAudioBase64 = "";
1408
1600
  const rawSample = String(sampleVideoUrl || "").trim();
@@ -1431,7 +1623,7 @@ export function registerCloudTools(server, config, api) {
1431
1623
  return textResult("topic, scriptExcerpt, sampleVoiceHints, sampleVideoUrl 중 하나는 필요합니다.", { isError: true });
1432
1624
  }
1433
1625
  const payload = await api.request("/api/tracker/tts/recommend-voice", {
1434
- body: { provider, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic },
1626
+ body: { provider, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic, productionProfile, ...(selection.localVoices ? { localVoiceCatalog: selection.localVoices } : {}) },
1435
1627
  method: "POST",
1436
1628
  timeoutMs: 180_000,
1437
1629
  });
@@ -1442,6 +1634,8 @@ export function registerCloudTools(server, config, api) {
1442
1634
  );
1443
1635
  }
1444
1636
  return jsonResult({
1637
+ productionPath: context || productionProfile ? saveProductionContext(config.stateDir, { ...context, productionProfile, voice: { ...payload?.recommendations?.[0], provider } }) : undefined,
1638
+ productionProfile,
1445
1639
  next: "recommendations[0].voiceId를 tts_narration 또는 tts_narration_with_captions의 voice로 사용하세요.",
1446
1640
  notes: [...notes, ...(Array.isArray(payload?.notes) ? payload.notes : [])],
1447
1641
  provider: payload?.provider || provider,