aimakeall-mcp 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,8 +9,8 @@ import { AimakeallApiError } from "./api-client.mjs";
9
9
  import { extractCompanionAudio } from "./companion-client.mjs";
10
10
  import {
11
11
  ALLOWED_IMAGE_EXTS,
12
- ALLOWED_VIDEO_EXTS,
13
12
  assertAllowedInputFile,
13
+ assertAllowedVideoUpload,
14
14
  encodedDataUrlBytes,
15
15
  fileToDataUrl,
16
16
  fileToImagePayload,
@@ -20,11 +20,15 @@ import {
20
20
  savePayloadHandle,
21
21
  SERVER_JSON_BODY_LIMIT_BYTES,
22
22
  } from "./media-store.mjs";
23
- import { mp3DurationSec } from "./mp3-duration.mjs";
23
+ import { audioDurationSecFromFile } from "./audio-duration.mjs";
24
24
  import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
25
25
  import { createUsageEventId } from "./usage-event.mjs";
26
+ import { fetchGenerationJob, GENERATION_JOB_PATH, submitGenerationJob } from "./generation-jobs.mjs";
26
27
  import { loudnessDbfsFromWav, silenceRatioFromWav } from "./wav-dsp.mjs";
27
28
  import { trimWavToSeconds } from "./wav-trim.mjs";
29
+ import { registerComposerTools } from "./composer-tools.mjs";
30
+ import { productionFields, composerTextStyle, composerOverlay } from "./production-schemas.mjs";
31
+ import { saveProductionContext, readProductionContext, durationReview, reviewProductionPayload } from "./production-context.mjs";
28
32
 
29
33
  // 여러 파일을 base64 로 싣는 요청의 인코딩 크기 합이 서버 JSON 상한을 넘지 않게 사전 검사.
30
34
  // 서버가 전체 업로드를 받은 뒤 413 을 내는 것을 막고 행동 가능한 한국어 안내를 준다.
@@ -74,18 +78,6 @@ function isSafePublicImageUrl(rawUrl) {
74
78
  }
75
79
  }
76
80
 
77
- // 실측 mp3 길이(초) — 파싱 실패 시에만 CBR-128 근사로 폴백.
78
- function audioDurationSecFromFile(filePath) {
79
- try {
80
- const real = mp3DurationSec(readFileSync(filePath));
81
- if (real > 0) return real;
82
- } catch {
83
- // fall through
84
- }
85
- const size = statSync(filePath).size;
86
- return Math.round((size * 8) / (128 * 1000) * 10) / 10;
87
- }
88
-
89
81
  // aimakeall.com 클라우드 툴 — PAT 필요. 기획·씬 생성·TTS·SUNO·스티치·퍼블리시.
90
82
  // 렌더는 여기 없다: 스티치가 만든 타임라인 페이로드를 render_start(로컬 컴패니언)로 넘긴다.
91
83
  // 대용량 산출물(오디오·매니페스트·base64 이미지)은 모델 컨텍스트로 돌려주지 않고
@@ -191,6 +183,65 @@ function trimAnalysis(analysis) {
191
183
  }
192
184
 
