aimakeall-mcp 0.11.0 → 0.13.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.
@@ -27,8 +27,11 @@ import { fetchGenerationJob, GENERATION_JOB_PATH, submitGenerationJob } from "./
27
27
  import { loudnessDbfsFromWav, silenceRatioFromWav } from "./wav-dsp.mjs";
28
28
  import { trimWavToSeconds } from "./wav-trim.mjs";
29
29
  import { registerComposerTools } from "./composer-tools.mjs";
30
- import { productionFields, composerTextStyle, composerOverlay } from "./production-schemas.mjs";
30
+ import { registerReviewTools } from "./review-tools.mjs";
31
+ import { productionFields, visualProductionFields, sceneVisual, composerTextStyle, composerOverlay } from "./production-schemas.mjs";
31
32
  import { saveProductionContext, readProductionContext, durationReview, reviewProductionPayload } from "./production-context.mjs";
33
+ import { TTS_PROVIDERS, buildTtsRequestBody, estimateTtsSubtitleLines, requestTtsBinary, resolveTtsSelection } from "./tts-workflow.mjs";
34
+ import { assertI2vAllowed, findSceneWebImage, resolveVisualSettings, sceneWebSearchQuery } from "./visual-production.mjs";
32
35
 
33
36
  // 여러 파일을 base64 로 싣는 요청의 인코딩 크기 합이 서버 JSON 상한을 넘지 않게 사전 검사.
34
37
  // 서버가 전체 업로드를 받은 뒤 413 을 내는 것을 막고 행동 가능한 한국어 안내를 준다.
@@ -36,6 +39,11 @@ const BODY_BUDGET_BYTES = SERVER_JSON_BODY_LIMIT_BYTES - 512 * 1024; // 바디
36
39
 
37
40
  // 컴패니언 계약 상수 — companion/index.cjs 의 MAX_SHOT_COUNT 와 맞춘다.
38
41
  const COMPANION_MAX_SHOTS = 18;
42
+ const ttsFallbackFields = {
43
+ allowProviderFallback: z.boolean().optional().describe("확정된 일시 거절 때만 대체 공급자 허용. 처리 결과 불명·인증·정책 오류는 재합성하지 않음"),
44
+ fallbackVoices: z.object(Object.fromEntries(TTS_PROVIDERS.map((provider) => [provider,
45
+ z.object({ voiceId: z.string().min(1) }).optional()]))).optional().describe("선택한 목소리를 대신해도 되는 공급자별 보이스 ID. 미지정이면 명시 보이스를 다른 목소리로 변경하지 않음"),
46
+ };
39
47
 
