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.
- package/README.md +48 -5
- package/lib/api-client.mjs +21 -3
- package/lib/cloud-tools.mjs +230 -81
- package/lib/companion-tools.mjs +6 -0
- package/lib/composer-tools.mjs +1 -1
- package/lib/config.mjs +1 -1
- package/lib/production-context.mjs +3 -2
- package/lib/production-schemas.mjs +23 -2
- package/lib/review-full-analysis.mjs +302 -0
- package/lib/review-sources.mjs +106 -0
- package/lib/review-tools.mjs +285 -0
- package/lib/tts-workflow.mjs +88 -0
- package/lib/visual-production.mjs +56 -0
- package/package.json +1 -1
package/lib/cloud-tools.mjs
CHANGED
|
@@ -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 {
|
|
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(["
|
|
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,
|
|
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:
|
|
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
|
-
"
|
|
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
|
-
|
|
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:
|
|
627
|
+
referenceSearchEnabled: false,
|
|
547
628
|
resolution,
|
|
548
|
-
webSearchEnabled:
|
|
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에
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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,
|
|
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
|
-
"씬
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(["
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
1387
|
-
|
|
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(["
|
|
1591
|
+
provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2"),
|
|
1446
1592
|
},
|
|
1447
|
-
wrapCloudHandler(config, async ({ provider
|
|
1448
|
-
const
|
|
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(["
|
|
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
|
-
|
|
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
|
});
|