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