40
48
  function assertEncodedBudget(filePaths, { context = "요청" } = {}) {
41
49
  let total = 0;
@@ -145,13 +153,21 @@ function describeLocalError(error) {
145
153
  }
146
154
 
147
155
  export function wrapCloudHandler(config, handler) {
148
- return async (args) => {
156
+ return async (args, extra) => {
149
157
  if (!config.pat) return textResult(NO_PAT_GUIDE, { isError: true });
150
158
  try {
151
- return await handler(args);
159
+ return await handler(args, extra);
152
160
  } catch (error) {
153
161
  if (error instanceof AimakeallApiError) {
154
162
  const retry = error.retryAfterSeconds ? ` (${error.retryAfterSeconds}초 후 재시도)` : "";
163
+ if (error.partialProviderUsage || error.accountCostEvents.length || error.fallbackStatus) {
164
+ return textResult(JSON.stringify({ error: error.message, code: error.errorCode, status: error.status,
165
+ provider: error.provider, fallbackStatus: error.fallbackStatus, fallbackReason: error.fallbackReason,
166
+ fallbackAvailable: error.fallbackAvailable, capability: error.capability,
167
+ accountCostEvents: error.accountCostEvents, acceptedTaskCosts: error.acceptedTaskCosts,
168
+ partialProviderUsage: error.partialProviderUsage, usage: error.usage,
169
+ guidance: "접수된 비용과 실패 사유를 확인하세요. 결과 불명 작업이나 지정한 보이스를 자동 재생성/교체하지 마세요." }, null, 2), { isError: true });
170
+ }
155
171
  return textResult(`${error.message}${retry}`, { isError: true });
156
172
  }
157
173
  return textResult(`요청 실패: ${describeLocalError(error)}`, { isError: true });
@@ -165,6 +181,7 @@ function trimPlanScenes(scenes) {
165
181
  durationSec: scene?.durationSec,
166
182
  idx: scene?.idx,
167
183
  imagePrompt: scene?.imagePrompt,
184
+ webSearchQuery: sceneWebSearchQuery(scene),
168
185
  label: scene?.label,
169
186
  ratio: scene?.ratio,
170
187
  sceneNarration: scene?.sceneNarration,
@@ -184,11 +201,13 @@ function trimAnalysis(analysis) {
184
201
 
185
202
  export function registerCloudTools(server, config, api) {
186
203
  registerComposerTools(server, config, api);
204
+ registerReviewTools(server, config, api, { wrap: handler => wrapCloudHandler(config, handler) });
187
205
  async function completedGenerationResult(job, includePreview) {
188
206
  const payload = job.result || {};
189
207
  const resultBody = { generationJobId: job.id, status: job.status, kind: job.kind,
190
208
  model: payload.model, taskId: payload.taskId, providerTasks: job.providerTasks };
191
209
  if (job.kind === "image") {
210
+ resultBody.source = "ai";
192
211
  resultBody.imageUrl = payload.imageUrl || undefined;
193
212
  let inlineImage = null;
194
213
  if (!payload.imageUrl && payload.imageDataUrl) {
@@ -285,10 +304,11 @@ export function registerCloudTools(server, config, api) {
285
304
  videoModel: z.string().optional().describe("기본 kie-grok-imagine"),
286
305
  videoSecondsPerScene: z.number().min(0).max(30).optional().describe("씬당 영상 초 (0이면 영상 미포함)"),
287
306
  videoQuality: z.string().optional().describe("기본 720p"),
288
- ttsProvider: z.enum(["typecast", "elevenlabs", "supertone"]).optional().describe("기본 typecast"),
307
+ ttsProvider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 계정별 자동 우선순위. Edge/Supertonic2는 TTS 공급자 비용 0"),
289
308
  ttsChars: z.number().int().min(0).max(20000).optional().describe("내레이션 글자 수"),
290
309
  },
291
310
  wrapCloudHandler(config, async (input) => {
311
+ if (input.ttsChars > 0) input = { ...input, ttsProvider: (await resolveTtsSelection(api, config, { provider: input.ttsProvider, validateCredentials: false })).provider };
292
312
  const payload = await api.request("/api/tracker/cost/estimate", { body: input, method: "POST", timeoutMs: 30_000 });
293
313
  return jsonResult(payload);
294
314
  }),
@@ -299,6 +319,7 @@ export function registerCloudTools(server, config, api) {
299
319
  "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조).",
300
320
  {
301
321
  productionProfile: productionFields.productionProfile,
322
+ ...visualProductionFields,
302
323
  durationTargetSec: z.number().positive().max(180).optional().describe("사용자가 요청한 목표 길이(예:40). 음성 길이로 덮어쓰지 않습니다."),
303
324
  topic: z.string().optional().describe("영상 주제"),
304
325
  copy: z.string().optional().describe("핵심 카피/대사"),
@@ -328,12 +349,14 @@ export function registerCloudTools(server, config, api) {
328
349
  forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
329
350
  })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
330
351
  },
331
- wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 0, characters = [], channelFingerprint = null, productionProfile, durationTargetSec }) => {
352
+ wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 0, characters = [], channelFingerprint = null, productionProfile, durationTargetSec, imageSourceMode, visualMotionMode }) => {
332
353
  if (!String(topic).trim() && !String(copy).trim()) {
333
354
  return textResult("topic 또는 copy 중 하나는 입력해야 합니다.", { isError: true });
334
355
  }
356
+ const visualSettings = resolveVisualSettings(null, { productionProfile, imageSourceMode, visualMotionMode });
335
357
  const payload = await api.request("/api/tracker/shorts-video/plan", {
336
358
  body: {
359
+ ...visualSettings,
337
360
  categoryId,
338
361
  productionProfile,
339
362
  durationTargetSec,
@@ -352,15 +375,17 @@ export function registerCloudTools(server, config, api) {
352
375
  timeoutMs: 240_000,
353
376
  });
354
377
  const productionPath = saveProductionContext(config.stateDir, {
355
- topic, productionProfile: payload?.productionProfile, narrationScript: payload?.narrationScript,
378
+ topic, visualSettings, autoCompose: productionProfile?.autoComposeEnabled ?? true,
379
+ productionProfile: payload?.productionProfile, narrationScript: payload?.narrationScript,
356
380
  narrationSource: payload?.narrationSource, tagline: payload?.tagline, emphasisKeywords: payload?.emphasisKeywords || [],
357
381
  channelFingerprint, scenes: trimPlanScenes(payload?.scenes),
358
382
  });
359
383
  return jsonResult({
360
384
  productionPath,
385
+ ...visualSettings,
361
386
  productionProfile: payload?.productionProfile,
362
387
  narrationSource: payload?.narrationSource,
363
- next: "productionPath를 recommend_voice → tts_narration_with_captions → stitch_timeline에 전달하세요. 대본을 뉴스체로 바꾸지 말고, list_composer_presets/search_composer_assets로 편집 소재도 선택하세요.",
388
+ next: `productionPath를 이미지 준비·보이스 추천·TTS·스티치에 계속 전달하세요. ${visualSettings.visualMotionMode === "image-motion" ? "I2V 프롬프트/영상 생성은 생략하고 이미지 URL을 stitch_timeline.sceneVisuals(kind=image)에 넣으세요." : "이미지 확인 후 I2V 영상 생성으로 진행하세요."} 대본을 뉴스체로 바꾸지 말고 컷컴포저 소재도 활용하세요.`,
364
389
  charactersUsed: Array.isArray(payload?.charactersUsed) ? payload.charactersUsed : undefined,
365
390
  ctaText: payload?.ctaText,
366
391
  emphasisKeywords: Array.isArray(payload?.emphasisKeywords) ? payload.emphasisKeywords : [],
@@ -455,6 +480,8 @@ export function registerCloudTools(server, config, api) {
455
480
  "plan_commerce_video",
456
481
  "제품 홍보 영상 기획을 생성합니다. 제품 사진 파일 경로가 최소 1장 필요합니다 (이 PC의 로컬 경로).",
457
482
  {
483
+ ...visualProductionFields,
484
+ productionProfile: productionFields.productionProfile,
458
485
  productName: z.string().describe("제품명 (필수)"),
459
486
  productImagePaths: z.array(z.string()).min(1).describe("제품/자산 사진의 로컬 파일 경로 (1장 이상)"),
460
487
  modelImagePath: z.string().optional().describe("모델 사진 로컬 경로 (선택)"),
@@ -472,7 +499,8 @@ export function registerCloudTools(server, config, api) {
472
499
  forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
473
500
  })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
474
501
  },
475
- wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" , characters = [] }) => {
502
+ wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" , characters = [], productionProfile, imageSourceMode, visualMotionMode }) => {
503
+ const visualSettings = resolveVisualSettings(null, { productionProfile, imageSourceMode, visualMotionMode });
476
504
  // 확장자 검증(임의 파일 업로드 차단) + 합산 크기 예산(서버 413 사전 차단).
477
505
  for (const filePath of productImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "이미지" });
478
506
  if (modelImagePath) assertAllowedInputFile(modelImagePath, ALLOWED_IMAGE_EXTS, { kind: "모델 이미지" });
@@ -480,6 +508,7 @@ export function registerCloudTools(server, config, api) {
480
508
 
481
509
  const payload = await api.request("/api/tracker/commerce-video/plan", {
482
510
  body: {
511
+ ...visualSettings, productionProfile,
483
512
  categoryId,
484
513
  description,
485
514
  modelImage: modelImagePath ? fileToImagePayload(modelImagePath) : null,
@@ -497,7 +526,14 @@ export function registerCloudTools(server, config, api) {
497
526
  method: "POST",
498
527
  timeoutMs: 300_000,
499
528
  });
529
+ const productionPath = saveProductionContext(config.stateDir, {
530
+ topic: productName, visualSettings, autoCompose: productionProfile?.autoComposeEnabled ?? true,
531
+ productionProfile: payload?.productionProfile || productionProfile,
532
+ tagline: payload?.tagline, narrationScript: payload?.narrationScript,
533
+ scenes: trimPlanScenes(payload?.scenes), featureKey: "commerceVideo",
534
+ });
500
535
  return jsonResult({
536
+ productionPath, ...visualSettings, productionProfile: payload?.productionProfile || productionProfile,
501
537
  analysis: trimAnalysis(payload?.analysis),
502
538
  ctaText: payload?.ctaText,
503
539
  ok: payload?.ok,
@@ -510,8 +546,13 @@ export function registerCloudTools(server, config, api) {
510
546
 
511
547
  server.tool(
512
548
  "generate_scene_image",
513
- "씬 이미지 생성 작업을 접수하고 즉시 generationJobId를 반환합니다. get_generation_job으로 completed 결과의 imageUrl을 받은 뒤 씬 영상 입력에 쓰세요. 첫 씬/캐릭터/제품 이미지를 referenceImageUrls·referenceImagePaths로 전달하면 일관성 유지에 도움이 됩니다. 완료 이미지는 직접 확인하거나 verify_scene_image로 검증하세요.",
549
+ "선택한 방식으로 장면 이미지를 준비합니다. productionPath의 web은 사용자 키로 Pexels/Pixabay 검색만, ai는 AI 생성만, mixed는 웹 검색 후 없을 때 AI 생성합니다. 웹 결과는 즉시 imageUrl·출처를 반환하며 AI만 generationJobId를 get_generation_job으로 조회합니다. image-motion이면 I2V를 생략하고 sceneVisual을 스티치에 사용하세요.",
514
550
  {
551
+ ...productionFields,
552
+ ...visualProductionFields,
553
+ sceneIdx: z.number().int().min(0).optional().describe("기획 장면 idx. 저장된 장면 검색어와 출처를 연결합니다."),
554
+ webSearchQuery: z.string().max(160).optional().describe("장면에 맞는 짧은 웹 검색 키워드. plan.scenes[].webSearchQuery를 사용하세요."),
555
+ excludeImageUrls: z.array(z.string()).max(100).optional().describe("이미 사용한 웹 이미지 URL. 반복 장면을 피합니다."),
515
556
  requestId: z.string().optional().describe("접수 응답 유실 후 같은 작업을 재확인할 때 기존 generationJobId를 지정합니다. 동일 ID·입력은 중복 생성하지 않습니다."),
516
557
  prompt: z.string().describe("이미지 프롬프트 (plan 결과의 imagePrompt)"),
517
558
  aspectRatio: z.string().optional().describe("기본 9:16"),
@@ -522,7 +563,46 @@ export function registerCloudTools(server, config, api) {
522
563
  referenceImageUrls: z.array(z.string()).max(6).optional().describe("참조 이미지 URL (씬1 앵커 등)"),
523
564
  referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 이미지 로컬 경로 (제품 사진 등)"),
524
565
  },
525
- wrapCloudHandler(config, async ({ requestId, prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true, identityLock = "" }) => {
566
+ 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 = [] }) => {
567
+ const context = readProductionContext(config.stateDir, productionPath);
568
+ const visualSettings = resolveVisualSettings(context, { productionProfile, imageSourceMode, visualMotionMode });
569
+ productionProfile = context?.productionProfile || productionProfile;
570
+ const plannedScene = context?.scenes?.find((entry) => entry.idx === sceneIdx) || {};
571
+ const saveSource = (source) => saveProductionContext(config.stateDir, {
572
+ ...context, productionProfile, visualSettings,
573
+ sceneSources: [...(context?.sceneSources || []).filter((entry) => entry.idx !== sceneIdx), { ...source, idx: sceneIdx }],
574
+ });
575
+ let fallbackReason;
576
+ if (visualSettings.imageSourceMode !== "ai") {
577
+ // A retry of a possibly accepted paid job must recover it before another
578
+ // stock selection can hide it. Unknown IDs continue the original flow.
579
+ if (requestId) {
580
+ try {
581
+ const job = await fetchGenerationJob(api, requestId);
582
+ if (job.kind !== "image") throw new Error("requestId는 이미지 생성 작업 ID여야 합니다. 영상 작업은 get_generation_job으로 조회하세요.");
583
+ return job.status === "completed" ? completedGenerationResult(job, returnImage)
584
+ : jsonResult({ generationJobId: job.id, status: job.status, kind: job.kind, existing: true, nextTool: "get_generation_job" });
585
+ } catch (error) { if (Number(error?.status) !== 404) throw error; }
586
+ }
587
+ const query = webSearchQuery || sceneWebSearchQuery(plannedScene) || sceneWebSearchQuery({ imagePrompt: prompt });
588
+ const selected = await findSceneWebImage(api, {
589
+ query,
590
+ excludeImageUrls: [...excludeImageUrls, ...(context?.sceneSources || []).filter((entry) => entry.idx !== sceneIdx).map((entry) => entry.url).filter(Boolean)],
591
+ });
592
+ if (selected) {
593
+ const visual = { ...selected, idx: sceneIdx, label: plannedScene.label || selected.label, durationSec: Number(plannedScene.durationSec) || 6 };
594
+ return jsonResult({ ok: true, status: "completed", ...visualSettings, source: "web", imageUrl: selected.url,
595
+ sceneVisual: visual, sourcePage: selected.sourcePage, attribution: selected.attribution, license: selected.license,
596
+ productionPath: saveSource(visual),
597
+ guidance: visualSettings.visualMotionMode === "image-motion" ? "sceneVisual을 stitch_timeline.sceneVisuals에 넣으세요. I2V 호출은 하지 않습니다." : "웹 이미지의 내용·출처를 확인한 뒤 I2V 입력으로 사용하세요. 생성 영상에도 이 출처 정보를 보존하세요.",
598
+ });
599
+ }
600
+ if (visualSettings.imageSourceMode === "web") {
601
+ return { ...jsonResult({ ok: false, code: "WEB_IMAGE_NOT_FOUND", ...visualSettings,
602
+ message: "사용 가능한 새 웹 이미지를 찾지 못했습니다. 스톡 검색 키·검색어를 확인하거나 직접 이미지를 선택하세요. 웹만 선택했으므로 AI 생성은 실행하지 않았습니다." }), isError: true };
603
+ }
604
+ fallbackReason = "혼합 모드: 사용 가능한 새 웹 이미지가 없어 선택한 AI 생성으로 진행합니다.";
605
+ }
526
606
  // identity_lock 리터럴 부착 — 이미 포함돼 있으면 재부착하지 않는다(멱등: 프롬프트 캐시 보존).
527
607
  const lock = String(identityLock || "").trim();
528
608
  if (lock && !prompt.includes(lock)) prompt = `${prompt.trim()}, ${lock}`;
@@ -536,18 +616,22 @@ export function registerCloudTools(server, config, api) {
536
616
  ...referenceImageUrls.map((url, index) => ({ name: `ref-${index + 1}`, previewUrl: url })),
537
617
  ].slice(0, 6);
538
618
 
539
- return jsonResult(await submitGenerationJob(api, {
619
+ const job = await submitGenerationJob(api, {
540
620
  kind: "image", includePreview: returnImage, requestId,
541
621
  input: {
622
+ productionProfile, ...visualSettings,
542
623
  aspectRatio,
543
624
  model,
544
625
  prompt,
545
626
  referenceImages,
546
- referenceSearchEnabled: true,
627
+ referenceSearchEnabled: false,
547
628
  resolution,
548
- webSearchEnabled: true,
629
+ webSearchEnabled: false,
549
630
  },
550
- }));
631
+ });
632
+ return jsonResult({ ...job, ...visualSettings, source: "ai", fallbackReason,
633
+ productionPath: saveSource({ source: "ai", generationJobId: job.generationJobId }),
634
+ });
551
635
  }),
552
636
  );
553
637
 
@@ -555,6 +639,8 @@ export function registerCloudTools(server, config, api) {
555
639
  "generate_scene_video_prompt",
556
640
  "씬 이미지 기반 영상 프롬프트를 생성합니다. 결과 videoPrompt를 generate_scene_video의 prompt로 넘기세요.",
557
641
  {
642
+ ...productionFields,
643
+ ...visualProductionFields,
558
644
  imagePrompt: z.string().describe("씬의 이미지 프롬프트"),
559
645
  sceneImageUrl: z.string().optional().describe("generate_scene_image가 반환한 imageUrl"),
560
646
  sceneScript: z.string().optional().describe("씬 대사/설명 (plan 결과의 label)"),
@@ -565,9 +651,13 @@ export function registerCloudTools(server, config, api) {
565
651
  kind: z.enum(["shorts_scene", "commerce_video_scene", "music_video_scene"]).optional(),
566
652
  modelId: z.string().optional().describe("대상 영상 모델 (기본 kie-grok-imagine — generate_scene_video와 동일하게 맞추세요)"),
567
653
  },
568
- wrapCloudHandler(config, async ({ imagePrompt, sceneImageUrl = "", sceneScript = "", sceneTitle = "", aspectRatio = "9:16", durationSec = 8, videoStylePreset = "", kind = "shorts_scene", modelId = "kie-grok-imagine" }) => {
654
+ wrapCloudHandler(config, async ({ imagePrompt, sceneImageUrl = "", sceneScript = "", sceneTitle = "", aspectRatio = "9:16", durationSec = 8, videoStylePreset = "", kind = "shorts_scene", modelId = "kie-grok-imagine", productionPath, productionProfile, imageSourceMode, visualMotionMode }) => {
655
+ const context = readProductionContext(config.stateDir, productionPath);
656
+ const visualSettings = resolveVisualSettings(context, { productionProfile, imageSourceMode, visualMotionMode });
657
+ assertI2vAllowed(visualSettings);
569
658
  const payload = await api.request("/api/tracker/gemini/storyboard-video-prompt", {
570
659
  body: {
660
+ productionProfile: context?.productionProfile || productionProfile, ...visualSettings,
571
661
  aspectRatio,
572
662
  durationLabel: `${Math.max(1, Math.round(durationSec))}s`,
573
663
  generationPlan: {
@@ -596,6 +686,8 @@ export function registerCloudTools(server, config, api) {
596
686
  "generate_scene_video",
597
687
  "씬 영상 생성 작업을 접수하고 즉시 generationJobId를 반환합니다. get_generation_job으로 completed가 될 때까지 조회한 뒤 videoUrl을 stitch_timeline에 넣으세요. 접수만 된 상태에서 새로 생성하지 마세요.",
598
688
  {
689
+ ...productionFields,
690
+ ...visualProductionFields,
599
691
  requestId: z.string().optional().describe("접수 응답 유실 후 동일 입력을 재접수할 때 기존 generationJobId를 지정합니다."),
600
692
  prompt: z.string().describe("영상 프롬프트 (generate_scene_video_prompt의 videoPrompt)"),
601
693
  sceneImageUrl: z.string().optional().describe("씬 이미지 URL (i2v 입력)"),
@@ -605,10 +697,14 @@ export function registerCloudTools(server, config, api) {
605
697
  quality: z.string().optional().describe("기본 720p"),
606
698
  returnFrames: z.boolean().optional().describe("기본 true — 결과 영상의 대표 프레임 3장을 직접 보고 QC 할 수 있게 이미지 블록으로 반환"),
607
699
  },
608
- wrapCloudHandler(config, async ({ requestId, prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p", returnFrames = true }) => {
700
+ wrapCloudHandler(config, async ({ requestId, prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p", returnFrames = true, productionPath, productionProfile, imageSourceMode, visualMotionMode }) => {
701
+ const context = readProductionContext(config.stateDir, productionPath);
702
+ const visualSettings = resolveVisualSettings(context, { productionProfile, imageSourceMode, visualMotionMode });
703
+ assertI2vAllowed(visualSettings);
609
704
  return jsonResult(await submitGenerationJob(api, {
610
705
  kind: "video", includePreview: returnFrames, requestId,
611
706
  input: {
707
+ productionProfile: context?.productionProfile || productionProfile, ...visualSettings,
612
708
  aspectRatio,
613
709
  durationLabel: `${Math.max(1, Math.ceil(durationSec))}s`,
614
710
  generationPlan: {
@@ -630,71 +726,50 @@ export function registerCloudTools(server, config, api) {
630
726
 
631
727
  server.tool(
632
728
  "tts_narration",
633
- "내레이션 TTS를 합성해 이 PC에 mp3로 저장하고 파일 경로를 반환합니다. stitch_timeline의 ttsAudioPath로 쓰세요. 사용자가 보이스를 직접 지정하지 않았다면 먼저 recommend_voice로 주제·샘플 영상에 맞는 보이스를 자동 선정해 voice에 넣으세요.",
729
+ "내레이션 TTS를 합성해 이 PC에 저장합니다. 자동 순서: Typecast → ElevenLabs → Supertone → Edge → Supertonic2. 확정된 일시 거절에만 폴백하며, 명시 보이스는 fallbackVoices가 있어야 교체합니다. 접수 후 결과 불명·정책·인증 오류는 재합성하지 않습니다. 먼저 recommend_voice로 톤에 맞는 보이스를 고르세요.",
634
730
  {
635
731
  ...productionFields,
636
732
  text: z.string().optional().describe("생략 시 productionPath의 기획 대본"),
637
- provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
733
+ ...ttsFallbackFields,
734
+ provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 명시 선택/저장된 보이스는 유지"),
638
735
  voice: z.string().optional().describe("보이스 이름(라벨)"),
639
736
  speed: z.number().optional().describe("기본 1"),
640
737
  stability: z.number().min(0).max(1).optional(), style: z.number().min(0).max(1).optional(),
641
738
  modelId: z.string().optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
642
739
  },
643
- wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, stability, style, modelId, similarityBoost, speakerBoost }) => {
740
+ wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, stability, style, modelId, similarityBoost, speakerBoost, fallbackVoices, allowProviderFallback }) => {
644
741
  const context = readProductionContext(config.stateDir, productionPath);
645
742
  productionProfile = context?.productionProfile || productionProfile;
646
743
  text ??= context?.narrationScript;
647
- provider ||= context?.voice?.provider || (productionProfile ? "elevenlabs" : "typecast");
744
+ const selection = await resolveTtsSelection(api, config, { provider, context });
745
+ provider = selection.provider;
648
746
  if (!voice && context?.voice?.provider && context.voice.provider !== provider) return textResult("보이스 공급자가 다릅니다. 선택한 provider용 voice를 다시 선정하세요.", { isError: true });
649
747
  voice ||= context?.voice?.voiceId || "";
650
748
  if (!text?.trim()) return textResult("대본(text/productionPath)이 필요합니다.", { isError: true });
651
749
  if (productionProfile?.deliveryStyle === "casual-social" && !voice) return textResult("먼저 recommend_voice에 productionPath를 전달해 커뮤니티형 보이스를 선정하세요.", { isError: true });
652
- const body = provider === "typecast"
653
- ? {
654
- productionProfile,
655
- breath: 0.3,
656
- costEventId: createUsageEventId("mcp-tts-typecast"),
657
- emotion: "neutral",
658
- fileName: "mcp-narration",
659
- language: "kor",
660
- modelId: "",
661
- pitch: 0,
662
- smartEmotion: true,
663
- speed,
664
- text,
665
- voice,
666
- volume: 100,
667
- }
668
- : {
669
- productionProfile,
670
- costEventId: createUsageEventId("mcp-tts-elevenlabs"),
671
- fileName: "mcp-narration",
672
- languageCode: "ko",
673
- modelId,
674
- similarityBoost,
675
- speakerBoost,
676
- speed,
677
- stability,
678
- style,
679
- text,
680
- voice,
681
- };
682
- const result = await api.requestBinary(`/api/tracker/tts/${provider}`, {
683
- body,
684
- method: "POST",
685
- timeoutMs: 120_000,
686
- });
687
- const saved = saveMediaBuffer(config.stateDir, "narration.mp3", result.buffer, { contentType: result.contentType });
750
+ const body = buildTtsRequestBody(provider, { productionProfile, costEventId: createUsageEventId(`mcp-tts-${provider}`), modelId, similarityBoost, speakerBoost, speed, stability, style, text, voice, fallbackVoices, allowProviderFallback });
751
+ const result = await requestTtsBinary(api, config, selection, body);
752
+ provider = result.provider || provider;
753
+ voice = result.voiceId || voice;
754
+ const saved = saveMediaBuffer(config.stateDir, provider === "supertonic2" ? "narration.wav" : "narration.mp3", result.buffer, { contentType: result.contentType });
688
755
  const durationSec = audioDurationSecFromFile(saved.filePath);
689
756
  return jsonResult({
690
- productionPath: saveProductionContext(config.stateDir, { ...context, productionProfile, narrationScript: text, tts: { filePath: saved.filePath, durationSec, voiceId: voice } }),
757
+ productionPath: saveProductionContext(config.stateDir, { ...context, productionProfile, narrationScript: text,
758
+ ...(result.providerFallback ? { voice: { provider, voiceId: voice, reason: "사용자가 허용한 공급자별 대체 보이스" } } : {}),
759
+ tts: { provider, filePath: saved.filePath, durationSec, voiceId: voice } }),
760
+ provider,
761
+ primaryProvider: result.primaryProvider || provider,
762
+ providerFallback: result.providerFallback === true,
763
+ accountCostEvents: result.accountCostEvents || [],
764
+ hasAccountCostEvents: true,
765
+ executionTarget: selection.executionTarget,
691
766
  durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec),
692
767
  bytes: saved.bytes,
693
768
  // 실측 mp3 길이 — stitch_timeline 의 audioDurationSec 로 그대로 쓰면 타임라인이 정확하다.
694
769
  durationSec,
695
770
  filePath: saved.filePath,
696
771
  voiceName: decodeHeaderValue(
697
- result.headers.get("x-typecast-voice-name") || result.headers.get("x-elevenlabs-voice-name") || "",
772
+ 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") || "",
698
773
  ),
699
774
  });
700
775
  }),
@@ -768,9 +843,12 @@ export function registerCloudTools(server, config, api) {
768
843
 
769
844
  server.tool(
770
845
  "stitch_timeline",
771
- "씬 영상들과 오디오를 서버에서 타임라인 매니페스트로 합칩니다. 결과는 파일 핸들(payloadPath)로 반환되며, 이를 render_start에 넘기면 이 PC에서 mp4가 렌더됩니다. 서버는 렌더하지 않습니다. 하단자막 강조: emphasisKeywords 를 주면 자막 라인 안의 해당 단어만 다른 색/크기로 강조되고, subtitleLines[].style 로 라인별 디자인도 바꿀 수 있습니다.",
846
+ "씬 이미지/영상과 오디오를 타임라인 매니페스트로 합칩니다. image-motion은 sceneVisuals(kind=image)에 인스펙터 imageMotionPreset/Intensity를 적용해 I2V 없이 렌더합니다. autoCompose 기본 true는 문맥별 모션·자막 디자인·적합한 라이브러리 소재를 배치하며 명시 편집을 우선합니다. 결과 payloadPath를 로컬 render_start에 넘기세요.",
772
847
  {
773
848
  ...productionFields,
849
+ ...visualProductionFields,
850
+ autoCompose: z.boolean().optional().describe("기본 true. 실제 인스펙터 모션·자막 계층·문맥별 라이브러리 자동 편집. false는 수동 편집을 유지합니다."),
851
+ sceneVisuals: z.array(sceneVisual).min(1).max(200).optional().describe("장면 이미지/영상과 출처 메타데이터. sceneVideos와 동시에 전달하지 마세요."),
774
852
  titleStylePresetId: z.string().optional(), subtitleStylePresetId: z.string().optional(),
775
853
  titleStyle: composerTextStyle.optional(), subtitleStyle: composerTextStyle.optional(),
776
854
  titleSegments: z.array(z.object({ text: z.string(), color: z.string().optional() })).max(20).optional(),
@@ -781,7 +859,7 @@ export function registerCloudTools(server, config, api) {
781
859
  idx: z.number().int(),
782
860
  label: z.string().optional(),
783
861
  url: z.string(),
784
- })).min(1).describe("씬 순서대로 idx=0부터. url은 generate_scene_video의 videoUrl"),
862
+ })).min(1).optional().describe("구버전 영상 전용 입력. 새 제작은 sceneVisuals를 권장합니다. image-motion에서는 사용할 수 없습니다."),
785
863
  featureKey: z.enum(["shortsVideo", "commerceVideo", "musicVideo"]).optional().describe("기본 commerceVideo"),
786
864
  projectTitle: z.string().optional(),
787
865
  aspectRatio: z.string().optional().describe("기본 9:16"),
@@ -808,14 +886,22 @@ export function registerCloudTools(server, config, api) {
808
886
  }).passthrough().optional().describe("measure_channel_spec 이 반환한 fingerprint — 자막 세로 위치·BGM 유무를 참고 채널에 맞춥니다"),
809
887
  transitionMode: z.enum(["fade", "none"]).optional().describe("기본 none(하드컷). fade 는 씬이 맞대기로 붙는 배치에서 컷마다 검은 딥을 만든다 — 명시 요청 시에만 쓸 것"),
810
888
  },
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 }) => {
889
+ 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 }) => {
812
890
  const context = readProductionContext(config.stateDir, productionPath);
813
891
  productionProfile = context?.productionProfile || productionProfile;
892
+ const visualSettings = resolveVisualSettings(context, { productionProfile, imageSourceMode, visualMotionMode });
893
+ if ((!sceneVisuals?.length && !sceneVideos?.length) || (sceneVisuals?.length && sceneVideos?.length)) {
894
+ return textResult("sceneVisuals 또는 구버전 sceneVideos 중 하나만 전달하세요.", { isError: true });
895
+ }
896
+ if (visualSettings.visualMotionMode === "image-motion" && (sceneVideos?.length || sceneVisuals.some((scene) => scene.kind !== "image"))) {
897
+ return textResult("IMAGE_MOTION_ONLY: 이미지 모션 제작에는 sceneVisuals(kind=image)만 사용할 수 있습니다.", { isError: true });
898
+ }
899
+ autoCompose ??= context?.autoCompose ?? productionProfile?.autoComposeEnabled ?? true;
814
900
  ttsAudioPath ||= context?.tts?.filePath || "";
815
901
  titleText ||= context?.tagline || "";
816
902
  subtitleLines ??= context?.tts?.subtitleLines || [];
817
903
  emphasisKeywords ??= context?.emphasisKeywords || [];
818
- featureKey ||= productionProfile?.categoryId?.endsWith("shorts") ? "shortsVideo" : "commerceVideo";
904
+ featureKey ||= context?.featureKey || (productionProfile?.categoryId?.endsWith("shorts") ? "shortsVideo" : "commerceVideo");
819
905
  channelFingerprint ||= context?.channelFingerprint;
820
906
  // 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
821
907
  if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
@@ -826,6 +912,7 @@ export function registerCloudTools(server, config, api) {
826
912
  ? audioDurationSec
827
913
  : (ttsAudioPath ? audioDurationSecFromFile(ttsAudioPath) : 0);
828
914
  const body = {
915
+ ...visualSettings, autoCompose,
829
916
  productionProfile, titleStylePresetId, subtitleStylePresetId, titleStyle, subtitleStyle, titleSegments, overlays,
830
917
  aspectRatio,
831
918
  audioDurationSec: resolvedAudioDurationSec,
@@ -834,12 +921,14 @@ export function registerCloudTools(server, config, api) {
834
921
  featureKey,
835
922
  muteVideoAudio,
836
923
  projectTitle,
837
- sceneVideos: sceneVideos.map((scene, index) => ({
924
+ ...(sceneVisuals ? { sceneVisuals: sceneVisuals.map((scene, index) => ({
925
+ ...scene, idx: index, label: scene.label || `scene-${index + 1}`,
926
+ })) } : { sceneVideos: sceneVideos.map((scene, index) => ({
838
927
  durationSec: scene.durationSec,
839
928
  idx: index,
840
929
  label: scene.label || `scene-${index + 1}`,
841
930
  url: scene.url,
842
- })),
931
+ })) }),
843
932
  emphasisColor,
844
933
  emphasisKeywords,
845
934
  channelFingerprint,
@@ -857,7 +946,11 @@ export function registerCloudTools(server, config, api) {
857
946
  if (!response?.ok || !response?.payload) {
858
947
  return textResult(`스티치 실패: ${response?.error || "매니페스트가 비어 있습니다."}`, { isError: true });
859
948
  }
860
- if (context || productionProfile) response.payload.productionContext = { ...context, productionProfile, decorationRationale };
949
+ const autoComposition = response.payload.compositionReport?.autoComposition;
950
+ if (!decorationRationale && autoComposition?.enabled && autoComposition?.omissions?.length) {
951
+ decorationRationale = autoComposition.omissions.map((item) => typeof item === "string" ? item : item?.reason || item?.message || "").filter(Boolean).join(" ").slice(0, 500);
952
+ }
953
+ if (context || productionProfile) response.payload.productionContext = { ...context, productionProfile, visualSettings, decorationRationale };
861
954
  const qualityReview = reviewProductionPayload(response.payload);
862
955
  const handle = savePayloadHandle(config.stateDir, response.payload, "stitch");
863
956
  return jsonResult({
@@ -1157,6 +1250,8 @@ export function registerCloudTools(server, config, api) {
1157
1250
  "plan_music_video",
1158
1251
  "뮤직비디오 씬 플랜을 생성합니다. suno_music_download로 받은 곡의 가사·길이(durationSec)를 입력하세요. 결과 scenes의 imagePrompt는 generate_scene_image로 이어집니다.",
1159
1252
  {
1253
+ ...visualProductionFields,
1254
+ productionProfile: productionFields.productionProfile,
1160
1255
  durationSec: z.number().min(10).describe("곡 길이(초) — suno_music_download 결과의 durationSec"),
1161
1256
  lyrics: z.string().optional().describe("가사 전체 (instrumental이면 생략 가능)"),
1162
1257
  conceptBrief: z.string().optional().describe("영상 컨셉 브리프"),
@@ -1173,12 +1268,14 @@ export function registerCloudTools(server, config, api) {
1173
1268
  })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
1174
1269
  aspectRatio: z.string().optional().describe("기본 9:16"),
1175
1270
  },
1176
- wrapCloudHandler(config, async ({ durationSec, lyrics = "", conceptBrief = "", instrumental = false, sceneSeconds = 5, splitMode = "fixed", videoStylePreset = "seedance-music-video", aspectRatio = "9:16" , characters = [] }) => {
1271
+ wrapCloudHandler(config, async ({ durationSec, lyrics = "", conceptBrief = "", instrumental = false, sceneSeconds = 5, splitMode = "fixed", videoStylePreset = "seedance-music-video", aspectRatio = "9:16" , characters = [], productionProfile, imageSourceMode, visualMotionMode }) => {
1177
1272
  if (!instrumental && !String(lyrics).trim()) {
1178
1273
  return textResult("lyrics를 입력하거나 instrumental=true로 지정하세요.", { isError: true });
1179
1274
  }
1275
+ const visualSettings = resolveVisualSettings(null, { productionProfile, imageSourceMode, visualMotionMode });
1180
1276
  const payload = await api.request("/api/tracker/music-video/plan", {
1181
1277
  body: {
1278
+ ...visualSettings, productionProfile,
1182
1279
  alignedLines: [],
1183
1280
  aspectRatio,
1184
1281
  conceptBrief,
@@ -1197,7 +1294,13 @@ export function registerCloudTools(server, config, api) {
1197
1294
  });
1198
1295
  // usage/원장 이벤트 등 컨텍스트 낭비 필드는 제거하고 기획 본문만 돌려준다.
1199
1296
  const { accountCostEvents, providerUsage, usage, ...plan } = payload && typeof payload === "object" ? payload : {};
1200
- return jsonResult(plan);
1297
+ const scenes = trimPlanScenes(plan.scenes);
1298
+ const productionPath = saveProductionContext(config.stateDir, {
1299
+ topic: conceptBrief, visualSettings, autoCompose: productionProfile?.autoComposeEnabled ?? true,
1300
+ productionProfile: plan.productionProfile || productionProfile,
1301
+ scenes, featureKey: "musicVideo",
1302
+ });
1303
+ return jsonResult({ ...plan, scenes, ...visualSettings, productionPath });
1201
1304
  }),
1202
1305
  );
1203
1306
 
@@ -1314,17 +1417,31 @@ export function registerCloudTools(server, config, api) {
1314
1417
  endMs: z.number().describe("원본에서 자를 끝(ms)"),
1315
1418
  })).min(1).max(6).describe("이 행에 이어 붙일 원본 구간들"),
1316
1419
  })).min(1).max(40).describe("타임라인 행 — 순서대로 이어 붙습니다"),
1317
- provider: z.enum(["typecast", "elevenlabs", "supertone"]).optional().describe("TTS 공급자, 기본 typecast (recommend_voice 로 보이스를 먼저 고르세요)"),
1420
+ provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2. recommend_voice로 보이스를 먼저 고르세요."),
1318
1421
  voiceId: z.string().optional().describe("보이스 ID (생략 시 공급자 기본)"),
1319
1422
  aspectRatio: z.enum(["9:16", "16:9", "1:1"]).optional().describe("기본 9:16"),
1320
1423
  subtitleCropMode: z.enum(["off", "always", "auto"]).optional().describe("원본 박힌 자막 하단 크롭 — auto 는 burnedSubtitle.present 소스만"),
1321
1424
  },
1322
- wrapCloudHandler(config, async ({ title = "", sourceVideos, rows, provider = "typecast", voiceId = "", aspectRatio = "9:16", subtitleCropMode = "off" }) => {
1425
+ wrapCloudHandler(config, async ({ title = "", sourceVideos, rows, provider, voiceId = "", aspectRatio = "9:16", subtitleCropMode = "off" }) => {
1323
1426
  const sourceIds = new Set(sourceVideos.map((source) => source.id));
1324
1427
  const badRef = rows.flatMap((row) => row.clipRefs).find((ref) => !sourceIds.has(ref.videoId));
1325
1428
  if (badRef) {
1326
1429
  return textResult(`clipRefs 의 videoId "${badRef.videoId}" 가 sourceVideos 에 없습니다.`, { isError: true });
1327
1430
  }
1431
+ const hasNarration = rows.some((row) => ["N", "SN"].includes(row.mode) && row.audioContent?.trim());
1432
+ const selection = hasNarration ? await resolveTtsSelection(api, config, { provider }) : { provider: "typecast", executionTarget: "server" };
1433
+ provider = selection.provider;
1434
+ let preSynthesizedNarrations;
1435
+ if (selection.executionTarget === "companion") {
1436
+ const narrations = {};
1437
+ for (const [rowIndex, row] of rows.entries()) {
1438
+ if (!["N", "SN"].includes(row.mode) || !row.audioContent?.trim()) continue;
1439
+ const text = row.audioContent.trim();
1440
+ const result = await requestTtsBinary(api, config, selection, buildTtsRequestBody(provider, { text, voice: voiceId }));
1441
+ narrations[rowIndex] = { dataUrl: `data:${result.contentType};base64,${result.buffer.toString("base64")}`, charCount: Array.from(text).length, voiceId };
1442
+ }
1443
+ preSynthesizedNarrations = { narrations, attempted: Object.keys(narrations).length, succeeded: Object.keys(narrations).length, lastError: "" };
1444
+ }
1328
1445
  const version = {
1329
1446
  id: "mcp-remake",
1330
1447
  title,
@@ -1342,6 +1459,7 @@ export function registerCloudTools(server, config, api) {
1342
1459
  aspectRatio,
1343
1460
  costEventId: createUsageEventId("mcp-remake-tts"),
1344
1461
  provider,
1462
+ ...(preSynthesizedNarrations ? { preSynthesizedNarrations } : {}),
1345
1463
  subtitleCropMode,
1346
1464
  version,
1347
1465
  voiceId,
@@ -1370,27 +1488,53 @@ export function registerCloudTools(server, config, api) {
1370
1488
  // ── 시간 동기 자막 TTS — 쇼츠 하단자막용 ─────────────────────────────────────
1371
1489
  server.tool(
1372
1490
  "tts_narration_with_captions",
1373
- "ElevenLabs TTS를 char-level 타이밍과 함께 합성해 mp3 파일 + 시간 동기 자막 라인(subtitleLines)을 만듭니다. 쇼츠 하단자막이 필요하면 tts_narration 대신 이걸 쓰고, 결과 subtitleLines·durationSec·filePath를 stitch_timeline에 그대로 넘기세요 (웹 쇼츠 스튜디오와 동일한 정렬 방식). 사용자가 보이스를 지정하지 않았다면 먼저 recommend_voice(provider=elevenlabs)로 자동 선정하세요.",
1491
+ "TTS 오디오와 자막을 만듭니다. 순서 Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 명시 목소리는 fallbackVoices 없이 변경하지 않습니다. ElevenLabs 선택 시 실제 char-level 타이밍을 유지하며 해당 정밀 기능은 대체하지 않습니다. 나머지는 실측 오디오 길이 기반 추정 자막이며 추가 STT 과금은 없습니다.",
1374
1492
  {
1375
1493
  ...productionFields,
1376
1494
  text: z.string().optional().describe("생략 시 제작 핸들의 기획 대본 그대로 합성. 사실·인용을 바꾸거나 뉴스체로 재작성하지 마세요."),
1495
+ ...ttsFallbackFields,
1496
+ provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동 선택 또는 제작 핸들에 선정된 공급자"),
1377
1497
  voice: z.string().optional().describe("보이스 이름 또는 ID (생략 시 기본 보이스)"),
1378
1498
  speed: z.number().optional().describe("기본 1 (0.7~1.2)"),
1379
1499
  modelId: z.string().optional(), stability: z.number().min(0).max(1).optional(),
1380
1500
  style: z.number().min(0).max(1).optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
1381
1501
  },
1382
- wrapCloudHandler(config, async ({ text, voice = "", speed, productionPath, productionProfile, modelId, stability, style, similarityBoost, speakerBoost }) => {
1502
+ wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, modelId, stability, style, similarityBoost, speakerBoost, fallbackVoices, allowProviderFallback }) => {
1383
1503
  const context = readProductionContext(config.stateDir, productionPath);
1384
1504
  productionProfile = context?.productionProfile || productionProfile;
1385
1505
  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 });
1506
+ const selection = await resolveTtsSelection(api, config, { provider, context });
1507
+ provider = selection.provider;
1508
+ if (!voice && context?.voice?.provider && context.voice.provider !== provider) {
1509
+ return textResult("보이스 공급자가 다릅니다. 선택한 provider용 voice를 다시 선정하세요.", { isError: true });
1388
1510
  }
1389
1511
  voice ||= context?.voice?.voiceId || "";
1390
1512
  if (!text?.trim()) return textResult("text 또는 기획 대본이 있는 productionPath가 필요합니다.", { isError: true });
1391
1513
  if (productionProfile?.deliveryStyle === "casual-social" && !voice) {
1392
1514
  return textResult("커뮤니티형 음성을 위해 먼저 recommend_voice에 productionPath를 전달하고, 반환된 productionPath 또는 보이스 ID를 사용하세요.", { isError: true });
1393
1515
  }
1516
+ if (provider !== "elevenlabs") {
1517
+ const body = buildTtsRequestBody(provider, { productionProfile, text, voice, speed, modelId, fallbackVoices, allowProviderFallback, costEventId: createUsageEventId(`mcp-tts-${provider}-captions`) });
1518
+ const result = await requestTtsBinary(api, config, selection, body);
1519
+ provider = result.provider || provider;
1520
+ voice = result.voiceId || voice;
1521
+ const saved = saveMediaBuffer(config.stateDir, provider === "supertonic2" ? "narration-captions.wav" : "narration-captions.mp3", result.buffer, { contentType: result.contentType });
1522
+ const durationSec = audioDurationSecFromFile(saved.filePath);
1523
+ const subtitleLines = estimateTtsSubtitleLines(text, durationSec);
1524
+ const timingSource = "estimated-from-audio-duration";
1525
+ const updatedProductionPath = saveProductionContext(config.stateDir, { ...context, productionProfile, narrationScript: text,
1526
+ ...(result.providerFallback ? { voice: { provider, voiceId: voice, reason: "사용자가 허용한 공급자별 대체 보이스" } } : {}),
1527
+ tts: { provider, durationSec, filePath: saved.filePath, subtitleLines, voiceId: voice, timingSource },
1528
+ });
1529
+ return jsonResult({ productionPath: updatedProductionPath, productionProfile, provider, executionTarget: selection.executionTarget,
1530
+ primaryProvider: result.primaryProvider || provider, providerFallback: result.providerFallback === true,
1531
+ accountCostEvents: result.accountCostEvents || [], hasAccountCostEvents: true,
1532
+ durationSec, durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec), filePath: saved.filePath,
1533
+ lineCount: subtitleLines.length, subtitleLines, timingSource,
1534
+ warnings: [durationSec > 0 ? "자막은 실측 오디오 길이 기준 추정입니다. 최종 렌더 전 발화와 동기를 확인하세요." : "오디오 길이를 측정하지 못해 자막을 만들지 않았습니다. 저장된 오디오를 확인하세요. 자동 재합성하지 않습니다."],
1535
+ next: "이 productionPath를 stitch_timeline에 전달하세요. 추정 자막의 발화 동기는 최종 미리보기에서 확인하세요.",
1536
+ });
1537
+ }
1394
1538
  const payload = await api.request("/api/tracker/tts/elevenlabs-with-timestamps", {
1395
1539
  body: {
1396
1540
  productionProfile, modelId, stability, style, similarityBoost, speakerBoost,
@@ -1421,10 +1565,12 @@ export function registerCloudTools(server, config, api) {
1421
1565
  : audioDurationSecFromFile(saved.filePath);
1422
1566
  const updatedProductionPath = saveProductionContext(config.stateDir, {
1423
1567
  ...context, productionProfile, narrationScript: text,
1424
- tts: { durationSec, filePath: saved.filePath, subtitleLines, voiceId: payload?.voiceId || voice, deliverySettings: payload?.deliverySettings },
1568
+ tts: { provider, durationSec, filePath: saved.filePath, subtitleLines, voiceId: payload?.voiceId || voice, deliverySettings: payload?.deliverySettings, timingSource: "provider-character-alignment" },
1425
1569
  });
1426
1570
  return jsonResult({
1427
1571
  productionPath: updatedProductionPath,
1572
+ provider,
1573
+ timingSource: "provider-character-alignment",
1428
1574
  productionProfile,
1429
1575
  deliverySettings: payload?.deliverySettings,
1430
1576
  durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec),
@@ -1442,10 +1588,12 @@ export function registerCloudTools(server, config, api) {
1442
1588
  "tts_list_voices",
1443
1589
  "TTS 보이스 목록을 조회합니다. 사용자가 목록에서 직접 고르고 싶어할 때 쓰세요 — 주제·샘플 영상 기반 자동 선정은 recommend_voice가 담당합니다. (voice를 생략하면 서버가 기본 보이스로 합성합니다)",
1444
1590
  {
1445
- provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
1591
+ provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2"),
1446
1592
  },
1447
- wrapCloudHandler(config, async ({ provider = "typecast" }) => {
1448
- const payload = await api.request(`/api/tracker/tts/${provider}/voices`, { method: "GET", timeoutMs: 30_000 });
1593
+ wrapCloudHandler(config, async ({ provider }) => {
1594
+ const selection = await resolveTtsSelection(api, config, { provider });
1595
+ provider = selection.provider;
1596
+ const payload = selection.localVoices ? { voices: selection.localVoices } : await api.request(`/api/tracker/tts/${provider}/voices`, { method: "GET", timeoutMs: 30_000 });
1449
1597
  // ElevenLabs 는 gender/age 가 labels 객체 안에 있다 — 두 공급자 형태 모두 지원.
1450
1598
  const voices = (Array.isArray(payload?.voices) ? payload.voices : []).slice(0, 80).map((voice) => ({
1451
1599
  age: voice?.age || voice?.labels?.age,
@@ -1455,7 +1603,7 @@ export function registerCloudTools(server, config, api) {
1455
1603
  nameKo: voice?.voiceNameKo,
1456
1604
  voiceId: voice?.voiceId,
1457
1605
  }));
1458
- return jsonResult({ count: voices.length, provider, voices });
1606
+ return jsonResult({ count: voices.length, provider, executionTarget: selection.executionTarget, voices });
1459
1607
  }),
1460
1608
  );
1461
1609
 
@@ -1467,13 +1615,14 @@ export function registerCloudTools(server, config, api) {
1467
1615
  topic: z.string().optional().describe("영상 주제·타깃 시청층 (예: 시니어 건강 정보, 커머스 쇼츠)"),
1468
1616
  scriptExcerpt: z.string().optional().describe("대본 앞부분 발췌 (선택)"),
1469
1617
  sampleVideoUrl: z.string().optional().describe("사용자가 샘플로 지정한 유튜브 URL 또는 11자 영상 ID"),
1470
- provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
1618
+ provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 키가 없으면 다음 후보, 합성 실패 후 자동 재과금 없음"),
1471
1619
  sampleVoiceHints: z.string().optional().describe("사용자가 말한 보이스 요구 (예: 차분한 중년 남성)"),
1472
1620
  },
1473
1621
  wrapCloudHandler(config, async ({ topic = "", scriptExcerpt = "", sampleVideoUrl = "", provider, sampleVoiceHints = "", productionPath, productionProfile }) => {
1474
1622
  const context = readProductionContext(config.stateDir, productionPath);
1475
1623
  productionProfile = context?.productionProfile || productionProfile;
1476
- provider ||= productionProfile ? "elevenlabs" : "typecast";
1624
+ const selection = await resolveTtsSelection(api, config, { provider, context });
1625
+ provider = selection.provider;
1477
1626
  topic ||= context?.topic || "";
1478
1627
  scriptExcerpt ||= context?.narrationScript?.slice(0, 2000) || "";
1479
1628
  const notes = [];
@@ -1504,7 +1653,7 @@ export function registerCloudTools(server, config, api) {
1504
1653
  return textResult("topic, scriptExcerpt, sampleVoiceHints, sampleVideoUrl 중 하나는 필요합니다.", { isError: true });
1505
1654
  }
1506
1655
  const payload = await api.request("/api/tracker/tts/recommend-voice", {
1507
- body: { provider, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic, productionProfile },
1656
+ body: { provider, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic, productionProfile, ...(selection.localVoices ? { localVoiceCatalog: selection.localVoices } : {}) },
1508
1657
  method: "POST",
1509
1658
  timeoutMs: 180_000,
1510
1659
  });