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.
- package/README.md +28 -3
- package/lib/cloud-tools.mjs +276 -82
- package/lib/companion-client.mjs +1 -0
- package/lib/companion-tools.mjs +27 -1
- package/lib/composer-tools.mjs +88 -0
- package/lib/config.mjs +1 -1
- package/lib/production-context.mjs +62 -0
- package/lib/production-schemas.mjs +43 -0
- package/lib/tts-workflow.mjs +74 -0
- package/lib/visual-production.mjs +56 -0
- package/package.json +2 -1
- package/lib/audio-duration.test.mjs +0 -89
- package/lib/companion-client.test.mjs +0 -71
- package/lib/config.test.mjs +0 -43
- package/lib/generation-jobs.test.mjs +0 -34
- package/lib/media-store.test.mjs +0 -119
- package/lib/mp3-duration.test.mjs +0 -50
- package/lib/payload-guard.test.mjs +0 -98
- package/lib/render-job-store.test.mjs +0 -149
- package/lib/tool-contract.test.mjs +0 -80
- package/lib/wav-trim.test.mjs +0 -89
package/lib/cloud-tools.mjs
CHANGED
|
@@ -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(["
|
|
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
|
-
"
|
|
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
|
-
|
|
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:
|
|
612
|
+
referenceSearchEnabled: false,
|
|
530
613
|
resolution,
|
|
531
|
-
webSearchEnabled:
|
|
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에
|
|
714
|
+
"내레이션 TTS를 합성해 이 PC에 오디오 파일로 저장합니다. 자동 순서: Typecast → ElevenLabs → Supertone → Edge TTS → Supertonic2. 키/런타임이 없을 때 다음 공급자를 고려하며, 합성 실패 후 다른 유료 공급자로 재시도하지 않습니다. 먼저 recommend_voice로 제작 톤에 맞는 보이스를 선택하세요.",
|
|
617
715
|
{
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
|
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
|
-
"씬
|
|
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("
|
|
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
|
-
|
|
760
|
-
|
|
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,
|
|
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
|
-
|
|
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를
|
|
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
|
-
|
|
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(["
|
|
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
|
|
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
|
-
"
|
|
1467
|
+
"TTS 오디오와 자막을 만듭니다. 자동 순서: Typecast→ElevenLabs→Supertone→Edge→Supertonic2, 명시 보이스/공급자는 유지. ElevenLabs는 실제 char-level 타이밍, 나머지는 실측 오디오 길이에 비례한 추정 자막(timingSource 확인)입니다. 먼저 recommend_voice로 제작 톤에 맞는 보이스를 고르세요. 자동 추가 STT 과금이나 다른 공급자로의 재합성은 하지 않습니다.",
|
|
1331
1468
|
{
|
|
1332
|
-
|
|
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
|
|
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(["
|
|
1561
|
+
provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2"),
|
|
1379
1562
|
},
|
|
1380
|
-
wrapCloudHandler(config, async ({ provider
|
|
1381
|
-
const
|
|
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(["
|
|
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 = "
|
|
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,
|