193
185
  export function registerCloudTools(server, config, api) {
186
+ registerComposerTools(server, config, api);
187
+ async function completedGenerationResult(job, includePreview) {
188
+ const payload = job.result || {};
189
+ const resultBody = { generationJobId: job.id, status: job.status, kind: job.kind,
190
+ model: payload.model, taskId: payload.taskId, providerTasks: job.providerTasks };
191
+ if (job.kind === "image") {
192
+ resultBody.imageUrl = payload.imageUrl || undefined;
193
+ let inlineImage = null;
194
+ if (!payload.imageUrl && payload.imageDataUrl) {
195
+ const match = String(payload.imageDataUrl).match(/^data:([^;]+);base64,(.+)$/s);
196
+ if (match) {
197
+ const ext = match[1].includes("png") ? "png" : "jpg";
198
+ resultBody.savedPath = saveMediaBuffer(config.stateDir, `scene-image.${ext}`, Buffer.from(match[2], "base64")).filePath;
199
+ if (includePreview && ALLOWED_INLINE_IMAGE_MIME.has(match[1].toLowerCase()) && match[2].length <= MAX_INLINE_IMAGE_BYTES * 4 / 3) {
200
+ inlineImage = { base64: match[2], mimeType: match[1].toLowerCase() };
201
+ }
202
+ }
203
+ } else if (includePreview && payload.imageUrl) {
204
+ inlineImage = await downloadImageAsBase64(payload.imageUrl);
205
+ }
206
+ resultBody.qc = inlineImage ? "동봉 이미지를 확인한 뒤 다음 단계로 진행하세요."
207
+ : "verify_scene_image로 검증하세요. imageUrl이 없으면 savedPath를 imagePath로 넘기세요.";
208
+ return inlineImage ? jsonWithImagesResult(resultBody, [inlineImage]) : jsonResult(resultBody);
209
+ }
210
+ Object.assign(resultBody, { videoUrl: payload.videoUrl, durationLabel: payload.durationLabel, resolvedRouteLabel: payload.resolvedRouteLabel });
211
+ let frames = [];
212
+ if (includePreview && payload.videoUrl) {
213
+ try {
214
+ const response = await api.request("/api/tracker/video/frames", {
215
+ method: "POST", body: { count: 3, maxWidth: 480, videoUrl: payload.videoUrl }, timeoutMs: 20_000,
216
+ });
217
+ if (response?.ok && Array.isArray(response.frames)) frames = response.frames;
218
+ } catch { /* A preview failure must not hide the generated video. */ }
219
+ }
220
+ resultBody.qc = frames.length ? "동봉 프레임을 확인한 뒤 stitch_timeline으로 진행하세요." : "verify_scene_video로 영상 품질을 확인하세요.";
221
+ return frames.length ? jsonWithImagesResult(resultBody, frames) : jsonResult(resultBody);
222
+ }
223
+
224
+ server.tool(
225
+ "get_generation_job",
226
+ "generate_scene_image/video가 반환한 generationJobId의 상태·결과를 조회합니다. running이면 잠시 뒤 다시 조회하고, completed의 imageUrl/videoUrl로 다음 단계를 진행하세요. MCP를 재시작해도 같은 ID로 회수할 수 있습니다.",
227
+ { generationJobId: z.string(), includePreview: z.boolean().optional().describe("이미지/대표 프레임 미리보기 포함. 미지정 시 생성 요청의 returnImage/returnFrames 설정 유지") },
228
+ wrapCloudHandler(config, async ({ generationJobId, includePreview }) => {
229
+ const job = await fetchGenerationJob(api, generationJobId);
230
+ if (job.status === "completed") return completedGenerationResult(job, includePreview ?? job.includePreview ?? true);
231
+ return textResult(JSON.stringify({ generationJobId: job.id, kind: job.kind, status: job.status,
232
+ providerTasks: job.providerTasks, error: job.error,
233
+ guidance: job.status === "running" ? "생성 중입니다. 5~10초 후 이 ID를 다시 조회하세요. 새로 생성하지 마세요." : "비용 원장과 providerTasks를 먼저 확인하세요. 실패·중단 작업을 자동 재생성하지 마세요." }, null, 2),
234
+ { isError: job.status === "failed" || job.status === "interrupted" });
235
+ }),
236
+ );
237
+
238
+ server.tool(
239
+ "list_generation_jobs",
240
+ "내 계정의 최근 이미지·영상 생성 작업 ID와 상태를 조회합니다. 연결이 끊기거나 MCP를 재시작했을 때 결과를 복구하고 중복 생성을 피하는 데 사용하세요.",
241
+ {},
242
+ wrapCloudHandler(config, async () => jsonResult(await api.request(GENERATION_JOB_PATH, { timeoutMs: 30_000 }))),
243
+ );
244
+
194
245
  server.tool(
195
246
  "get_usage",
196
247
  "내 플랜과 기능별 렌더 쿼터 현황을 조회합니다. 생성 작업 전에 확인하세요.",
@@ -247,6 +298,8 @@ export function registerCloudTools(server, config, api) {
247
298
  "plan_shorts_video",
248
299
  "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조).",
249
300
  {
301
+ productionProfile: productionFields.productionProfile,
302
+ durationTargetSec: z.number().positive().max(180).optional().describe("사용자가 요청한 목표 길이(예:40). 음성 길이로 덮어쓰지 않습니다."),
250
303
  topic: z.string().optional().describe("영상 주제"),
251
304
  copy: z.string().optional().describe("핵심 카피/대사"),
252
305
  categoryId: z.enum(["community-shorts", "viral-shorts", "insight"]).optional().describe("기본 community-shorts. insight=명언(통찰) 쇼츠"),
@@ -275,13 +328,15 @@ export function registerCloudTools(server, config, api) {
275
328
  forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
276
329
  })).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
277
330
  },
278
- wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 0, characters = [], channelFingerprint = null }) => {
331
+ wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 0, characters = [], channelFingerprint = null, productionProfile, durationTargetSec }) => {
279
332
  if (!String(topic).trim() && !String(copy).trim()) {
280
333
  return textResult("topic 또는 copy 중 하나는 입력해야 합니다.", { isError: true });
281
334
  }
282
335
  const payload = await api.request("/api/tracker/shorts-video/plan", {
283
336
  body: {
284
337
  categoryId,
338
+ productionProfile,
339
+ durationTargetSec,
285
340
  channelFingerprint,
286
341
  copy,
287
342
  operationId: createUsageEventId("shorts-plan"),
@@ -296,7 +351,16 @@ export function registerCloudTools(server, config, api) {
296
351
  method: "POST",
297
352
  timeoutMs: 240_000,
298
353
  });
354
+ const productionPath = saveProductionContext(config.stateDir, {
355
+ topic, productionProfile: payload?.productionProfile, narrationScript: payload?.narrationScript,
356
+ narrationSource: payload?.narrationSource, tagline: payload?.tagline, emphasisKeywords: payload?.emphasisKeywords || [],
357
+ channelFingerprint, scenes: trimPlanScenes(payload?.scenes),
358
+ });
299
359
  return jsonResult({
360
+ productionPath,
361
+ productionProfile: payload?.productionProfile,
362
+ narrationSource: payload?.narrationSource,
363
+ next: "productionPath를 recommend_voice → tts_narration_with_captions → stitch_timeline에 전달하세요. 대본을 뉴스체로 바꾸지 말고, list_composer_presets/search_composer_assets로 편집 소재도 선택하세요.",
300
364
  charactersUsed: Array.isArray(payload?.charactersUsed) ? payload.charactersUsed : undefined,
301
365
  ctaText: payload?.ctaText,
302
366
  emphasisKeywords: Array.isArray(payload?.emphasisKeywords) ? payload.emphasisKeywords : [],
@@ -446,8 +510,9 @@ export function registerCloudTools(server, config, api) {
446
510
 
447
511
  server.tool(
448
512
  "generate_scene_image",
449
- "씬 이미지를 생성합니다 (기획 결과의 imagePrompt 사용). 반환된 imageUrl을 씬 영상 생성의 입력으로 쓰세요. 인물·제품 일관성은 이 참조 방식이 권장 경로입니다: 첫 씬(또는 캐릭터 시트/제품 사진)의 이미지를 referenceImageUrls·referenceImagePaths로 모든 씬에 앵커로 전달하세요. 결과에 이미지 블록이 동봉됩니다 — 반드시 직접 보고 QC(캐릭터 앵커·왜곡·지시 이행) 후 불합격 시 재생성하세요. 이미지를 볼 수 없으면 verify_scene_image 사용.",
513
+ "씬 이미지 생성 작업을 접수하고 즉시 generationJobId를 반환합니다. get_generation_job으로 completed 결과의 imageUrl을 받은 뒤 씬 영상 입력에 쓰세요. 첫 씬/캐릭터/제품 이미지를 referenceImageUrls·referenceImagePaths로 전달하면 일관성 유지에 도움이 됩니다. 완료 이미지는 직접 확인하거나 verify_scene_image로 검증하세요.",
450
514
  {
515
+ requestId: z.string().optional().describe("접수 응답 유실 후 같은 작업을 재확인할 때 기존 generationJobId를 지정합니다. 동일 ID·입력은 중복 생성하지 않습니다."),
451
516
  prompt: z.string().describe("이미지 프롬프트 (plan 결과의 imagePrompt)"),
452
517
  aspectRatio: z.string().optional().describe("기본 9:16"),
453
518
  model: z.enum(["gpt-image-2-beta", "gemini-3.1-flash-image-preview", "doubao-seedream-5.0-lite", "google-flow-nano-banana-pro", "kie-grok-imagine-image"]).optional().describe("이미지 모델, 기본 gpt-image-2-beta"),
@@ -457,7 +522,7 @@ export function registerCloudTools(server, config, api) {
457
522
  referenceImageUrls: z.array(z.string()).max(6).optional().describe("참조 이미지 URL (씬1 앵커 등)"),
458
523
  referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 이미지 로컬 경로 (제품 사진 등)"),
459
524
  },
460
- wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true, identityLock = "" }) => {
525
+ wrapCloudHandler(config, async ({ requestId, prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true, identityLock = "" }) => {
461
526
  // identity_lock 리터럴 부착 — 이미 포함돼 있으면 재부착하지 않는다(멱등: 프롬프트 캐시 보존).
462
527
  const lock = String(identityLock || "").trim();
463
528
  if (lock && !prompt.includes(lock)) prompt = `${prompt.trim()}, ${lock}`;
@@ -471,8 +536,9 @@ export function registerCloudTools(server, config, api) {
471
536
  ...referenceImageUrls.map((url, index) => ({ name: `ref-${index + 1}`, previewUrl: url })),
472
537
  ].slice(0, 6);
473
538
 
474
- const payload = await api.request("/api/tracker/gemini/storyboard-scene-image", {
475
- body: {
539
+ return jsonResult(await submitGenerationJob(api, {
540
+ kind: "image", includePreview: returnImage, requestId,
541
+ input: {
476
542
  aspectRatio,
477
543
  model,
478
544
  prompt,
@@ -481,38 +547,7 @@ export function registerCloudTools(server, config, api) {
481
547
  resolution,
482
548
  webSearchEnabled: true,
483
549
  },
484
- method: "POST",
485
- timeoutMs: 240_000,
486
- });
487
-
488
- let imageUrl = String(payload?.imageUrl || "");
489
- let savedPath = "";
490
- let inlineImage = null;
491
- if (!imageUrl && payload?.imageDataUrl) {
492
- // URL 없이 base64만 온 경우 — 컨텍스트로 돌려주지 않고 파일로 저장.
493
- const match = String(payload.imageDataUrl).match(/^data:([^;]+);base64,(.+)$/s);
494
- if (match) {
495
- const ext = match[1].includes("png") ? "png" : "jpg";
496
- const saved = saveMediaBuffer(config.stateDir, `scene-image.${ext}`, Buffer.from(match[2], "base64"));
497
- savedPath = saved.filePath;
498
- const inlineMime = String(match[1]).toLowerCase();
499
- if (returnImage && ALLOWED_INLINE_IMAGE_MIME.has(inlineMime) && match[2].length <= MAX_INLINE_IMAGE_BYTES * 4 / 3) {
500
- inlineImage = { base64: match[2], mimeType: inlineMime };
501
- }
502
- }
503
- } else if (imageUrl && returnImage) {
504
- inlineImage = await downloadImageAsBase64(imageUrl);
505
- }
506
- const resultBody = {
507
- imageUrl: imageUrl || undefined,
508
- model: payload?.model,
509
- qc: inlineImage
510
- ? "동봉된 이미지를 직접 확인하세요: 캐릭터 앵커 일치·손가락/글자 왜곡·프롬프트 이행. 불합격이면 사유를 프롬프트에 반영해 재생성하세요. 이미지를 볼 수 없는 클라이언트라면 verify_scene_image 를 호출하세요."
511
- : "이미지를 동봉하지 못했습니다 — verify_scene_image 로 서버측 검증을 수행하세요 (imageUrl 이 없으면 savedPath 를 imagePath 로 넘기세요).",
512
- savedPath: savedPath || undefined,
513
- taskId: payload?.taskId,
514
- };
515
- return inlineImage ? jsonWithImagesResult(resultBody, [inlineImage]) : jsonResult(resultBody);
550
+ }));
516
551
  }),
517
552
  );
518
553
 
@@ -559,8 +594,9 @@ export function registerCloudTools(server, config, api) {
559
594
 
560
595
  server.tool(
561
596
  "generate_scene_video",
562
- "씬 영상을 생성합니다 (동기 호출, 최대 ~30분 — MCP 클라이언트의 툴 타임아웃(MCP_TIMEOUT)을 그 이상으로 늘려두세요. 타임아웃되면 이미 과금된 결과를 회수할 수 없으니 주의). 반환된 videoUrl을 stitch_timeline의 sceneVideos에 넣으세요. 결과에 대표 프레임 3장이 동봉됩니다 — 반드시 직접 보고 QC 후 불합격 시 재생성하세요. 볼 수 없으면 verify_scene_video 사용.",
597
+ "씬 영상 생성 작업을 접수하고 즉시 generationJobId를 반환합니다. get_generation_job으로 completed가 될 때까지 조회한 뒤 videoUrl을 stitch_timeline에 넣으세요. 접수만 된 상태에서 새로 생성하지 마세요.",
563
598
  {
599
+ requestId: z.string().optional().describe("접수 응답 유실 후 동일 입력을 재접수할 때 기존 generationJobId를 지정합니다."),
564
600
  prompt: z.string().describe("영상 프롬프트 (generate_scene_video_prompt의 videoPrompt)"),
565
601
  sceneImageUrl: z.string().optional().describe("씬 이미지 URL (i2v 입력)"),
566
602
  aspectRatio: z.string().optional().describe("기본 9:16"),
@@ -569,9 +605,10 @@ export function registerCloudTools(server, config, api) {
569
605
  quality: z.string().optional().describe("기본 720p"),
570
606
  returnFrames: z.boolean().optional().describe("기본 true — 결과 영상의 대표 프레임 3장을 직접 보고 QC 할 수 있게 이미지 블록으로 반환"),
571
607
  },
572
- wrapCloudHandler(config, async ({ prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p", returnFrames = true }) => {
573
- const payload = await api.request("/api/tracker/gemini/storyboard-scene-video", {
574
- body: {
608
+ wrapCloudHandler(config, async ({ requestId, prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p", returnFrames = true }) => {
609
+ return jsonResult(await submitGenerationJob(api, {
610
+ kind: "video", includePreview: returnFrames, requestId,
611
+ input: {
575
612
  aspectRatio,
576
613
  durationLabel: `${Math.max(1, Math.ceil(durationSec))}s`,
577
614
  generationPlan: {
@@ -587,35 +624,7 @@ export function registerCloudTools(server, config, api) {
587
624
  selectedVideoModel: { id: modelId },
588
625
  videoGenerationSettings: { inputMode: sceneImageUrl ? "image" : "text", quality },
589
626
  },
590
- method: "POST",
591
- // 서버 최악 경로(KIE 900s + Evolink 폴백 3회×900s)를 넘겨 잡아 조기 abort 로 과금-미회수를 방지.
592
- timeoutMs: 1_800_000,
593
- });
594
- // 대표 프레임 동봉(1층 QC) — 실패는 조용히 넘기고 verify_scene_video 안내로 대체.
595
- let frames = [];
596
- if (returnFrames && payload?.videoUrl) {
597
- try {
598
- const frameResp = await api.request("/api/tracker/video/frames", {
599
- body: { count: 3, maxWidth: 480, videoUrl: payload.videoUrl },
600
- method: "POST",
601
- timeoutMs: 60_000, // 과금 완료된 결과를 오래 붙들지 않기 — 실패해도 verify 폴백 안내
602
- });
603
- if (frameResp?.ok && Array.isArray(frameResp?.frames)) frames = frameResp.frames;
604
- } catch {
605
- frames = [];
606
- }
607
- }
608
- const resultBody = {
609
- durationLabel: payload?.durationLabel,
610
- model: payload?.model,
611
- qc: frames.length
612
- ? "동봉된 대표 프레임 3장을 직접 확인하세요: 캐릭터 붕괴·모션 파탄·왜곡. 불합격이면 프롬프트를 교정해 재생성하세요. 프레임을 볼 수 없는 클라이언트라면 verify_scene_video 를 호출하세요."
613
- : "프레임을 동봉하지 못했습니다 — verify_scene_video 로 서버측 검증을 수행하세요.",
614
- resolvedRouteLabel: payload?.resolvedRouteLabel,
615
- taskId: payload?.taskId,
616
- videoUrl: payload?.videoUrl,
617
- };
618
- return frames.length ? jsonWithImagesResult(resultBody, frames) : jsonResult(resultBody);
627
+ }));
619
628
  }),
620
629
  );
621
630
 
@@ -623,14 +632,26 @@ export function registerCloudTools(server, config, api) {
623
632
  "tts_narration",
624
633
  "내레이션 TTS를 합성해 이 PC에 mp3로 저장하고 파일 경로를 반환합니다. stitch_timeline의 ttsAudioPath로 쓰세요. 사용자가 보이스를 직접 지정하지 않았다면 먼저 recommend_voice로 주제·샘플 영상에 맞는 보이스를 자동 선정해 voice에 넣으세요.",
625
634
  {
626
- text: z.string().describe("내레이션 텍스트"),
635
+ ...productionFields,
636
+ text: z.string().optional().describe("생략 시 productionPath의 기획 대본"),
627
637
  provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
628
638
  voice: z.string().optional().describe("보이스 이름(라벨)"),
629
639
  speed: z.number().optional().describe("기본 1"),
640
+ stability: z.number().min(0).max(1).optional(), style: z.number().min(0).max(1).optional(),
641
+ modelId: z.string().optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
630
642
  },
631
- wrapCloudHandler(config, async ({ text, provider = "typecast", voice = "", speed = 1 }) => {
643
+ wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, stability, style, modelId, similarityBoost, speakerBoost }) => {
644
+ const context = readProductionContext(config.stateDir, productionPath);
645
+ productionProfile = context?.productionProfile || productionProfile;
646
+ text ??= context?.narrationScript;
647
+ provider ||= context?.voice?.provider || (productionProfile ? "elevenlabs" : "typecast");
648
+ if (!voice && context?.voice?.provider && context.voice.provider !== provider) return textResult("보이스 공급자가 다릅니다. 선택한 provider용 voice를 다시 선정하세요.", { isError: true });
649
+ voice ||= context?.voice?.voiceId || "";
650
+ if (!text?.trim()) return textResult("대본(text/productionPath)이 필요합니다.", { isError: true });
651
+ if (productionProfile?.deliveryStyle === "casual-social" && !voice) return textResult("먼저 recommend_voice에 productionPath를 전달해 커뮤니티형 보이스를 선정하세요.", { isError: true });
632
652
  const body = provider === "typecast"
633
653
  ? {
654
+ productionProfile,
634
655
  breath: 0.3,
635
656
  costEventId: createUsageEventId("mcp-tts-typecast"),
636
657
  emotion: "neutral",
@@ -645,15 +666,16 @@ export function registerCloudTools(server, config, api) {
645
666
  volume: 100,
646
667
  }
647
668
  : {
669
+ productionProfile,
648
670
  costEventId: createUsageEventId("mcp-tts-elevenlabs"),
649
671
  fileName: "mcp-narration",
650
672
  languageCode: "ko",
651
- modelId: "",
652
- similarityBoost: 0.8,
653
- speakerBoost: true,
673
+ modelId,
674
+ similarityBoost,
675
+ speakerBoost,
654
676
  speed,
655
- stability: 0.45,
656
- style: 0.35,
677
+ stability,
678
+ style,
657
679
  text,
658
680
  voice,
659
681
  };
@@ -663,10 +685,13 @@ export function registerCloudTools(server, config, api) {
663
685
  timeoutMs: 120_000,
664
686
  });
665
687
  const saved = saveMediaBuffer(config.stateDir, "narration.mp3", result.buffer, { contentType: result.contentType });
688
+ const durationSec = audioDurationSecFromFile(saved.filePath);
666
689
  return jsonResult({
690
+ productionPath: saveProductionContext(config.stateDir, { ...context, productionProfile, narrationScript: text, tts: { filePath: saved.filePath, durationSec, voiceId: voice } }),
691
+ durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec),
667
692
  bytes: saved.bytes,
668
693
  // 실측 mp3 길이 — stitch_timeline 의 audioDurationSec 로 그대로 쓰면 타임라인이 정확하다.
669
- durationSec: audioDurationSecFromFile(saved.filePath),
694
+ durationSec,
670
695
  filePath: saved.filePath,
671
696
  voiceName: decodeHeaderValue(
672
697
  result.headers.get("x-typecast-voice-name") || result.headers.get("x-elevenlabs-voice-name") || "",
@@ -745,6 +770,12 @@ export function registerCloudTools(server, config, api) {
745
770
  "stitch_timeline",
746
771
  "씬 영상들과 오디오를 서버에서 타임라인 매니페스트로 합칩니다. 결과는 파일 핸들(payloadPath)로 반환되며, 이를 render_start에 넘기면 이 PC에서 mp4가 렌더됩니다. 서버는 렌더하지 않습니다. 하단자막 강조: emphasisKeywords 를 주면 자막 라인 안의 해당 단어만 다른 색/크기로 강조되고, subtitleLines[].style 로 라인별 디자인도 바꿀 수 있습니다.",
747
772
  {
773
+ ...productionFields,
774
+ titleStylePresetId: z.string().optional(), subtitleStylePresetId: z.string().optional(),
775
+ titleStyle: composerTextStyle.optional(), subtitleStyle: composerTextStyle.optional(),
776
+ titleSegments: z.array(z.object({ text: z.string(), color: z.string().optional() })).max(20).optional(),
777
+ overlays: z.array(composerOverlay).max(40).optional().describe("검색한 소재를 장면에 맞는 시간/위치에 배치. 배경 이미지에 그려진 문양은 편집 소재 사용으로 세지 않습니다."),
778
+ decorationRationale: z.string().max(500).optional().describe("소재를 생략한 이유. 피해자 희화화·권리 위험이면 밈 대신 중립적 아이콘을 검토하세요."),
748
779
  sceneVideos: z.array(z.object({
749
780
  durationSec: z.number(),
750
781
  idx: z.number().int(),
@@ -764,17 +795,8 @@ export function registerCloudTools(server, config, api) {
764
795
  endSec: z.number(),
765
796
  startSec: z.number(),
766
797
  text: z.string(),
767
- style: z.object({
768
- fontSize: z.number().optional(),
769
- fontWeight: z.enum(["bold", "normal"]).optional(),
770
- strokeColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
771
- strokeWidth: z.number().optional(),
772
- textBackgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
773
- textBackgroundEnabled: z.boolean().optional(),
774
- textBackgroundOpacity: z.number().optional(),
775
- textColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
776
- textEffectVariant: z.string().optional(),
777
- }).optional().describe("이 라인만의 디자인 오버라이드 (내용에 따라 라인별 디자인을 달리할 때)"),
798
+ stylePresetId: z.string().optional(),
799
+ style: composerTextStyle.optional(),
778
800
  })).optional().describe("자막 타이밍 (쇼츠 하단자막) — tts_narration_with_captions의 subtitleLines를 그대로 사용"),
779
801
  emphasisKeywords: z.array(z.string()).max(12).optional().describe("강조 단어 목록 — 자막 라인 안에서 해당 단어만 다른 색/크기로 렌더 (plan_shorts_video의 emphasisKeywords 를 그대로 전달)"),
780
802
  emphasisColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional().describe("강조 색 #RRGGBB (기본 #facc15 노랑)"),
@@ -786,7 +808,15 @@ export function registerCloudTools(server, config, api) {
786
808
  }).passthrough().optional().describe("measure_channel_spec 이 반환한 fingerprint — 자막 세로 위치·BGM 유무를 참고 채널에 맞춥니다"),
787
809
  transitionMode: z.enum(["fade", "none"]).optional().describe("기본 none(하드컷). fade 는 씬이 맞대기로 붙는 배치에서 컷마다 검은 딥을 만든다 — 명시 요청 시에만 쓸 것"),
788
810
  },
789
- wrapCloudHandler(config, async ({ sceneVideos, featureKey = "commerceVideo", projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines = [], emphasisKeywords = [], emphasisColor = "", emphasisSizeScale = 0, transitionMode = "none", channelFingerprint = null }) => {
811
+ wrapCloudHandler(config, async ({ sceneVideos, featureKey, projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines, emphasisKeywords, emphasisColor = "", emphasisSizeScale = 0, transitionMode = "none", channelFingerprint = null, productionPath, productionProfile, titleStylePresetId, subtitleStylePresetId, titleStyle, subtitleStyle, titleSegments, overlays, decorationRationale }) => {
812
+ const context = readProductionContext(config.stateDir, productionPath);
813
+ productionProfile = context?.productionProfile || productionProfile;
814
+ ttsAudioPath ||= context?.tts?.filePath || "";
815
+ titleText ||= context?.tagline || "";
816
+ subtitleLines ??= context?.tts?.subtitleLines || [];
817
+ emphasisKeywords ??= context?.emphasisKeywords || [];
818
+ featureKey ||= productionProfile?.categoryId?.endsWith("shorts") ? "shortsVideo" : "commerceVideo";
819
+ channelFingerprint ||= context?.channelFingerprint;
790
820
  // 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
791
821
  if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
792
822
  if (bgmAudioPath) assertAllowedInputFile(bgmAudioPath, new Set([".mp3", ".wav"]), { kind: "BGM 오디오" });
@@ -796,9 +826,10 @@ export function registerCloudTools(server, config, api) {
796
826
  ? audioDurationSec
797
827
  : (ttsAudioPath ? audioDurationSecFromFile(ttsAudioPath) : 0);
798
828
  const body = {
829
+ productionProfile, titleStylePresetId, subtitleStylePresetId, titleStyle, subtitleStyle, titleSegments, overlays,
799
830
  aspectRatio,
800
831
  audioDurationSec: resolvedAudioDurationSec,
801
- bgmAudioUrl: bgmAudioPath ? fileToDataUrl(bgmAudioPath, { mimeType: "audio/mpeg" }) : "",
832
+ bgmAudioUrl: bgmAudioPath ? fileToDataUrl(bgmAudioPath) : "",
802
833
  bgmVolume,
803
834
  featureKey,
804
835
  muteVideoAudio,
@@ -816,7 +847,7 @@ export function registerCloudTools(server, config, api) {
816
847
  subtitleLines,
817
848
  titleText,
818
849
  transitionMode,
819
- ttsAudioUrl: ttsAudioPath ? fileToDataUrl(ttsAudioPath, { mimeType: "audio/mpeg" }) : "",
850
+ ttsAudioUrl: ttsAudioPath ? fileToDataUrl(ttsAudioPath) : "",
820
851
  };
821
852
  const response = await api.request("/api/tracker/commerce-video/stitch", {
822
853
  body,
@@ -826,11 +857,15 @@ export function registerCloudTools(server, config, api) {
826
857
  if (!response?.ok || !response?.payload) {
827
858
  return textResult(`스티치 실패: ${response?.error || "매니페스트가 비어 있습니다."}`, { isError: true });
828
859
  }
860
+ if (context || productionProfile) response.payload.productionContext = { ...context, productionProfile, decorationRationale };
861
+ const qualityReview = reviewProductionPayload(response.payload);
829
862
  const handle = savePayloadHandle(config.stateDir, response.payload, "stitch");
830
863
  return jsonResult({
831
864
  assetCount: Array.isArray(response.payload.assets) ? response.payload.assets.length : 0,
832
865
  clipCount: Array.isArray(response.payload.studio?.clips) ? response.payload.studio.clips.length : 0,
833
- next: "render_start에 payloadPath를 넘겨 이 PC에서 렌더하세요.",
866
+ next: qualityReview.pass ? "qualityReview 경고를 검토한 뒤 render_start에 payloadPath를 전달하세요. 최종 영상 프레임과 음성도 검토해야 합니다." : "qualityReview.errors의 길이·소재 선택 검토 문제를 먼저 해결하세요. render_start는 제작 규격 불일치를 차단합니다.",
867
+ qualityReview,
868
+ compositionReport: response.payload.compositionReport,
834
869
  payloadPath: handle.filePath,
835
870
  projectTitle: response.payload.projectTitle,
836
871
  sizeBytes: handle.bytes,
@@ -895,8 +930,7 @@ export function registerCloudTools(server, config, api) {
895
930
  notifySubscribers: z.boolean().optional().describe("기본 true"),
896
931
  },
897
932
  wrapCloudHandler(config, async ({ videoPath, title, description = "", tags = [], categoryId = "", visibility = "private", madeForKids = false, notifySubscribers = true }) => {
898
- assertAllowedInputFile(videoPath, ALLOWED_VIDEO_EXTS, { kind: "영상 파일" });
899
- const stats = statSync(videoPath);
933
+ const stats = assertAllowedVideoUpload(videoPath);
900
934
  // 메타데이터가 쿼리스트링을 타므로 한국어 설명의 퍼센트 인코딩 팽창으로 헤더 상한(16KB)을
901
935
  // 넘지 않도록 보수적으로 제한한다.
902
936
  const safeTitle = title.slice(0, 100);
@@ -1338,13 +1372,28 @@ export function registerCloudTools(server, config, api) {
1338
1372
  "tts_narration_with_captions",
1339
1373
  "ElevenLabs TTS를 char-level 타이밍과 함께 합성해 mp3 파일 + 시간 동기 자막 라인(subtitleLines)을 만듭니다. 쇼츠 하단자막이 필요하면 tts_narration 대신 이걸 쓰고, 결과 subtitleLines·durationSec·filePath를 stitch_timeline에 그대로 넘기세요 (웹 쇼츠 스튜디오와 동일한 정렬 방식). 사용자가 보이스를 지정하지 않았다면 먼저 recommend_voice(provider=elevenlabs)로 자동 선정하세요.",
1340
1374
  {
1341
- text: z.string().describe("내레이션 텍스트"),
1375
+ ...productionFields,
1376
+ text: z.string().optional().describe("생략 시 제작 핸들의 기획 대본 그대로 합성. 사실·인용을 바꾸거나 뉴스체로 재작성하지 마세요."),
1342
1377
  voice: z.string().optional().describe("보이스 이름 또는 ID (생략 시 기본 보이스)"),
1343
1378
  speed: z.number().optional().describe("기본 1 (0.7~1.2)"),
1379
+ modelId: z.string().optional(), stability: z.number().min(0).max(1).optional(),
1380
+ style: z.number().min(0).max(1).optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
1344
1381
  },
1345
- wrapCloudHandler(config, async ({ text, voice = "", speed = 1 }) => {
1382
+ wrapCloudHandler(config, async ({ text, voice = "", speed, productionPath, productionProfile, modelId, stability, style, similarityBoost, speakerBoost }) => {
1383
+ const context = readProductionContext(config.stateDir, productionPath);
1384
+ productionProfile = context?.productionProfile || productionProfile;
1385
+ text ??= context?.narrationScript;
1386
+ if (!voice && context?.voice?.provider && context.voice.provider !== "elevenlabs") {
1387
+ return textResult("이 제작 핸들의 보이스는 ElevenLabs용이 아닙니다. recommend_voice(provider=elevenlabs)로 다시 선정하거나 ElevenLabs voice를 명시하세요.", { isError: true });
1388
+ }
1389
+ voice ||= context?.voice?.voiceId || "";
1390
+ if (!text?.trim()) return textResult("text 또는 기획 대본이 있는 productionPath가 필요합니다.", { isError: true });
1391
+ if (productionProfile?.deliveryStyle === "casual-social" && !voice) {
1392
+ return textResult("커뮤니티형 음성을 위해 먼저 recommend_voice에 productionPath를 전달하고, 반환된 productionPath 또는 보이스 ID를 사용하세요.", { isError: true });
1393
+ }
1346
1394
  const payload = await api.request("/api/tracker/tts/elevenlabs-with-timestamps", {
1347
1395
  body: {
1396
+ productionProfile, modelId, stability, style, similarityBoost, speakerBoost,
1348
1397
  costEventId: createUsageEventId("mcp-tts-elevenlabs-ts"),
1349
1398
  languageCode: "ko",
1350
1399
  speed,
@@ -1370,7 +1419,16 @@ export function registerCloudTools(server, config, api) {
1370
1419
  const durationSec = Array.isArray(charEnds) && charEnds.length
1371
1420
  ? Math.round(Number(charEnds[charEnds.length - 1]) * 10) / 10
1372
1421
  : audioDurationSecFromFile(saved.filePath);
1422
+ const updatedProductionPath = saveProductionContext(config.stateDir, {
1423
+ ...context, productionProfile, narrationScript: text,
1424
+ tts: { durationSec, filePath: saved.filePath, subtitleLines, voiceId: payload?.voiceId || voice, deliverySettings: payload?.deliverySettings },
1425
+ });
1373
1426
  return jsonResult({
1427
+ productionPath: updatedProductionPath,
1428
+ productionProfile,
1429
+ deliverySettings: payload?.deliverySettings,
1430
+ durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec),
1431
+ next: "이 productionPath를 stitch_timeline으로 전달하세요. 목표보다 짧으면 자동 재과금 없이 먼저 대본/말속도와 재합성 비용을 검토하세요. 자막은 실제 발화에 맞추고 강조는 편집 스타일로 적용하세요.",
1374
1432
  durationSec,
1375
1433
  filePath: saved.filePath,
1376
1434
  lineCount: subtitleLines.length,
@@ -1405,13 +1463,19 @@ export function registerCloudTools(server, config, api) {
1405
1463
  "recommend_voice",
1406
1464
  "주제·대본과 사용자가 샘플로 지정한 영상(유튜브)에 어울리는 TTS 보이스를 LLM이 자동 선정합니다. 사용자가 보이스를 직접 지정하지 않았다면 tts_narration/tts_narration_with_captions 전에 이 도구를 호출해 recommendations[0].voiceId를 voice로 쓰세요. 샘플 영상이 있으면 컴패니언 앱이 화자 음성을 추출·분석해 가장 비슷한 보이스를 우선합니다(컴패니언 미실행 시 주제 기반으로만 추천).",
1407
1465
  {
1466
+ ...productionFields,
1408
1467
  topic: z.string().optional().describe("영상 주제·타깃 시청층 (예: 시니어 건강 정보, 커머스 쇼츠)"),
1409
1468
  scriptExcerpt: z.string().optional().describe("대본 앞부분 발췌 (선택)"),
1410
1469
  sampleVideoUrl: z.string().optional().describe("사용자가 샘플로 지정한 유튜브 URL 또는 11자 영상 ID"),
1411
1470
  provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
1412
1471
  sampleVoiceHints: z.string().optional().describe("사용자가 말한 보이스 요구 (예: 차분한 중년 남성)"),
1413
1472
  },
1414
- wrapCloudHandler(config, async ({ topic = "", scriptExcerpt = "", sampleVideoUrl = "", provider = "typecast", sampleVoiceHints = "" }) => {
1473
+ wrapCloudHandler(config, async ({ topic = "", scriptExcerpt = "", sampleVideoUrl = "", provider, sampleVoiceHints = "", productionPath, productionProfile }) => {
1474
+ const context = readProductionContext(config.stateDir, productionPath);
1475
+ productionProfile = context?.productionProfile || productionProfile;
1476
+ provider ||= productionProfile ? "elevenlabs" : "typecast";
1477
+ topic ||= context?.topic || "";
1478
+ scriptExcerpt ||= context?.narrationScript?.slice(0, 2000) || "";
1415
1479
  const notes = [];
1416
1480
  let sampleAudioBase64 = "";
1417
1481
  const rawSample = String(sampleVideoUrl || "").trim();
@@ -1440,7 +1504,7 @@ export function registerCloudTools(server, config, api) {
1440
1504
  return textResult("topic, scriptExcerpt, sampleVoiceHints, sampleVideoUrl 중 하나는 필요합니다.", { isError: true });
1441
1505
  }
1442
1506
  const payload = await api.request("/api/tracker/tts/recommend-voice", {
1443
- body: { provider, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic },
1507
+ body: { provider, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic, productionProfile },
1444
1508
  method: "POST",
1445
1509
  timeoutMs: 180_000,
1446
1510
  });
@@ -1451,6 +1515,8 @@ export function registerCloudTools(server, config, api) {
1451
1515
  );
1452
1516
  }
1453
1517
  return jsonResult({
1518
+ productionPath: context || productionProfile ? saveProductionContext(config.stateDir, { ...context, productionProfile, voice: { ...payload?.recommendations?.[0], provider } }) : undefined,
1519
+ productionProfile,
1454
1520
  next: "recommendations[0].voiceId를 tts_narration 또는 tts_narration_with_captions의 voice로 사용하세요.",
1455
1521
  notes: [...notes, ...(Array.isArray(payload?.notes) ? payload.notes : [])],
1456
1522
  provider: payload?.provider || provider,
@@ -1,4 +1,5 @@
1
- import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, createWriteStream, mkdirSync, openSync, renameSync, rmSync, statSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { Readable } from "node:stream";
4
5
  import { pipeline } from "node:stream/promises";
@@ -66,6 +67,7 @@ export async function triageCompanion(companionUrl) {
66
67
  detail: `moviepy ${moviepy.payload.moviepy.version} (${moviepy.payload.moviepy.label})`,
67
68
  state: "ready",
68
69
  version: String(health.payload?.version || ""),
70
+ capabilities: moviepy.payload?.capabilities || {},
69
71
  };
70
72
  }
71
73
 
@@ -157,20 +159,31 @@ export function parseContentDispositionFilename(headerValue) {
157
159
  * 성공 시 컴패니언이 파일을 삭제(1회성)하므로 저장 성공 후에만 소비된 것으로 본다.
158
160
  * 반환: { filePath, bytes } 또는 { error, status }
159
161
  */
160
- // 사용된 파일명이 이미 있으면 -2, -3 … 을 붙여 기존 출력을 덮어쓰지 않는다.
161
- function uniqueOutputPath(dir, fileName) {
162
+ // wx 로 최종 경로를 먼저 예약한다. 존재 검사만 하면 여러 프로세스가 같은
163
+ // 이름과 .part 파일을 골라 서로의 다운로드를 덮어쓴다.
164
+ function reserveOutputPath(dir, fileName) {
162
165
  const ext = path.extname(fileName);
163
166
  const base = fileName.slice(0, fileName.length - ext.length);
164
167
  let candidate = path.join(dir, fileName);
165
168
  let counter = 2;
166
- while (existsSync(candidate)) {
167
- candidate = path.join(dir, `${base}-${counter}${ext}`);
168
- counter += 1;
169
+ while (true) {
170
+ try {
171
+ closeSync(openSync(candidate, "wx", 0o600));
172
+ return candidate;
173
+ } catch (error) {
174
+ if (error?.code !== "EEXIST") throw error;
175
+ candidate = path.join(dir, `${base}-${counter}${ext}`);
176
+ counter += 1;
177
+ }
169
178
  }
170
- return candidate;
171
179
  }
172
180
 
173
181
  export async function downloadRenderOutput(companionUrl, jobId, outputDir, { suggestedName = "" } = {}) {
182
+ try {
183
+ mkdirSync(outputDir, { recursive: true });
184
+ } catch (error) {
185
+ return { error: `출력 폴더를 준비하지 못했습니다: ${String(error?.message || error)}`, status: 0 };
186
+ }
174
187
  let response;
175
188
  try {
176
189
  response = await fetch(
@@ -193,17 +206,20 @@ export async function downloadRenderOutput(companionUrl, jobId, outputDir, { sug
193
206
  const headerName = parseContentDispositionFilename(response.headers.get("content-disposition"));
194
207
  let fileName = sanitizeOutputFilename(suggestedName || headerName, `render-${jobId}.mp4`);
195
208
  if (!/\.[A-Za-z0-9]{1,5}$/u.test(fileName)) fileName += ".mp4"; // 확장자 없으면 mp4 보정
196
- mkdirSync(outputDir, { recursive: true });
197
- const finalPath = uniqueOutputPath(outputDir, fileName);
198
- const partPath = `${finalPath}.part`;
199
-
209
+ let finalPath = "";
210
+ let partPath = "";
200
211
  try {
201
- await pipeline(Readable.fromWeb(response.body), createWriteStream(partPath, { mode: 0o600 }));
212
+ finalPath = reserveOutputPath(outputDir, fileName);
213
+ partPath = `${finalPath}.${randomUUID()}.part`;
214
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(partPath, { flags: "wx", mode: 0o600 }));
215
+ const bytes = statSync(partPath).size;
202
216
  renameSync(partPath, finalPath);
217
+ return { bytes, filePath: finalPath };
203
218
  } catch (error) {
204
219
  // 중단 시 부분 파일 정리 — 잡은 다운로드 실패로 남아 재시도 가능(네트워크 abort 시 서버가 복구).
205
- try { rmSync(partPath, { force: true }); } catch { /* ignore */ }
220
+ await response.body?.cancel().catch(() => {});
221
+ if (partPath) { try { rmSync(partPath, { force: true }); } catch { /* ignore */ } }
222
+ if (finalPath) { try { rmSync(finalPath, { force: true }); } catch { /* ignore */ } }
206
223
  return { error: `출력 저장 중 오류: ${String(error?.message || error)} — render_status로 상태 확인 후 다시 시도하세요.`, status: 0 };
207
224
  }
208
- return { bytes: statSync(finalPath).size, filePath: finalPath };
209
225
  }