aimakeall-mcp 0.1.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.
@@ -0,0 +1,644 @@
1
+
2
+ import { createReadStream, readFileSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { Readable } from "node:stream";
5
+
6
+ import { z } from "zod";
7
+
8
+ import { AimakeallApiError } from "./api-client.mjs";
9
+ import {
10
+ ALLOWED_IMAGE_EXTS,
11
+ ALLOWED_VIDEO_EXTS,
12
+ assertAllowedInputFile,
13
+ encodedDataUrlBytes,
14
+ fileToDataUrl,
15
+ fileToImagePayload,
16
+ saveMediaBuffer,
17
+ savePayloadHandle,
18
+ SERVER_JSON_BODY_LIMIT_BYTES,
19
+ } from "./media-store.mjs";
20
+ import { mp3DurationSec } from "./mp3-duration.mjs";
21
+ import { createUsageEventId } from "./usage-event.mjs";
22
+
23
+ // 여러 파일을 base64 로 싣는 요청의 인코딩 크기 합이 서버 JSON 상한을 넘지 않게 사전 검사.
24
+ // 서버가 전체 업로드를 받은 뒤 413 을 내는 것을 막고 행동 가능한 한국어 안내를 준다.
25
+ const BODY_BUDGET_BYTES = SERVER_JSON_BODY_LIMIT_BYTES - 512 * 1024; // 바디의 나머지 필드 여유
26
+
27
+ function assertEncodedBudget(filePaths, { context = "요청" } = {}) {
28
+ let total = 0;
29
+ for (const filePath of filePaths.filter(Boolean)) {
30
+ total += encodedDataUrlBytes(statSync(filePath).size);
31
+ }
32
+ if (total > BODY_BUDGET_BYTES) {
33
+ throw new Error(
34
+ `${context}의 총 크기가 서버 상한(약 ${Math.round(BODY_BUDGET_BYTES / 1024 / 1024)}MB)을 초과합니다. 더 짧거나 낮은 비트레이트의 오디오/작은 이미지를 사용하세요.`,
35
+ );
36
+ }
37
+ }
38
+
39
+ // 서버가 헤더에 encodeURIComponent 로 실어 보낸 값(한국어 보이스명 등)을 복원.
40
+ function decodeHeaderValue(value) {
41
+ try {
42
+ return decodeURIComponent(String(value || ""));
43
+ } catch {
44
+ return String(value || "");
45
+ }
46
+ }
47
+
48
+ // 실측 mp3 길이(초) — 파싱 실패 시에만 CBR-128 근사로 폴백.
49
+ function audioDurationSecFromFile(filePath) {
50
+ try {
51
+ const real = mp3DurationSec(readFileSync(filePath));
52
+ if (real > 0) return real;
53
+ } catch {
54
+ // fall through
55
+ }
56
+ const size = statSync(filePath).size;
57
+ return Math.round((size * 8) / (128 * 1000) * 10) / 10;
58
+ }
59
+
60
+ // aimakeall.com 클라우드 툴 — PAT 필요. 기획·씬 생성·TTS·SUNO·스티치·퍼블리시.
61
+ // 렌더는 여기 없다: 스티치가 만든 타임라인 페이로드를 render_start(로컬 컴패니언)로 넘긴다.
62
+ // 대용량 산출물(오디오·매니페스트·base64 이미지)은 모델 컨텍스트로 돌려주지 않고
63
+ // 파일 경로 핸들로 주고받는다.
64
+
65
+ function textResult(text, { isError = false } = {}) {
66
+ return { content: [{ text, type: "text" }], ...(isError ? { isError: true } : {}) };
67
+ }
68
+
69
+ function jsonResult(value) {
70
+ return textResult(JSON.stringify(value, null, 2));
71
+ }
72
+
73
+ const NO_PAT_GUIDE = "AIMAKEALL_PAT가 설정되지 않았습니다. aimakeall.com → API 키 설정 → MCP 토큰에서 발급한 뒤 MCP 설정의 env에 넣어주세요.";
74
+
75
+ function describeLocalError(error) {
76
+ const code = String(error?.code || "");
77
+ const filePath = String(error?.path || "");
78
+ if (code === "ENOENT") return `파일을 찾을 수 없습니다: ${filePath}`;
79
+ if (code === "EACCES") return `파일을 읽을 수 없습니다(권한): ${filePath}`;
80
+ return String(error?.message || error);
81
+ }
82
+
83
+ export function wrapCloudHandler(config, handler) {
84
+ return async (args) => {
85
+ if (!config.pat) return textResult(NO_PAT_GUIDE, { isError: true });
86
+ try {
87
+ return await handler(args);
88
+ } catch (error) {
89
+ if (error instanceof AimakeallApiError) {
90
+ const retry = error.retryAfterSeconds ? ` (${error.retryAfterSeconds}초 후 재시도)` : "";
91
+ return textResult(`${error.message}${retry}`, { isError: true });
92
+ }
93
+ return textResult(`요청 실패: ${describeLocalError(error)}`, { isError: true });
94
+ }
95
+ };
96
+ }
97
+
98
+ // 씬 응답에서 모델 컨텍스트에 유용한 필드만 추린다 (base64/내부 필드 제외).
99
+ function trimPlanScenes(scenes) {
100
+ return (Array.isArray(scenes) ? scenes : []).map((scene) => ({
101
+ durationSec: scene?.durationSec,
102
+ idx: scene?.idx,
103
+ imagePrompt: scene?.imagePrompt,
104
+ label: scene?.label,
105
+ ratio: scene?.ratio,
106
+ sceneNarration: scene?.sceneNarration,
107
+ scenePurpose: scene?.scenePurpose,
108
+ }));
109
+ }
110
+
111
+ // commerce 분석 응답에서 기획에 유용한 필드만 (usage/원문 등 컨텍스트 낭비 필드 제외).
112
+ function trimAnalysis(analysis) {
113
+ if (!analysis || typeof analysis !== "object") return analysis;
114
+ const { colorPalette, materialFeel, productCategory, style, subjectType, visualCues } = analysis;
115
+ return { colorPalette, materialFeel, productCategory, style, subjectType, visualCues };
116
+ }
117
+
118
+ export function registerCloudTools(server, config, api) {
119
+ server.tool(
120
+ "get_usage",
121
+ "내 플랜과 기능별 렌더 쿼터 현황을 조회합니다. 생성 작업 전에 확인하세요.",
122
+ {},
123
+ wrapCloudHandler(config, async () => jsonResult(await api.request("/api/tracker/usage"))),
124
+ );
125
+
126
+ server.tool(
127
+ "plan_shorts_video",
128
+ "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로 이어집니다.",
129
+ {
130
+ topic: z.string().optional().describe("영상 주제"),
131
+ copy: z.string().optional().describe("핵심 카피/대사"),
132
+ categoryId: z.enum(["community-shorts", "viral-shorts"]).optional().describe("기본 community-shorts"),
133
+ targetCustomer: z.string().optional(),
134
+ tone: z.string().optional().describe("기본 '자동 추천'"),
135
+ sceneCount: z.number().int().min(1).max(10).optional().describe("기본 3"),
136
+ },
137
+ wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 3 }) => {
138
+ if (!String(topic).trim() && !String(copy).trim()) {
139
+ return textResult("topic 또는 copy 중 하나는 입력해야 합니다.", { isError: true });
140
+ }
141
+ const payload = await api.request("/api/tracker/shorts-video/plan", {
142
+ body: {
143
+ categoryId,
144
+ copy,
145
+ operationId: createUsageEventId("shorts-plan"),
146
+ sceneCount,
147
+ selectedCharacters: [],
148
+ skillOverride: null,
149
+ targetCustomer,
150
+ tone,
151
+ topic,
152
+ },
153
+ method: "POST",
154
+ timeoutMs: 240_000,
155
+ });
156
+ return jsonResult({
157
+ ctaText: payload?.ctaText,
158
+ narrationScript: payload?.narrationScript,
159
+ ok: payload?.ok,
160
+ scenes: trimPlanScenes(payload?.scenes),
161
+ skill: payload?.skill,
162
+ tagline: payload?.tagline,
163
+ });
164
+ }),
165
+ );
166
+
167
+ server.tool(
168
+ "plan_commerce_video",
169
+ "제품 홍보 영상 기획을 생성합니다. 제품 사진 파일 경로가 최소 1장 필요합니다 (이 PC의 로컬 경로).",
170
+ {
171
+ productName: z.string().describe("제품명 (필수)"),
172
+ productImagePaths: z.array(z.string()).min(1).describe("제품/자산 사진의 로컬 파일 경로 (1장 이상)"),
173
+ modelImagePath: z.string().optional().describe("모델 사진 로컬 경로 (선택)"),
174
+ description: z.string().optional(),
175
+ targetCustomer: z.string().optional(),
176
+ tone: z.string().optional().describe("기본 '자동 추천'"),
177
+ categoryId: z.string().optional().describe("기본 ecommerce"),
178
+ sceneCount: z.number().int().min(1).max(12).optional().describe("기본 6"),
179
+ },
180
+ wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6 }) => {
181
+ // 확장자 검증(임의 파일 업로드 차단) + 합산 크기 예산(서버 413 사전 차단).
182
+ for (const filePath of productImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "이미지" });
183
+ if (modelImagePath) assertAllowedInputFile(modelImagePath, ALLOWED_IMAGE_EXTS, { kind: "모델 이미지" });
184
+ assertEncodedBudget([...productImagePaths, modelImagePath], { context: "제품/모델 사진" });
185
+
186
+ const payload = await api.request("/api/tracker/commerce-video/plan", {
187
+ body: {
188
+ categoryId,
189
+ description,
190
+ modelImage: modelImagePath ? fileToImagePayload(modelImagePath) : null,
191
+ operationId: createUsageEventId("commerce-plan"),
192
+ productImages: productImagePaths.map((filePath) => fileToImagePayload(filePath)),
193
+ productName,
194
+ sceneCount,
195
+ selectedCharacters: [],
196
+ skillOverride: null,
197
+ targetCustomer,
198
+ tone,
199
+ },
200
+ method: "POST",
201
+ timeoutMs: 300_000,
202
+ });
203
+ return jsonResult({
204
+ analysis: trimAnalysis(payload?.analysis),
205
+ ctaText: payload?.ctaText,
206
+ ok: payload?.ok,
207
+ scenes: trimPlanScenes(payload?.scenes),
208
+ skill: payload?.skill,
209
+ tagline: payload?.tagline,
210
+ });
211
+ }),
212
+ );
213
+
214
+ server.tool(
215
+ "generate_scene_image",
216
+ "씬 이미지를 생성합니다 (기획 결과의 imagePrompt 사용). 반환된 imageUrl을 씬 영상 생성의 입력으로 쓰세요. 캐릭터/앞씬 일관성이 필요하면 앞 씬의 imageUrl을 referenceImageUrls로 전달하세요.",
217
+ {
218
+ prompt: z.string().describe("이미지 프롬프트 (plan 결과의 imagePrompt)"),
219
+ aspectRatio: z.string().optional().describe("기본 9:16"),
220
+ model: z.string().optional().describe("기본 gpt-image-2-beta (대안: gemini-3.1-flash-image-preview, doubao-seedream-5.0-lite)"),
221
+ resolution: z.string().optional().describe("기본 1K"),
222
+ referenceImageUrls: z.array(z.string()).max(6).optional().describe("참조 이미지 URL (씬1 앵커 등)"),
223
+ referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 이미지 로컬 경로 (제품 사진 등)"),
224
+ },
225
+ wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [] }) => {
226
+ for (const filePath of referenceImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 이미지" });
227
+ assertEncodedBudget(referenceImagePaths, { context: "참조 이미지" });
228
+ const referenceImages = [
229
+ ...referenceImagePaths.map((filePath) => ({
230
+ name: path.basename(filePath),
231
+ previewUrl: fileToDataUrl(filePath, { allowedExts: ALLOWED_IMAGE_EXTS, kind: "참조 이미지" }),
232
+ })),
233
+ ...referenceImageUrls.map((url, index) => ({ name: `ref-${index + 1}`, previewUrl: url })),
234
+ ].slice(0, 6);
235
+
236
+ const payload = await api.request("/api/tracker/gemini/storyboard-scene-image", {
237
+ body: {
238
+ aspectRatio,
239
+ model,
240
+ prompt,
241
+ referenceImages,
242
+ referenceSearchEnabled: true,
243
+ resolution,
244
+ webSearchEnabled: true,
245
+ },
246
+ method: "POST",
247
+ timeoutMs: 240_000,
248
+ });
249
+
250
+ let imageUrl = String(payload?.imageUrl || "");
251
+ let savedPath = "";
252
+ if (!imageUrl && payload?.imageDataUrl) {
253
+ // URL 없이 base64만 온 경우 — 컨텍스트로 돌려주지 않고 파일로 저장.
254
+ const match = String(payload.imageDataUrl).match(/^data:([^;]+);base64,(.+)$/s);
255
+ if (match) {
256
+ const ext = match[1].includes("png") ? "png" : "jpg";
257
+ const saved = saveMediaBuffer(config.stateDir, `scene-image.${ext}`, Buffer.from(match[2], "base64"));
258
+ savedPath = saved.filePath;
259
+ }
260
+ }
261
+ return jsonResult({
262
+ imageUrl: imageUrl || undefined,
263
+ model: payload?.model,
264
+ savedPath: savedPath || undefined,
265
+ taskId: payload?.taskId,
266
+ });
267
+ }),
268
+ );
269
+
270
+ server.tool(
271
+ "generate_scene_video_prompt",
272
+ "씬 이미지 기반 영상 프롬프트를 생성합니다. 결과 videoPrompt를 generate_scene_video의 prompt로 넘기세요.",
273
+ {
274
+ imagePrompt: z.string().describe("씬의 이미지 프롬프트"),
275
+ sceneImageUrl: z.string().optional().describe("generate_scene_image가 반환한 imageUrl"),
276
+ sceneScript: z.string().optional().describe("씬 대사/설명 (plan 결과의 label)"),
277
+ sceneTitle: z.string().optional(),
278
+ aspectRatio: z.string().optional().describe("기본 9:16"),
279
+ durationSec: z.number().optional().describe("기본 8"),
280
+ videoStylePreset: z.string().optional().describe("plan 결과의 skill"),
281
+ kind: z.enum(["shorts_scene", "commerce_video_scene", "music_video_scene"]).optional(),
282
+ modelId: z.string().optional().describe("대상 영상 모델 (기본 kie-grok-imagine — generate_scene_video와 동일하게 맞추세요)"),
283
+ },
284
+ wrapCloudHandler(config, async ({ imagePrompt, sceneImageUrl = "", sceneScript = "", sceneTitle = "", aspectRatio = "9:16", durationSec = 8, videoStylePreset = "", kind = "shorts_scene", modelId = "kie-grok-imagine" }) => {
285
+ const payload = await api.request("/api/tracker/gemini/storyboard-video-prompt", {
286
+ body: {
287
+ aspectRatio,
288
+ durationLabel: `${Math.max(1, Math.round(durationSec))}s`,
289
+ generationPlan: {
290
+ hasSceneImage: Boolean(sceneImageUrl),
291
+ kind,
292
+ requestedInputMode: "image",
293
+ resolvedInputMode: "image",
294
+ resolvedRouteLabel: "i2v",
295
+ videoStylePreset: videoStylePreset || null,
296
+ },
297
+ imagePrompt,
298
+ referenceImage: sceneImageUrl ? { name: "scene-image", previewUrl: sceneImageUrl } : null,
299
+ sceneScript,
300
+ sceneTitle,
301
+ // 프롬프트가 대상 모델에 맞춰 작성되도록 — 미지정 시 서버가 Seedance 로 오해.
302
+ selectedVideoModel: { id: modelId },
303
+ },
304
+ method: "POST",
305
+ timeoutMs: 180_000,
306
+ });
307
+ return jsonResult({ model: payload?.model, videoPrompt: payload?.videoPrompt });
308
+ }),
309
+ );
310
+
311
+ server.tool(
312
+ "generate_scene_video",
313
+ "씬 영상을 생성합니다 (동기 호출, 최대 ~30분 — MCP 클라이언트의 툴 타임아웃(MCP_TIMEOUT)을 그 이상으로 늘려두세요. 타임아웃되면 이미 과금된 결과를 회수할 수 없으니 주의). 반환된 videoUrl을 stitch_timeline의 sceneVideos에 넣으세요.",
314
+ {
315
+ prompt: z.string().describe("영상 프롬프트 (generate_scene_video_prompt의 videoPrompt)"),
316
+ sceneImageUrl: z.string().optional().describe("씬 이미지 URL (i2v 입력)"),
317
+ aspectRatio: z.string().optional().describe("기본 9:16"),
318
+ durationSec: z.number().optional().describe("기본 8 (모델 상한으로 클램프됨)"),
319
+ modelId: z.string().optional().describe("기본 kie-grok-imagine (대안: seedance-2.0, kling-3.0)"),
320
+ quality: z.string().optional().describe("기본 720p"),
321
+ },
322
+ wrapCloudHandler(config, async ({ prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p" }) => {
323
+ const payload = await api.request("/api/tracker/gemini/storyboard-scene-video", {
324
+ body: {
325
+ aspectRatio,
326
+ durationLabel: `${Math.max(1, Math.ceil(durationSec))}s`,
327
+ generationPlan: {
328
+ hasSceneImage: Boolean(sceneImageUrl),
329
+ kind: "shorts_scene",
330
+ requestedInputMode: sceneImageUrl ? "image" : "text",
331
+ resolvedInputMode: sceneImageUrl ? "image" : "text",
332
+ resolvedRouteLabel: sceneImageUrl ? "i2v" : "t2v",
333
+ videoStylePreset: null,
334
+ },
335
+ prompt,
336
+ sceneImage: sceneImageUrl ? { name: "scene-image", previewUrl: sceneImageUrl } : null,
337
+ selectedVideoModel: { id: modelId },
338
+ videoGenerationSettings: { inputMode: sceneImageUrl ? "image" : "text", quality },
339
+ },
340
+ method: "POST",
341
+ // 서버 최악 경로(KIE 900s + Evolink 폴백 3회×900s)를 넘겨 잡아 조기 abort 로 과금-미회수를 방지.
342
+ timeoutMs: 1_800_000,
343
+ });
344
+ return jsonResult({
345
+ durationLabel: payload?.durationLabel,
346
+ model: payload?.model,
347
+ resolvedRouteLabel: payload?.resolvedRouteLabel,
348
+ taskId: payload?.taskId,
349
+ videoUrl: payload?.videoUrl,
350
+ });
351
+ }),
352
+ );
353
+
354
+ server.tool(
355
+ "tts_narration",
356
+ "내레이션 TTS를 합성해 이 PC에 mp3로 저장하고 파일 경로를 반환합니다. stitch_timeline의 ttsAudioPath로 쓰세요.",
357
+ {
358
+ text: z.string().describe("내레이션 텍스트"),
359
+ provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
360
+ voice: z.string().optional().describe("보이스 이름(라벨)"),
361
+ speed: z.number().optional().describe("기본 1"),
362
+ },
363
+ wrapCloudHandler(config, async ({ text, provider = "typecast", voice = "", speed = 1 }) => {
364
+ const body = provider === "typecast"
365
+ ? {
366
+ breath: 0.3,
367
+ costEventId: createUsageEventId("mcp-tts-typecast"),
368
+ emotion: "neutral",
369
+ fileName: "mcp-narration",
370
+ language: "kor",
371
+ modelId: "",
372
+ pitch: 0,
373
+ smartEmotion: true,
374
+ speed,
375
+ text,
376
+ voice,
377
+ volume: 100,
378
+ }
379
+ : {
380
+ costEventId: createUsageEventId("mcp-tts-elevenlabs"),
381
+ fileName: "mcp-narration",
382
+ languageCode: "ko",
383
+ modelId: "",
384
+ similarityBoost: 0.8,
385
+ speakerBoost: true,
386
+ speed,
387
+ stability: 0.45,
388
+ style: 0.35,
389
+ text,
390
+ voice,
391
+ };
392
+ const result = await api.requestBinary(`/api/tracker/tts/${provider}`, {
393
+ body,
394
+ method: "POST",
395
+ timeoutMs: 120_000,
396
+ });
397
+ const saved = saveMediaBuffer(config.stateDir, "narration.mp3", result.buffer, { contentType: result.contentType });
398
+ return jsonResult({
399
+ bytes: saved.bytes,
400
+ // 실측 mp3 길이 — stitch_timeline 의 audioDurationSec 로 그대로 쓰면 타임라인이 정확하다.
401
+ durationSec: audioDurationSecFromFile(saved.filePath),
402
+ filePath: saved.filePath,
403
+ voiceName: decodeHeaderValue(
404
+ result.headers.get("x-typecast-voice-name") || result.headers.get("x-elevenlabs-voice-name") || "",
405
+ ),
406
+ });
407
+ }),
408
+ );
409
+
410
+ server.tool(
411
+ "suno_music_start",
412
+ "SUNO 음악 생성을 시작합니다 (taskId 반환 → suno_music_status로 폴링).",
413
+ {
414
+ title: z.string().describe("곡 제목 (필수)"),
415
+ stylePrompt: z.string().describe("스타일 프롬프트 (필수)"),
416
+ lyrics: z.string().optional().describe("가사 (instrumental=false면 필수)"),
417
+ instrumental: z.boolean().optional().describe("반주만 (BGM용). 기본 false"),
418
+ model: z.string().optional().describe("기본 V5.5"),
419
+ },
420
+ wrapCloudHandler(config, async ({ title, stylePrompt, lyrics = "", instrumental = false, model = "V5.5" }) => {
421
+ if (!instrumental && !String(lyrics).trim()) {
422
+ return textResult("instrumental=false면 lyrics가 필요합니다.", { isError: true });
423
+ }
424
+ const payload = await api.request("/api/tracker/suno/music", {
425
+ body: {
426
+ audioWeight: 0.5,
427
+ instrumental,
428
+ lyrics,
429
+ model,
430
+ negativeTags: "",
431
+ personaId: "",
432
+ personaModel: "",
433
+ stylePrompt,
434
+ styleWeight: 0.7,
435
+ title,
436
+ vocalGender: "",
437
+ weirdnessConstraint: 0.3,
438
+ },
439
+ method: "POST",
440
+ timeoutMs: 60_000,
441
+ });
442
+ return jsonResult({ model: payload?.model, taskId: payload?.id });
443
+ }),
444
+ );
445
+
446
+ server.tool(
447
+ "suno_music_status",
448
+ "SUNO 음악 태스크 상태를 조회합니다 (12초 간격 폴링 권장). 완료되면 tracks[].audioUrl이 옵니다.",
449
+ { taskId: z.string() },
450
+ wrapCloudHandler(config, async ({ taskId }) => {
451
+ const payload = await api.request(`/api/tracker/suno/tasks/${encodeURIComponent(taskId)}`, { timeoutMs: 30_000 });
452
+ return jsonResult({
453
+ errorMessage: payload?.errorMessage,
454
+ progress: payload?.progress,
455
+ status: payload?.status,
456
+ tracks: (Array.isArray(payload?.tracks) ? payload.tracks : []).map((track) => ({
457
+ audioUrl: track?.audioUrl,
458
+ durationSec: track?.durationSec,
459
+ title: track?.title,
460
+ })),
461
+ });
462
+ }),
463
+ );
464
+
465
+ server.tool(
466
+ "suno_music_download",
467
+ "완성된 SUNO 곡을 이 PC에 mp3로 저장하고 경로를 반환합니다. stitch_timeline의 bgmAudioPath로 쓰세요.",
468
+ { audioUrl: z.string().describe("suno_music_status의 tracks[].audioUrl") },
469
+ wrapCloudHandler(config, async ({ audioUrl }) => {
470
+ const result = await api.requestBinary(`/api/tracker/suno/download?url=${encodeURIComponent(audioUrl)}`, { timeoutMs: 300_000 });
471
+ const saved = saveMediaBuffer(config.stateDir, "suno-track.mp3", result.buffer, { contentType: result.contentType });
472
+ return jsonResult({ bytes: saved.bytes, filePath: saved.filePath });
473
+ }),
474
+ );
475
+
476
+ server.tool(
477
+ "stitch_timeline",
478
+ "씬 영상들과 오디오를 서버에서 타임라인 매니페스트로 합칩니다. 결과는 파일 핸들(payloadPath)로 반환되며, 이를 render_start에 넘기면 이 PC에서 mp4가 렌더됩니다. 서버는 렌더하지 않습니다.",
479
+ {
480
+ sceneVideos: z.array(z.object({
481
+ durationSec: z.number(),
482
+ idx: z.number().int(),
483
+ label: z.string().optional(),
484
+ url: z.string(),
485
+ })).min(1).describe("씬 순서대로 idx=0부터. url은 generate_scene_video의 videoUrl"),
486
+ featureKey: z.enum(["shortsVideo", "commerceVideo", "musicVideo"]).optional().describe("기본 commerceVideo"),
487
+ projectTitle: z.string().optional(),
488
+ aspectRatio: z.string().optional().describe("기본 9:16"),
489
+ ttsAudioPath: z.string().optional().describe("tts_narration이 반환한 mp3 경로"),
490
+ bgmAudioPath: z.string().optional().describe("suno_music_download가 반환한 mp3 경로"),
491
+ audioDurationSec: z.number().optional().describe("TTS 길이(초) — tts_narration의 durationSec (생략 시 ttsAudioPath에서 실측)"),
492
+ bgmVolume: z.number().nullable().optional().describe("null=자동(TTS 있으면 30, 없으면 100)"),
493
+ muteVideoAudio: z.boolean().optional().describe("기본 true (원본 씬 오디오 제거)"),
494
+ titleText: z.string().optional().describe("상단 고정 타이틀 (쇼츠)"),
495
+ subtitleLines: z.array(z.object({
496
+ endSec: z.number(),
497
+ startSec: z.number(),
498
+ text: z.string(),
499
+ })).optional().describe("자막 타이밍 (쇼츠)"),
500
+ transitionMode: z.enum(["fade", "none"]).optional().describe("기본 fade"),
501
+ },
502
+ wrapCloudHandler(config, async ({ sceneVideos, featureKey = "commerceVideo", projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines = [], transitionMode = "fade" }) => {
503
+ // 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
504
+ if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
505
+ if (bgmAudioPath) assertAllowedInputFile(bgmAudioPath, new Set([".mp3", ".wav"]), { kind: "BGM 오디오" });
506
+ assertEncodedBudget([ttsAudioPath, bgmAudioPath], { context: "오디오" });
507
+ // audioDurationSec 미지정 시 TTS 파일에서 실측 (CBR 추정 대신 프레임 합산).
508
+ const resolvedAudioDurationSec = audioDurationSec > 0
509
+ ? audioDurationSec
510
+ : (ttsAudioPath ? audioDurationSecFromFile(ttsAudioPath) : 0);
511
+ const body = {
512
+ aspectRatio,
513
+ audioDurationSec: resolvedAudioDurationSec,
514
+ bgmAudioUrl: bgmAudioPath ? fileToDataUrl(bgmAudioPath, { mimeType: "audio/mpeg" }) : "",
515
+ bgmVolume,
516
+ featureKey,
517
+ muteVideoAudio,
518
+ projectTitle,
519
+ sceneVideos: sceneVideos.map((scene, index) => ({
520
+ durationSec: scene.durationSec,
521
+ idx: index,
522
+ label: scene.label || `scene-${index + 1}`,
523
+ url: scene.url,
524
+ })),
525
+ subtitleLines,
526
+ titleText,
527
+ transitionMode,
528
+ ttsAudioUrl: ttsAudioPath ? fileToDataUrl(ttsAudioPath, { mimeType: "audio/mpeg" }) : "",
529
+ };
530
+ const response = await api.request("/api/tracker/commerce-video/stitch", {
531
+ body,
532
+ method: "POST",
533
+ timeoutMs: 180_000,
534
+ });
535
+ if (!response?.ok || !response?.payload) {
536
+ return textResult(`스티치 실패: ${response?.error || "매니페스트가 비어 있습니다."}`, { isError: true });
537
+ }
538
+ const handle = savePayloadHandle(config.stateDir, response.payload, "stitch");
539
+ return jsonResult({
540
+ assetCount: Array.isArray(response.payload.assets) ? response.payload.assets.length : 0,
541
+ clipCount: Array.isArray(response.payload.studio?.clips) ? response.payload.studio.clips.length : 0,
542
+ next: "render_start에 payloadPath를 넘겨 이 PC에서 렌더하세요.",
543
+ payloadPath: handle.filePath,
544
+ projectTitle: response.payload.projectTitle,
545
+ sizeBytes: handle.bytes,
546
+ });
547
+ }),
548
+ );
549
+
550
+ server.tool(
551
+ "publish_auth_status",
552
+ "퍼블리시 플랫폼(YouTube 등) OAuth 연결 상태를 조회합니다. 연결은 aimakeall.com 웹에서 해야 합니다.",
553
+ {},
554
+ wrapCloudHandler(config, async () => jsonResult((await api.request("/api/upload/auth/status"))?.status || {})),
555
+ );
556
+
557
+ server.tool(
558
+ "youtube_categories",
559
+ "업로드 가능한 YouTube 카테고리 목록을 조회합니다 (YouTube 연결 필요).",
560
+ {},
561
+ wrapCloudHandler(config, async () => {
562
+ const payload = await api.request("/api/upload/youtube/categories?regionCode=KR&hl=ko");
563
+ return jsonResult({ categories: payload?.categories || [], source: payload?.source });
564
+ }),
565
+ );
566
+
567
+ server.tool(
568
+ "ai_publish_metadata",
569
+ "영상 제목·설명·태그를 AI로 생성합니다.",
570
+ {
571
+ title: z.string().optional().describe("제목 힌트"),
572
+ script: z.string().optional().describe("영상 대본/내레이션"),
573
+ description: z.string().optional(),
574
+ platforms: z.array(z.string()).optional().describe('기본 ["youtube"]'),
575
+ },
576
+ wrapCloudHandler(config, async ({ title = "", script = "", description = "", platforms = ["youtube"] }) => {
577
+ const payload = await api.request("/api/upload/ai/metadata", {
578
+ body: {
579
+ costEventId: createUsageEventId("upload-ai-metadata"),
580
+ description,
581
+ language: "한국어",
582
+ platforms,
583
+ script,
584
+ title,
585
+ },
586
+ method: "POST",
587
+ timeoutMs: 90_000,
588
+ });
589
+ return jsonResult({ description: payload?.description, tags: payload?.tags, title: payload?.title });
590
+ }),
591
+ );
592
+
593
+ server.tool(
594
+ "publish_youtube",
595
+ "이 PC의 mp4 파일을 YouTube에 업로드합니다 (YouTube OAuth 연결 필요). 기본은 비공개(private)이며, visibility=public/unlisted은 사용자가 명시적으로 요청한 경우에만 사용하세요.",
596
+ {
597
+ videoPath: z.string().describe("render_result가 저장한 mp4의 절대 경로"),
598
+ title: z.string().describe("영상 제목 (100자 이내)"),
599
+ description: z.string().optional(),
600
+ tags: z.array(z.string()).optional(),
601
+ categoryId: z.string().optional().describe("youtube_categories의 id"),
602
+ visibility: z.enum(["private", "unlisted", "public"]).optional().describe("기본 private"),
603
+ madeForKids: z.boolean().optional().describe("기본 false"),
604
+ notifySubscribers: z.boolean().optional().describe("기본 true"),
605
+ },
606
+ wrapCloudHandler(config, async ({ videoPath, title, description = "", tags = [], categoryId = "", visibility = "private", madeForKids = false, notifySubscribers = true }) => {
607
+ assertAllowedInputFile(videoPath, ALLOWED_VIDEO_EXTS, { kind: "영상 파일" });
608
+ const stats = statSync(videoPath);
609
+ // 메타데이터가 쿼리스트링을 타므로 한국어 설명의 퍼센트 인코딩 팽창으로 헤더 상한(16KB)을
610
+ // 넘지 않도록 보수적으로 제한한다.
611
+ const safeTitle = title.slice(0, 100);
612
+ const safeDescription = description.slice(0, 1500);
613
+ const params = new URLSearchParams({
614
+ description: safeDescription,
615
+ language: "ko",
616
+ madeForKids: madeForKids ? "true" : "false",
617
+ notifySubscribers: notifySubscribers ? "true" : "false",
618
+ scheduleEnabled: "false",
619
+ tags: tags.join(","),
620
+ title: safeTitle,
621
+ visibility,
622
+ });
623
+ if (categoryId) {
624
+ params.set("categoryId", categoryId);
625
+ params.set("category", categoryId);
626
+ }
627
+ const payload = await api.requestStreamUpload(`/api/upload/youtube?${params.toString()}`, {
628
+ contentLength: stats.size,
629
+ contentType: "video/mp4",
630
+ stream: Readable.toWeb(createReadStream(videoPath)),
631
+ timeoutMs: 30 * 60_000,
632
+ });
633
+ // 무엇이 어떤 공개범위로 올라갔는지 결과에 명시 (사용자가 트랜스크립트로 확인 가능).
634
+ return jsonResult({
635
+ madeForKids,
636
+ ok: payload?.ok,
637
+ title: safeTitle,
638
+ videoId: payload?.videoId,
639
+ videoUrl: payload?.videoUrl,
640
+ visibility,
641
+ });
642
+ }),
643
+ );
644
+ }