aimakeall-mcp 0.4.3 → 0.6.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 +4 -2
- package/lib/cloud-tools.mjs +87 -6
- package/lib/companion-client.mjs +56 -0
- package/lib/config.mjs +1 -1
- package/lib/payload-guard.mjs +38 -0
- package/lib/payload-guard.test.mjs +22 -0
- package/lib/wav-trim.mjs +80 -0
- package/lib/wav-trim.test.mjs +89 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Claude Code·Codex 같은 MCP 클라이언트에서 자연어로 AImakeAll 영
|
|
|
16
16
|
"mcpServers": {
|
|
17
17
|
"aimakeall": {
|
|
18
18
|
"command": "npx",
|
|
19
|
-
"args": ["-y", "aimakeall-mcp@0.
|
|
19
|
+
"args": ["-y", "aimakeall-mcp@0.6.0"],
|
|
20
20
|
"env": { "AIMAKEALL_PAT": "aio_pat_..." }
|
|
21
21
|
}
|
|
22
22
|
}
|
|
@@ -36,13 +36,15 @@ Claude Code·Codex 같은 MCP 클라이언트에서 자연어로 AImakeAll 영
|
|
|
36
36
|
|
|
37
37
|
```
|
|
38
38
|
plan_shorts_video → (씬마다) generate_scene_image → generate_scene_video_prompt → generate_scene_video
|
|
39
|
-
→ tts_narration → stitch_timeline → render_start → render_status(폴링) → render_result
|
|
39
|
+
→ (보이스 미지정 시) recommend_voice → tts_narration_with_captions(하단자막·강조용) 또는 tts_narration → stitch_timeline → render_start → render_status(폴링) → render_result
|
|
40
40
|
→ ai_publish_metadata → publish_youtube
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
- 씬 영상 생성은 동기 호출로 최대 15분 걸립니다 — MCP 클라이언트의 툴 타임아웃(`MCP_TIMEOUT`)을 충분히 잡아주세요.
|
|
44
44
|
- 렌더 잡은 `~/.aimakeall/render-jobs.json`에 기록되어 프록시 재시작 후 `list_render_jobs`로 복구할 수 있습니다.
|
|
45
45
|
- 대용량 산출물(TTS mp3, 스티치 매니페스트)은 파일 경로로 주고받아 모델 컨텍스트를 오염시키지 않습니다.
|
|
46
|
+
- `recommend_voice`는 주제·샘플 영상(유튜브)에 맞는 보이스를 자동 선정합니다 — 샘플 화자 분석은 컴패니언 앱이 실행 중일 때만 동작합니다.
|
|
47
|
+
- 하단자막 강조: `tts_narration_with_captions`의 `subtitleLines`와 `plan_shorts_video`의 `emphasisKeywords`를 `stitch_timeline`에 함께 넘기면 자막 안 해당 단어만 노랑/큰 글씨로 강조됩니다(최신 컴패니언 런타임 필요 — 구버전은 일반 자막으로 안전 강등).
|
|
46
48
|
|
|
47
49
|
## 한도
|
|
48
50
|
|
package/lib/cloud-tools.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { Readable } from "node:stream";
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
|
|
8
8
|
import { AimakeallApiError } from "./api-client.mjs";
|
|
9
|
+
import { extractCompanionAudio } from "./companion-client.mjs";
|
|
9
10
|
import {
|
|
10
11
|
ALLOWED_IMAGE_EXTS,
|
|
11
12
|
ALLOWED_VIDEO_EXTS,
|
|
@@ -20,7 +21,9 @@ import {
|
|
|
20
21
|
SERVER_JSON_BODY_LIMIT_BYTES,
|
|
21
22
|
} from "./media-store.mjs";
|
|
22
23
|
import { mp3DurationSec } from "./mp3-duration.mjs";
|
|
24
|
+
import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
|
|
23
25
|
import { createUsageEventId } from "./usage-event.mjs";
|
|
26
|
+
import { trimWavToSeconds } from "./wav-trim.mjs";
|
|
24
27
|
|
|
25
28
|
// 여러 파일을 base64 로 싣는 요청의 인코딩 크기 합이 서버 JSON 상한을 넘지 않게 사전 검사.
|
|
26
29
|
// 서버가 전체 업로드를 받은 뒤 413 을 내는 것을 막고 행동 가능한 한국어 안내를 준다.
|
|
@@ -147,7 +150,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
147
150
|
|
|
148
151
|
server.tool(
|
|
149
152
|
"plan_shorts_video",
|
|
150
|
-
"쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset
|
|
153
|
+
"쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조).",
|
|
151
154
|
{
|
|
152
155
|
topic: z.string().optional().describe("영상 주제"),
|
|
153
156
|
copy: z.string().optional().describe("핵심 카피/대사"),
|
|
@@ -177,6 +180,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
177
180
|
});
|
|
178
181
|
return jsonResult({
|
|
179
182
|
ctaText: payload?.ctaText,
|
|
183
|
+
emphasisKeywords: Array.isArray(payload?.emphasisKeywords) ? payload.emphasisKeywords : [],
|
|
180
184
|
narrationScript: payload?.narrationScript,
|
|
181
185
|
ok: payload?.ok,
|
|
182
186
|
scenes: trimPlanScenes(payload?.scenes),
|
|
@@ -454,7 +458,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
454
458
|
|
|
455
459
|
server.tool(
|
|
456
460
|
"tts_narration",
|
|
457
|
-
"내레이션 TTS를 합성해 이 PC에 mp3로 저장하고 파일 경로를 반환합니다. stitch_timeline의 ttsAudioPath로 쓰세요.",
|
|
461
|
+
"내레이션 TTS를 합성해 이 PC에 mp3로 저장하고 파일 경로를 반환합니다. stitch_timeline의 ttsAudioPath로 쓰세요. 사용자가 보이스를 직접 지정하지 않았다면 먼저 recommend_voice로 주제·샘플 영상에 맞는 보이스를 자동 선정해 voice에 넣으세요.",
|
|
458
462
|
{
|
|
459
463
|
text: z.string().describe("내레이션 텍스트"),
|
|
460
464
|
provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
|
|
@@ -576,7 +580,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
576
580
|
|
|
577
581
|
server.tool(
|
|
578
582
|
"stitch_timeline",
|
|
579
|
-
"씬 영상들과 오디오를 서버에서 타임라인 매니페스트로 합칩니다. 결과는 파일 핸들(payloadPath)로 반환되며, 이를 render_start에 넘기면 이 PC에서 mp4가 렌더됩니다. 서버는 렌더하지 않습니다.",
|
|
583
|
+
"씬 영상들과 오디오를 서버에서 타임라인 매니페스트로 합칩니다. 결과는 파일 핸들(payloadPath)로 반환되며, 이를 render_start에 넘기면 이 PC에서 mp4가 렌더됩니다. 서버는 렌더하지 않습니다. 하단자막 강조: emphasisKeywords 를 주면 자막 라인 안의 해당 단어만 다른 색/크기로 강조되고, subtitleLines[].style 로 라인별 디자인도 바꿀 수 있습니다.",
|
|
580
584
|
{
|
|
581
585
|
sceneVideos: z.array(z.object({
|
|
582
586
|
durationSec: z.number(),
|
|
@@ -597,10 +601,24 @@ export function registerCloudTools(server, config, api) {
|
|
|
597
601
|
endSec: z.number(),
|
|
598
602
|
startSec: z.number(),
|
|
599
603
|
text: z.string(),
|
|
604
|
+
style: z.object({
|
|
605
|
+
fontSize: z.number().optional(),
|
|
606
|
+
fontWeight: z.enum(["bold", "normal"]).optional(),
|
|
607
|
+
strokeColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
|
|
608
|
+
strokeWidth: z.number().optional(),
|
|
609
|
+
textBackgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
|
|
610
|
+
textBackgroundEnabled: z.boolean().optional(),
|
|
611
|
+
textBackgroundOpacity: z.number().optional(),
|
|
612
|
+
textColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional(),
|
|
613
|
+
textEffectVariant: z.string().optional(),
|
|
614
|
+
}).optional().describe("이 라인만의 디자인 오버라이드 (내용에 따라 라인별 디자인을 달리할 때)"),
|
|
600
615
|
})).optional().describe("자막 타이밍 (쇼츠 하단자막) — tts_narration_with_captions의 subtitleLines를 그대로 사용"),
|
|
616
|
+
emphasisKeywords: z.array(z.string()).max(12).optional().describe("강조 단어 목록 — 자막 라인 안에서 해당 단어만 다른 색/크기로 렌더 (plan_shorts_video의 emphasisKeywords 를 그대로 전달)"),
|
|
617
|
+
emphasisColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional().describe("강조 색 #RRGGBB (기본 #facc15 노랑)"),
|
|
618
|
+
emphasisSizeScale: z.number().optional().describe("강조 크기 배율 0.5~2 (기본 1.2)"),
|
|
601
619
|
transitionMode: z.enum(["fade", "none"]).optional().describe("기본 fade"),
|
|
602
620
|
},
|
|
603
|
-
wrapCloudHandler(config, async ({ sceneVideos, featureKey = "commerceVideo", projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines = [], transitionMode = "fade" }) => {
|
|
621
|
+
wrapCloudHandler(config, async ({ sceneVideos, featureKey = "commerceVideo", projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines = [], emphasisKeywords = [], emphasisColor = "", emphasisSizeScale = 0, transitionMode = "fade" }) => {
|
|
604
622
|
// 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
|
|
605
623
|
if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
|
|
606
624
|
if (bgmAudioPath) assertAllowedInputFile(bgmAudioPath, new Set([".mp3", ".wav"]), { kind: "BGM 오디오" });
|
|
@@ -623,6 +641,9 @@ export function registerCloudTools(server, config, api) {
|
|
|
623
641
|
label: scene.label || `scene-${index + 1}`,
|
|
624
642
|
url: scene.url,
|
|
625
643
|
})),
|
|
644
|
+
emphasisColor,
|
|
645
|
+
emphasisKeywords,
|
|
646
|
+
emphasisSizeScale,
|
|
626
647
|
subtitleLines,
|
|
627
648
|
titleText,
|
|
628
649
|
transitionMode,
|
|
@@ -1036,7 +1057,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
1036
1057
|
// ── 시간 동기 자막 TTS — 쇼츠 하단자막용 ─────────────────────────────────────
|
|
1037
1058
|
server.tool(
|
|
1038
1059
|
"tts_narration_with_captions",
|
|
1039
|
-
"ElevenLabs TTS를 char-level 타이밍과 함께 합성해 mp3 파일 + 시간 동기 자막 라인(subtitleLines)을 만듭니다. 쇼츠 하단자막이 필요하면 tts_narration 대신 이걸 쓰고, 결과 subtitleLines·durationSec·filePath를 stitch_timeline에 그대로 넘기세요 (웹 쇼츠 스튜디오와 동일한 정렬 방식).",
|
|
1060
|
+
"ElevenLabs TTS를 char-level 타이밍과 함께 합성해 mp3 파일 + 시간 동기 자막 라인(subtitleLines)을 만듭니다. 쇼츠 하단자막이 필요하면 tts_narration 대신 이걸 쓰고, 결과 subtitleLines·durationSec·filePath를 stitch_timeline에 그대로 넘기세요 (웹 쇼츠 스튜디오와 동일한 정렬 방식). 사용자가 보이스를 지정하지 않았다면 먼저 recommend_voice(provider=elevenlabs)로 자동 선정하세요.",
|
|
1040
1061
|
{
|
|
1041
1062
|
text: z.string().describe("내레이션 텍스트"),
|
|
1042
1063
|
voice: z.string().optional().describe("보이스 이름 또는 ID (생략 시 기본 보이스)"),
|
|
@@ -1082,7 +1103,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
1082
1103
|
// ── TTS 보이스 카탈로그 ──────────────────────────────────────────────────────
|
|
1083
1104
|
server.tool(
|
|
1084
1105
|
"tts_list_voices",
|
|
1085
|
-
"TTS 보이스 목록을 조회합니다.
|
|
1106
|
+
"TTS 보이스 목록을 조회합니다. 사용자가 목록에서 직접 고르고 싶어할 때 쓰세요 — 주제·샘플 영상 기반 자동 선정은 recommend_voice가 담당합니다. (voice를 생략하면 서버가 기본 보이스로 합성합니다)",
|
|
1086
1107
|
{
|
|
1087
1108
|
provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
|
|
1088
1109
|
},
|
|
@@ -1101,6 +1122,66 @@ export function registerCloudTools(server, config, api) {
|
|
|
1101
1122
|
}),
|
|
1102
1123
|
);
|
|
1103
1124
|
|
|
1125
|
+
server.tool(
|
|
1126
|
+
"recommend_voice",
|
|
1127
|
+
"주제·대본과 사용자가 샘플로 지정한 영상(유튜브)에 어울리는 TTS 보이스를 LLM이 자동 선정합니다. 사용자가 보이스를 직접 지정하지 않았다면 tts_narration/tts_narration_with_captions 전에 이 도구를 호출해 recommendations[0].voiceId를 voice로 쓰세요. 샘플 영상이 있으면 컴패니언 앱이 화자 음성을 추출·분석해 가장 비슷한 보이스를 우선합니다(컴패니언 미실행 시 주제 기반으로만 추천).",
|
|
1128
|
+
{
|
|
1129
|
+
topic: z.string().optional().describe("영상 주제·타깃 시청층 (예: 시니어 건강 정보, 커머스 쇼츠)"),
|
|
1130
|
+
scriptExcerpt: z.string().optional().describe("대본 앞부분 발췌 (선택)"),
|
|
1131
|
+
sampleVideoUrl: z.string().optional().describe("사용자가 샘플로 지정한 유튜브 URL 또는 11자 영상 ID"),
|
|
1132
|
+
provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
|
|
1133
|
+
sampleVoiceHints: z.string().optional().describe("사용자가 말한 보이스 요구 (예: 차분한 중년 남성)"),
|
|
1134
|
+
},
|
|
1135
|
+
wrapCloudHandler(config, async ({ topic = "", scriptExcerpt = "", sampleVideoUrl = "", provider = "typecast", sampleVoiceHints = "" }) => {
|
|
1136
|
+
const notes = [];
|
|
1137
|
+
let sampleAudioBase64 = "";
|
|
1138
|
+
const rawSample = String(sampleVideoUrl || "").trim();
|
|
1139
|
+
if (rawSample) {
|
|
1140
|
+
// 모바일/뮤직/shorts/youtu.be 변형을 표준 watch URL 로 정규화 — 컴패니언의
|
|
1141
|
+
// 유튜브 전용 처리(봇 우회)가 표준 형태에서만 발동한다.
|
|
1142
|
+
const watchUrl = canonicalYoutubeWatchUrl(rawSample);
|
|
1143
|
+
if (!watchUrl) {
|
|
1144
|
+
return textResult("sampleVideoUrl은 유튜브 영상 URL 또는 11자 영상 ID여야 합니다.", { isError: true });
|
|
1145
|
+
}
|
|
1146
|
+
try {
|
|
1147
|
+
// 컴패니언(사용자 PC)이 오디오를 추출 — 90초로 잘라 서버 본문 상한·분석 비용을 지킨다.
|
|
1148
|
+
const wav = await extractCompanionAudio(config.companionUrl, { url: watchUrl });
|
|
1149
|
+
sampleAudioBase64 = trimWavToSeconds(wav, 90).toString("base64");
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
notes.push(
|
|
1152
|
+
`샘플 오디오 추출 실패(${String(error?.message || error)}) — 컴패니언 앱 실행 여부를 확인하세요. 주제 기반으로만 추천합니다.`,
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
if (!topic && !scriptExcerpt && !sampleVoiceHints && !sampleAudioBase64) {
|
|
1157
|
+
// 샘플만 줬는데 추출이 실패한 경우: 누락 안내가 아니라 실패 사유를 그대로 전달한다.
|
|
1158
|
+
if (notes.length) {
|
|
1159
|
+
return textResult(`${notes.join("\n")}\n주제(topic)를 함께 주면 샘플 없이도 추천할 수 있습니다.`, { isError: true });
|
|
1160
|
+
}
|
|
1161
|
+
return textResult("topic, scriptExcerpt, sampleVoiceHints, sampleVideoUrl 중 하나는 필요합니다.", { isError: true });
|
|
1162
|
+
}
|
|
1163
|
+
const payload = await api.request("/api/tracker/tts/recommend-voice", {
|
|
1164
|
+
body: { provider, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic },
|
|
1165
|
+
method: "POST",
|
|
1166
|
+
timeoutMs: 180_000,
|
|
1167
|
+
});
|
|
1168
|
+
if (payload?.ok === false) {
|
|
1169
|
+
return textResult(
|
|
1170
|
+
`보이스 추천 실패: ${payload?.error || "알 수 없는 오류"}\n${payload?.guidance || ""}`.trim(),
|
|
1171
|
+
{ isError: true },
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
return jsonResult({
|
|
1175
|
+
next: "recommendations[0].voiceId를 tts_narration 또는 tts_narration_with_captions의 voice로 사용하세요.",
|
|
1176
|
+
notes: [...notes, ...(Array.isArray(payload?.notes) ? payload.notes : [])],
|
|
1177
|
+
provider: payload?.provider || provider,
|
|
1178
|
+
recommendations: Array.isArray(payload?.recommendations) ? payload.recommendations : [],
|
|
1179
|
+
sampleAnalyzed: Boolean(sampleAudioBase64 && payload?.sampleVoiceProfile),
|
|
1180
|
+
sampleVoiceProfile: payload?.sampleVoiceProfile || null,
|
|
1181
|
+
});
|
|
1182
|
+
}),
|
|
1183
|
+
);
|
|
1184
|
+
|
|
1104
1185
|
// ── 상세페이지(PDP) ─────────────────────────────────────────────────────────
|
|
1105
1186
|
server.tool(
|
|
1106
1187
|
"generate_pdp",
|
package/lib/companion-client.mjs
CHANGED
|
@@ -69,6 +69,62 @@ export async function triageCompanion(companionUrl) {
|
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// 유튜브 URL에서 16kHz mono PCM WAV 를 추출한다 — 보이스 샘플 분석용.
|
|
73
|
+
// 컴패니언이 로컬(사용자 PC)에서 yt-dlp+ffmpeg 를 돌리므로 서버측 유튜브 차단을 우회한다.
|
|
74
|
+
// 컴패니언은 영상 전체 오디오를 반환(긴 영상이면 시간당 ~115MB WAV)하므로,
|
|
75
|
+
// 앞 maxBytes 만 스트리밍으로 받고 끊는다 — 필요한 건 앞 90초뿐이다.
|
|
76
|
+
export async function extractCompanionAudio(
|
|
77
|
+
companionUrl,
|
|
78
|
+
{ url, sampleRate = 16000, timeoutMs = 180_000, maxBytes = 8 * 1024 * 1024 } = {},
|
|
79
|
+
) {
|
|
80
|
+
const response = await fetch(`${companionUrl}/api/audio/extract`, {
|
|
81
|
+
body: JSON.stringify({ filenameHint: "voice-sample", sampleRate, url }),
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
method: "POST",
|
|
84
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
// 컴패니언은 실패 사유를 JSON {error}로 준다 — yt-dlp 차단/URL 문제를 행동 가능하게 전달.
|
|
88
|
+
let detail = "";
|
|
89
|
+
try {
|
|
90
|
+
const text = await response.text();
|
|
91
|
+
try {
|
|
92
|
+
detail = String(JSON.parse(text)?.error || "").trim();
|
|
93
|
+
} catch {
|
|
94
|
+
detail = text.slice(0, 200).trim();
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
detail = "";
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`컴패니언 오디오 추출 실패 (HTTP ${response.status}${detail ? `: ${detail}` : ""})`);
|
|
100
|
+
}
|
|
101
|
+
if (!response.body) {
|
|
102
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
103
|
+
if (!buffer.length) throw new Error("컴패니언이 빈 오디오를 반환했습니다.");
|
|
104
|
+
return buffer.subarray(0, maxBytes);
|
|
105
|
+
}
|
|
106
|
+
const reader = response.body.getReader();
|
|
107
|
+
const parts = [];
|
|
108
|
+
let received = 0;
|
|
109
|
+
try {
|
|
110
|
+
while (received < maxBytes) {
|
|
111
|
+
const { done, value } = await reader.read();
|
|
112
|
+
if (done) break;
|
|
113
|
+
if (value?.length) {
|
|
114
|
+
parts.push(Buffer.from(value));
|
|
115
|
+
received += value.length;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
await reader.cancel().catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
const buffer = Buffer.concat(parts).subarray(0, maxBytes);
|
|
122
|
+
if (!buffer.length) {
|
|
123
|
+
throw new Error("컴패니언이 빈 오디오를 반환했습니다.");
|
|
124
|
+
}
|
|
125
|
+
return buffer;
|
|
126
|
+
}
|
|
127
|
+
|
|
72
128
|
export async function createRenderJob(companionUrl, payload) {
|
|
73
129
|
return requestJson(companionUrl, "/api/moviepy/render-jobs", {
|
|
74
130
|
body: payload,
|
package/lib/config.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
// 프록시 버전 — 서버가 X-AImakeAll-MCP-Version 으로 하한을 강제(426)할 수 있다.
|
|
5
|
-
export const MCP_PROXY_VERSION = "0.
|
|
5
|
+
export const MCP_PROXY_VERSION = "0.6.0";
|
|
6
6
|
|
|
7
7
|
export const DEFAULT_API_BASE = "https://aimakeall.com";
|
|
8
8
|
export const DEFAULT_COMPANION_URL = "http://127.0.0.1:9876";
|
package/lib/payload-guard.mjs
CHANGED
|
@@ -23,6 +23,44 @@ export function isAllowedYoutubeUrl(value) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 유튜브 입력(11자 ID·youtu.be·모바일/뮤직 호스트·shorts/live/embed 경로)을
|
|
30
|
+
* 표준 www.youtube.com/watch URL 로 정규화한다. 컴패니언의 유튜브 전용 처리(봇 우회 전략)는
|
|
31
|
+
* 표준 호스트/경로에서만 발동하므로, 변형 URL 을 그대로 넘기면 조용히 품질이 떨어진다.
|
|
32
|
+
* 유튜브 영상으로 해석할 수 없으면 "" 반환.
|
|
33
|
+
*/
|
|
34
|
+
export function canonicalYoutubeWatchUrl(value) {
|
|
35
|
+
const raw = String(value || "").trim();
|
|
36
|
+
if (!raw) return "";
|
|
37
|
+
if (YOUTUBE_VIDEO_ID_PATTERN.test(raw)) {
|
|
38
|
+
return `https://www.youtube.com/watch?v=${raw}`;
|
|
39
|
+
}
|
|
40
|
+
let url;
|
|
41
|
+
try {
|
|
42
|
+
url = new URL(raw);
|
|
43
|
+
} catch {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return "";
|
|
47
|
+
const host = url.hostname.toLowerCase();
|
|
48
|
+
if (!ALLOWED_YOUTUBE_HOSTS.has(host)) return "";
|
|
49
|
+
|
|
50
|
+
let videoId = "";
|
|
51
|
+
if (host === "youtu.be") {
|
|
52
|
+
videoId = url.pathname.split("/").filter(Boolean)[0] || "";
|
|
53
|
+
} else {
|
|
54
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
55
|
+
if (url.pathname === "/watch" || url.pathname === "/watch/") {
|
|
56
|
+
videoId = url.searchParams.get("v") || "";
|
|
57
|
+
} else if (segments.length === 2 && ["shorts", "live", "embed", "v"].includes(segments[0])) {
|
|
58
|
+
videoId = segments[1];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return YOUTUBE_VIDEO_ID_PATTERN.test(videoId) ? `https://www.youtube.com/watch?v=${videoId}` : "";
|
|
62
|
+
}
|
|
63
|
+
|
|
26
64
|
function isHttpsUrl(value) {
|
|
27
65
|
try {
|
|
28
66
|
return new URL(String(value || "")).protocol === "https:";
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
canonicalYoutubeWatchUrl,
|
|
5
6
|
checkRenderPayloadSize,
|
|
6
7
|
COMPANION_RENDER_BODY_LIMIT_BYTES,
|
|
7
8
|
isAllowedYoutubeUrl,
|
|
@@ -74,3 +75,24 @@ test("출력 파일명 정화 — 금지문자 치환·길이 상한·확장자
|
|
|
74
75
|
assert.ok(long.length <= 120);
|
|
75
76
|
assert.ok(long.endsWith(".mp4"));
|
|
76
77
|
});
|
|
78
|
+
|
|
79
|
+
test("canonicalYoutubeWatchUrl — 변형 입력을 표준 watch URL 로 정규화", () => {
|
|
80
|
+
const canonical = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
|
|
81
|
+
assert.equal(canonicalYoutubeWatchUrl("jNQXAC9IVRw"), canonical);
|
|
82
|
+
assert.equal(canonicalYoutubeWatchUrl("https://youtu.be/jNQXAC9IVRw?si=abc"), canonical);
|
|
83
|
+
assert.equal(canonicalYoutubeWatchUrl("https://www.youtube.com/watch?v=jNQXAC9IVRw&t=10s"), canonical);
|
|
84
|
+
assert.equal(canonicalYoutubeWatchUrl("https://m.youtube.com/watch?v=jNQXAC9IVRw"), canonical);
|
|
85
|
+
assert.equal(canonicalYoutubeWatchUrl("https://music.youtube.com/watch?v=jNQXAC9IVRw"), canonical);
|
|
86
|
+
assert.equal(canonicalYoutubeWatchUrl("https://www.youtube.com/shorts/jNQXAC9IVRw"), canonical);
|
|
87
|
+
assert.equal(canonicalYoutubeWatchUrl("https://www.youtube.com/live/jNQXAC9IVRw"), canonical);
|
|
88
|
+
assert.equal(canonicalYoutubeWatchUrl("https://www.youtube.com/embed/jNQXAC9IVRw"), canonical);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("canonicalYoutubeWatchUrl — 유튜브가 아니거나 ID 불명이면 빈 문자열", () => {
|
|
92
|
+
assert.equal(canonicalYoutubeWatchUrl(""), "");
|
|
93
|
+
assert.equal(canonicalYoutubeWatchUrl("https://evil.com/watch?v=jNQXAC9IVRw"), "");
|
|
94
|
+
assert.equal(canonicalYoutubeWatchUrl("https://www.youtube.com/channel/UCabc"), "");
|
|
95
|
+
assert.equal(canonicalYoutubeWatchUrl("https://www.youtube.com/watch?v=short"), "");
|
|
96
|
+
assert.equal(canonicalYoutubeWatchUrl("file:///etc/passwd"), "");
|
|
97
|
+
assert.equal(canonicalYoutubeWatchUrl("그냥 텍스트"), "");
|
|
98
|
+
});
|
package/lib/wav-trim.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// PCM WAV 앞부분 트림 — 컴패니언 /api/audio/extract 가 주는 오디오를 서버 전송 전에
|
|
2
|
+
// 90초 이내로 잘라 JSON 본문 상한과 Gemini 오디오 비용을 지킨다.
|
|
3
|
+
// (서버 server/voice-recommendation.mjs 의 동일 로직과 독립 구현 — 이 패키지는 npm 단독 배포)
|
|
4
|
+
|
|
5
|
+
export function trimWavToSeconds(buffer, maxSeconds = 90) {
|
|
6
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 44) {
|
|
7
|
+
throw new Error("WAV 버퍼가 아닙니다.");
|
|
8
|
+
}
|
|
9
|
+
if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") {
|
|
10
|
+
throw new Error("RIFF/WAVE 형식이 아닙니다.");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let offset = 12;
|
|
14
|
+
let fmt = null;
|
|
15
|
+
let dataOffset = -1;
|
|
16
|
+
let dataSize = 0;
|
|
17
|
+
while (offset + 8 <= buffer.length) {
|
|
18
|
+
const chunkId = buffer.toString("ascii", offset, offset + 4);
|
|
19
|
+
const chunkSize = buffer.readUInt32LE(offset + 4);
|
|
20
|
+
const body = offset + 8;
|
|
21
|
+
if (chunkId === "fmt " && body + 16 <= buffer.length) {
|
|
22
|
+
fmt = {
|
|
23
|
+
audioFormat: buffer.readUInt16LE(body),
|
|
24
|
+
bitsPerSample: buffer.readUInt16LE(body + 14),
|
|
25
|
+
blockAlign: buffer.readUInt16LE(body + 12),
|
|
26
|
+
byteRate: buffer.readUInt32LE(body + 8),
|
|
27
|
+
numChannels: buffer.readUInt16LE(body + 2),
|
|
28
|
+
sampleRate: buffer.readUInt32LE(body + 4),
|
|
29
|
+
};
|
|
30
|
+
} else if (chunkId === "data" && dataOffset < 0) {
|
|
31
|
+
dataOffset = body;
|
|
32
|
+
dataSize = Math.min(chunkSize, buffer.length - body);
|
|
33
|
+
}
|
|
34
|
+
// 청크는 워드 정렬 — 홀수 크기면 패딩 1바이트.
|
|
35
|
+
offset = body + chunkSize + (chunkSize % 2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!fmt || dataOffset < 0) {
|
|
39
|
+
throw new Error("WAV fmt/data 청크를 찾지 못했습니다.");
|
|
40
|
+
}
|
|
41
|
+
if (fmt.audioFormat !== 1) {
|
|
42
|
+
throw new Error("PCM WAV 만 지원합니다.");
|
|
43
|
+
}
|
|
44
|
+
// 헤더 필드 신뢰 범위 검증 — 조작된 byteRate 로 트림 상한을 우회하거나
|
|
45
|
+
// 파생 계산이 고정폭 헤더 쓰기를 넘치게(RangeError) 만드는 것을 차단한다.
|
|
46
|
+
if (!Number.isInteger(fmt.sampleRate) || fmt.sampleRate < 4000 || fmt.sampleRate > 192000) {
|
|
47
|
+
throw new Error(`지원하지 않는 WAV 샘플레이트입니다: ${fmt.sampleRate}`);
|
|
48
|
+
}
|
|
49
|
+
if (![8, 16, 24, 32].includes(fmt.bitsPerSample)) {
|
|
50
|
+
throw new Error(`지원하지 않는 WAV 비트수입니다: ${fmt.bitsPerSample}`);
|
|
51
|
+
}
|
|
52
|
+
if (!Number.isInteger(fmt.numChannels) || fmt.numChannels < 1 || fmt.numChannels > 8) {
|
|
53
|
+
throw new Error(`지원하지 않는 WAV 채널 수입니다: ${fmt.numChannels}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// byteRate 는 헤더의 자기신고 값을 무시하고 sampleRate×blockAlign 으로 파생한다 —
|
|
57
|
+
// 디코더도 같은 식으로 길이를 해석하므로, 트림 결과의 실제 재생 길이가 항상 상한 이내가 된다.
|
|
58
|
+
const blockAlign = fmt.numChannels * Math.ceil(fmt.bitsPerSample / 8);
|
|
59
|
+
const byteRate = fmt.sampleRate * blockAlign;
|
|
60
|
+
const seconds = Number.isFinite(Number(maxSeconds)) && Number(maxSeconds) > 0 ? Number(maxSeconds) : 90;
|
|
61
|
+
const rawBytes = Math.floor(byteRate * seconds);
|
|
62
|
+
const alignedBytes = Math.max(blockAlign, rawBytes - (rawBytes % blockAlign));
|
|
63
|
+
const keep = Math.min(dataSize, alignedBytes);
|
|
64
|
+
|
|
65
|
+
const header = Buffer.alloc(44);
|
|
66
|
+
header.write("RIFF", 0, "ascii");
|
|
67
|
+
header.writeUInt32LE(36 + keep, 4);
|
|
68
|
+
header.write("WAVE", 8, "ascii");
|
|
69
|
+
header.write("fmt ", 12, "ascii");
|
|
70
|
+
header.writeUInt32LE(16, 16);
|
|
71
|
+
header.writeUInt16LE(1, 20);
|
|
72
|
+
header.writeUInt16LE(fmt.numChannels, 22);
|
|
73
|
+
header.writeUInt32LE(fmt.sampleRate, 24);
|
|
74
|
+
header.writeUInt32LE(byteRate, 28);
|
|
75
|
+
header.writeUInt16LE(blockAlign, 32);
|
|
76
|
+
header.writeUInt16LE(fmt.bitsPerSample, 34);
|
|
77
|
+
header.write("data", 36, "ascii");
|
|
78
|
+
header.writeUInt32LE(keep, 40);
|
|
79
|
+
return Buffer.concat([header, buffer.subarray(dataOffset, dataOffset + keep)]);
|
|
80
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { trimWavToSeconds } from "./wav-trim.mjs";
|
|
5
|
+
|
|
6
|
+
function buildTestWav({ seconds = 3, sampleRate = 16000, numChannels = 1, extraChunk = false } = {}) {
|
|
7
|
+
const blockAlign = numChannels * 2;
|
|
8
|
+
const byteRate = sampleRate * blockAlign;
|
|
9
|
+
const dataBytes = Math.floor(seconds * byteRate);
|
|
10
|
+
const chunks = [];
|
|
11
|
+
|
|
12
|
+
const fmt = Buffer.alloc(24);
|
|
13
|
+
fmt.write("fmt ", 0, "ascii");
|
|
14
|
+
fmt.writeUInt32LE(16, 4);
|
|
15
|
+
fmt.writeUInt16LE(1, 8);
|
|
16
|
+
fmt.writeUInt16LE(numChannels, 10);
|
|
17
|
+
fmt.writeUInt32LE(sampleRate, 12);
|
|
18
|
+
fmt.writeUInt32LE(byteRate, 16);
|
|
19
|
+
fmt.writeUInt16LE(blockAlign, 20);
|
|
20
|
+
fmt.writeUInt16LE(16, 22);
|
|
21
|
+
chunks.push(fmt);
|
|
22
|
+
|
|
23
|
+
if (extraChunk) {
|
|
24
|
+
const listBody = Buffer.from("INFOxyz", "ascii");
|
|
25
|
+
const list = Buffer.alloc(8 + listBody.length + (listBody.length % 2));
|
|
26
|
+
list.write("LIST", 0, "ascii");
|
|
27
|
+
list.writeUInt32LE(listBody.length, 4);
|
|
28
|
+
listBody.copy(list, 8);
|
|
29
|
+
chunks.push(list);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const data = Buffer.alloc(8 + dataBytes);
|
|
33
|
+
data.write("data", 0, "ascii");
|
|
34
|
+
data.writeUInt32LE(dataBytes, 4);
|
|
35
|
+
chunks.push(data);
|
|
36
|
+
|
|
37
|
+
const body = Buffer.concat(chunks);
|
|
38
|
+
const header = Buffer.alloc(12);
|
|
39
|
+
header.write("RIFF", 0, "ascii");
|
|
40
|
+
header.writeUInt32LE(4 + body.length, 4);
|
|
41
|
+
header.write("WAVE", 8, "ascii");
|
|
42
|
+
return Buffer.concat([header, body]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function wavSeconds(buffer) {
|
|
46
|
+
return buffer.readUInt32LE(40) / buffer.readUInt32LE(28);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("긴 WAV 를 지정 초로 자르고 헤더를 재구성한다", () => {
|
|
50
|
+
const wav = buildTestWav({ seconds: 300 });
|
|
51
|
+
const trimmed = trimWavToSeconds(wav, 90);
|
|
52
|
+
assert.ok(trimmed.length < wav.length);
|
|
53
|
+
assert.equal(trimmed.toString("ascii", 0, 4), "RIFF");
|
|
54
|
+
assert.ok(Math.abs(wavSeconds(trimmed) - 90) < 0.01);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("짧은 WAV·부가 청크·스테레오도 안전하게 처리한다", () => {
|
|
58
|
+
const short = trimWavToSeconds(buildTestWav({ seconds: 2 }), 90);
|
|
59
|
+
assert.ok(Math.abs(wavSeconds(short) - 2) < 0.01);
|
|
60
|
+
|
|
61
|
+
const listed = trimWavToSeconds(buildTestWav({ seconds: 5, extraChunk: true }), 2);
|
|
62
|
+
assert.ok(Math.abs(wavSeconds(listed) - 2) < 0.01);
|
|
63
|
+
|
|
64
|
+
const stereo = trimWavToSeconds(buildTestWav({ seconds: 6, numChannels: 2 }), 2);
|
|
65
|
+
assert.equal(stereo.readUInt32LE(40) % 4, 0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("비정상 입력은 명시적으로 거부한다", () => {
|
|
69
|
+
assert.throws(() => trimWavToSeconds(Buffer.from("nope"), 90), /WAV/);
|
|
70
|
+
assert.throws(() => trimWavToSeconds(Buffer.alloc(100), 90), /RIFF/);
|
|
71
|
+
const nonPcm = buildTestWav({ seconds: 1 });
|
|
72
|
+
nonPcm.writeUInt16LE(3, 20);
|
|
73
|
+
assert.throws(() => trimWavToSeconds(nonPcm, 90), /PCM/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("조작된 byteRate 로 트림 상한을 우회할 수 없고, 범위 밖 헤더는 한국어 오류", () => {
|
|
77
|
+
const lying = buildTestWav({ seconds: 300 });
|
|
78
|
+
lying.writeUInt32LE(0xffffffff, 28); // byteRate 거짓 신고
|
|
79
|
+
const trimmed = trimWavToSeconds(lying, 90);
|
|
80
|
+
assert.equal(trimmed.readUInt32LE(40), 90 * 16000 * 2, "파생 byteRate 기준 90초로 잘려야 한다");
|
|
81
|
+
|
|
82
|
+
const badRate = buildTestWav({ seconds: 1 });
|
|
83
|
+
badRate.writeUInt32LE(0xffffffff, 24);
|
|
84
|
+
assert.throws(() => trimWavToSeconds(badRate, 90), /샘플레이트/);
|
|
85
|
+
|
|
86
|
+
const badChannels = buildTestWav({ seconds: 1 });
|
|
87
|
+
badChannels.writeUInt16LE(65535, 22);
|
|
88
|
+
assert.throws(() => trimWavToSeconds(badChannels, 90), /채널/);
|
|
89
|
+
});
|