aimakeall-mcp 0.7.0 → 0.9.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 +24 -1
- package/lib/cloud-tools.mjs +326 -7
- package/lib/companion-tools.mjs +53 -0
- package/lib/config.mjs +1 -1
- package/lib/wav-dsp.mjs +82 -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.9.0"],
|
|
20
20
|
"env": { "AIMAKEALL_PAT": "aio_pat_..." }
|
|
21
21
|
}
|
|
22
22
|
}
|
|
@@ -46,6 +46,29 @@ plan_shorts_video → (씬마다) generate_scene_image → [QC: 동봉 이미지
|
|
|
46
46
|
- `recommend_voice`는 주제·샘플 영상(유튜브)에 맞는 보이스를 자동 선정합니다 — 샘플 화자 분석은 컴패니언 앱이 실행 중일 때만 동작합니다.
|
|
47
47
|
- 자체검증(QC): 씬 이미지/영상 결과에 이미지·프레임 블록이 동봉되어 에이전트가 직접 보고 판정합니다. 비전 미지원 클라이언트는 `verify_scene_image`/`verify_scene_video`(서버 Gemini 판정)를 쓰세요 — 불합격 시 `regenerationHint`를 반영해 재생성.
|
|
48
48
|
- 하단자막 강조: `tts_narration_with_captions`의 `subtitleLines`와 `plan_shorts_video`의 `emphasisKeywords`를 `stitch_timeline`에 함께 넘기면 자막 안 해당 단어만 노랑/큰 글씨로 강조됩니다(최신 컴패니언 런타임 필요 — 구버전은 일반 자막으로 안전 강등).
|
|
49
|
+
- 렌더 영수증: `render_status`(완료 시)와 `render_result`가 산출물 실측(`summary.measured` — 길이·해상도·오디오·디코드 청결성·파일 크기)을 요청 규격과 대조해 `mismatches`로 알려줍니다. 불일치가 있으면 저장 전 원인을 확인하세요.
|
|
50
|
+
- 원가 가시화: 생성 전 `estimate_video_cost`로 견적을 내고, 작업 후 `get_cost_report`로 실제 지출을 확인하세요. 어두운/검은 컷 의심 시 `verify_render_darkness`로 완성본 휘도를 실측할 수 있습니다.
|
|
51
|
+
- 캐릭터 일관성: `plan_*`에 `characters`(이름·외모 앵커·의상 고정)를 넘기면 씬마다 identity lock 이 프롬프트에 강제 주입되고, `generate_scene_image`의 `identityLock`으로 재생성 시에도 유지됩니다.
|
|
52
|
+
|
|
53
|
+
## 채널 규격 지문 (벤치마킹)
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
measure_channel_spec(벤치마크 영상 URL ≤5) → fingerprint → plan_shorts_video(channelFingerprint: fingerprint)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- 벤치마크 채널의 길이·컷/분·라우드니스·말끝 스타일을 **실측**해 숫자 규격으로 만들고, 대본 생성이 그 규격을 강제하게 합니다. 실측(샷 감지·오디오 추출)은 컴패니언 앱이 수행합니다.
|
|
60
|
+
- 대본은 생성 후 규격 게이트(글자수·문장수·종결어미 교차·교훈조 아웃트로 금지)로 검증되며, 위반 시 1회 자동 리라이트 후 남은 위반을 `specWarnings`로 돌려줍니다.
|
|
61
|
+
|
|
62
|
+
## 짜집기 (리메이크) 플로우
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
detect_shots(원본별, 컴패니언) → analyze_edit_points(videos + shotsFiles) → 에이전트가 sceneCatalog에서 컷 선별·내레이션 작성
|
|
66
|
+
→ prepare_remake_timeline(sourceVideos + rows) → render_start(payloadPath) → render_status(폴링) → render_result
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- 행(row) 모드: `[N]` TTS 내레이션(원본 무음) / `[S]` 원본 대사 유지 / `[A]` 현장음 유지 / `[SN]` 원본 대사 + TTS.
|
|
70
|
+
- `analyze_edit_points`가 돌려주는 영상별 `burnedSubtitle`(박힌 자막 관측)을 `prepare_remake_timeline`의 `sourceVideos[].burnedSubtitle`로 그대로 넘기고 `subtitleCropMode: "auto"`를 주면 자막 박힌 소스만 하단 크롭됩니다.
|
|
71
|
+
- `[N]`/`[SN]` 행의 `audioContent`는 유료 TTS로 합성됩니다 — 실행 전 사용자 확인을 받으세요. 원본 다운로드·렌더는 이 PC의 컴패니언이 수행합니다.
|
|
49
72
|
|
|
50
73
|
## 한도
|
|
51
74
|
|
package/lib/cloud-tools.mjs
CHANGED
|
@@ -23,12 +23,16 @@ import {
|
|
|
23
23
|
import { mp3DurationSec } from "./mp3-duration.mjs";
|
|
24
24
|
import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
|
|
25
25
|
import { createUsageEventId } from "./usage-event.mjs";
|
|
26
|
+
import { loudnessDbfsFromWav, silenceRatioFromWav } from "./wav-dsp.mjs";
|
|
26
27
|
import { trimWavToSeconds } from "./wav-trim.mjs";
|
|
27
28
|
|
|
28
29
|
// 여러 파일을 base64 로 싣는 요청의 인코딩 크기 합이 서버 JSON 상한을 넘지 않게 사전 검사.
|
|
29
30
|
// 서버가 전체 업로드를 받은 뒤 413 을 내는 것을 막고 행동 가능한 한국어 안내를 준다.
|
|
30
31
|
const BODY_BUDGET_BYTES = SERVER_JSON_BODY_LIMIT_BYTES - 512 * 1024; // 바디의 나머지 필드 여유
|
|
31
32
|
|
|
33
|
+
// 컴패니언 계약 상수 — companion/index.cjs 의 MAX_SHOT_COUNT 와 맞춘다.
|
|
34
|
+
const COMPANION_MAX_SHOTS = 18;
|
|
35
|
+
|
|
32
36
|
function assertEncodedBudget(filePaths, { context = "요청" } = {}) {
|
|
33
37
|
let total = 0;
|
|
34
38
|
for (const filePath of filePaths.filter(Boolean)) {
|
|
@@ -173,6 +177,9 @@ function trimPlanScenes(scenes) {
|
|
|
173
177
|
ratio: scene?.ratio,
|
|
174
178
|
sceneNarration: scene?.sceneNarration,
|
|
175
179
|
scenePurpose: scene?.scenePurpose,
|
|
180
|
+
visibleCharacterSlots: Array.isArray(scene?.visibleCharacterSlots) && scene.visibleCharacterSlots.length
|
|
181
|
+
? scene.visibleCharacterSlots
|
|
182
|
+
: undefined,
|
|
176
183
|
}));
|
|
177
184
|
}
|
|
178
185
|
|
|
@@ -191,6 +198,51 @@ export function registerCloudTools(server, config, api) {
|
|
|
191
198
|
wrapCloudHandler(config, async () => jsonResult(await api.request("/api/tracker/usage"))),
|
|
192
199
|
);
|
|
193
200
|
|
|
201
|
+
server.tool(
|
|
202
|
+
"get_cost_report",
|
|
203
|
+
"내 계정의 AI 원가 원장을 요약 조회합니다 — 총 지출, 모델별 상위 지출, 최근 이벤트. 여러 씬을 생성/재생성하기 전후로 호출해 실제 지출 델타를 확인하세요 (추측 과금 방지).",
|
|
204
|
+
{
|
|
205
|
+
limit: z.number().int().min(10).max(120).optional().describe("최근 이벤트 조회 수, 기본 60"),
|
|
206
|
+
},
|
|
207
|
+
wrapCloudHandler(config, async ({ limit = 60 }) => {
|
|
208
|
+
const payload = await api.request(`/api/tracker/account/cost-ledger?limit=${limit}`, { method: "GET", timeoutMs: 30_000 });
|
|
209
|
+
const ai = payload?.productionCost?.ai || {};
|
|
210
|
+
const byModel = ai?.byModel && typeof ai.byModel === "object" ? ai.byModel : {};
|
|
211
|
+
const topModels = Object.entries(byModel)
|
|
212
|
+
.map(([modelKey, row]) => ({ calls: row?.calls || 0, modelKey, usd: row?.usd || 0 }))
|
|
213
|
+
.sort((left, right) => right.usd - left.usd)
|
|
214
|
+
.slice(0, 8);
|
|
215
|
+
const recent = (Array.isArray(ai?.history) ? ai.history : []).slice(0, 15)
|
|
216
|
+
.map((event) => ({ at: event?.createdAt, kind: event?.kind, label: event?.label, modelKey: event?.modelKey, usd: event?.usd }));
|
|
217
|
+
return jsonResult({
|
|
218
|
+
recent,
|
|
219
|
+
topModels,
|
|
220
|
+
totalCalls: ai?.calls ?? payload?.summary?.calls,
|
|
221
|
+
totalUsd: ai?.totalUsd ?? payload?.summary?.totalUsd,
|
|
222
|
+
});
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
server.tool(
|
|
227
|
+
"estimate_video_cost",
|
|
228
|
+
"생성 파이프라인 비용을 실행 전에 견적냅니다 (공급자 호출·과금 없음, 단가표 계산). 씬 수를 늘리거나 재생성 루프를 돌기 전에 호출해 예상 지출을 확인하세요.",
|
|
229
|
+
{
|
|
230
|
+
sceneCount: z.number().int().min(0).max(60).describe("씬 수"),
|
|
231
|
+
imageModel: z.string().optional().describe("기본 gpt-image-2-beta"),
|
|
232
|
+
imagesPerScene: z.number().int().min(0).max(4).optional().describe("씬당 이미지 수, 기본 1"),
|
|
233
|
+
referenceImageCount: z.number().int().min(0).max(6).optional().describe("이미지당 참조 장수 (일부 모델 단가 가산)"),
|
|
234
|
+
videoModel: z.string().optional().describe("기본 kie-grok-imagine"),
|
|
235
|
+
videoSecondsPerScene: z.number().min(0).max(30).optional().describe("씬당 영상 초 (0이면 영상 미포함)"),
|
|
236
|
+
videoQuality: z.string().optional().describe("기본 720p"),
|
|
237
|
+
ttsProvider: z.enum(["typecast", "elevenlabs", "supertone"]).optional().describe("기본 typecast"),
|
|
238
|
+
ttsChars: z.number().int().min(0).max(20000).optional().describe("내레이션 글자 수"),
|
|
239
|
+
},
|
|
240
|
+
wrapCloudHandler(config, async (input) => {
|
|
241
|
+
const payload = await api.request("/api/tracker/cost/estimate", { body: input, method: "POST", timeoutMs: 30_000 });
|
|
242
|
+
return jsonResult(payload);
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
|
|
194
246
|
server.tool(
|
|
195
247
|
"plan_shorts_video",
|
|
196
248
|
"쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조).",
|
|
@@ -200,18 +252,41 @@ export function registerCloudTools(server, config, api) {
|
|
|
200
252
|
categoryId: z.enum(["community-shorts", "viral-shorts", "insight"]).optional().describe("기본 community-shorts. insight=명언(통찰) 쇼츠"),
|
|
201
253
|
targetCustomer: z.string().optional(),
|
|
202
254
|
tone: z.string().optional().describe("기본 '자동 추천'"),
|
|
203
|
-
sceneCount: z.number().int().min(1).max(10).optional().describe("
|
|
255
|
+
sceneCount: z.number().int().min(1).max(10).optional().describe("씬 수. 생략하면 channelFingerprint 의 목표 길이로 자동 산출(지문도 없으면 3)"),
|
|
256
|
+
channelFingerprint: z.object({
|
|
257
|
+
sampleSize: z.number().optional(),
|
|
258
|
+
durationTargetSec: z.number().nullable().optional(),
|
|
259
|
+
cutIntervalSec: z.number().nullable().optional(),
|
|
260
|
+
cutsPerMin: z.number().nullable().optional(),
|
|
261
|
+
cpsTarget: z.number().nullable().optional(),
|
|
262
|
+
loudnessDbfs: z.number().nullable().optional(),
|
|
263
|
+
silenceRatio: z.number().nullable().optional(),
|
|
264
|
+
subtitleYPct: z.number().nullable().optional(),
|
|
265
|
+
subtitleLines: z.number().nullable().optional(),
|
|
266
|
+
endingStyle: z.string().nullable().optional(),
|
|
267
|
+
emphasisCount: z.number().nullable().optional(),
|
|
268
|
+
hasBgm: z.boolean().nullable().optional(),
|
|
269
|
+
}).passthrough().optional().describe("measure_channel_spec 이 반환한 fingerprint — 대본이 실측 규격을 강제하게 함"),
|
|
270
|
+
characters: z.array(z.object({
|
|
271
|
+
name: z.string().describe("캐릭터 이름"),
|
|
272
|
+
appearance: z.string().optional().describe("외모 앵커 (얼굴형·헤어·피부톤·의상 등)"),
|
|
273
|
+
role: z.string().optional(),
|
|
274
|
+
outfitLock: z.string().optional().describe("의상 고정 (예: navy hoodie)"),
|
|
275
|
+
forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
|
|
276
|
+
})).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
|
|
204
277
|
},
|
|
205
|
-
wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount =
|
|
278
|
+
wrapCloudHandler(config, async ({ topic = "", copy = "", categoryId = "community-shorts", targetCustomer = "", tone = "자동 추천", sceneCount = 0, characters = [], channelFingerprint = null }) => {
|
|
206
279
|
if (!String(topic).trim() && !String(copy).trim()) {
|
|
207
280
|
return textResult("topic 또는 copy 중 하나는 입력해야 합니다.", { isError: true });
|
|
208
281
|
}
|
|
209
282
|
const payload = await api.request("/api/tracker/shorts-video/plan", {
|
|
210
283
|
body: {
|
|
211
284
|
categoryId,
|
|
285
|
+
channelFingerprint,
|
|
212
286
|
copy,
|
|
213
287
|
operationId: createUsageEventId("shorts-plan"),
|
|
214
288
|
sceneCount,
|
|
289
|
+
characters,
|
|
215
290
|
selectedCharacters: [],
|
|
216
291
|
skillOverride: null,
|
|
217
292
|
targetCustomer,
|
|
@@ -222,12 +297,14 @@ export function registerCloudTools(server, config, api) {
|
|
|
222
297
|
timeoutMs: 240_000,
|
|
223
298
|
});
|
|
224
299
|
return jsonResult({
|
|
300
|
+
charactersUsed: Array.isArray(payload?.charactersUsed) ? payload.charactersUsed : undefined,
|
|
225
301
|
ctaText: payload?.ctaText,
|
|
226
302
|
emphasisKeywords: Array.isArray(payload?.emphasisKeywords) ? payload.emphasisKeywords : [],
|
|
227
303
|
narrationScript: payload?.narrationScript,
|
|
228
304
|
ok: payload?.ok,
|
|
229
305
|
scenes: trimPlanScenes(payload?.scenes),
|
|
230
306
|
skill: payload?.skill,
|
|
307
|
+
specWarnings: Array.isArray(payload?.specWarnings) && payload.specWarnings.length ? payload.specWarnings : undefined,
|
|
231
308
|
tagline: payload?.tagline,
|
|
232
309
|
});
|
|
233
310
|
}),
|
|
@@ -323,8 +400,15 @@ export function registerCloudTools(server, config, api) {
|
|
|
323
400
|
categoryId: z.string().optional().describe("기본 ecommerce"),
|
|
324
401
|
sceneCount: z.number().int().min(1).max(12).optional().describe("기본 6"),
|
|
325
402
|
researchBrief: z.string().optional().describe("research_product 결과의 planBrief — 불만·후킹·경쟁공백이 기획에 반영됨"),
|
|
403
|
+
characters: z.array(z.object({
|
|
404
|
+
name: z.string().describe("캐릭터 이름"),
|
|
405
|
+
appearance: z.string().optional().describe("외모 앵커 (얼굴형·헤어·피부톤·의상 등)"),
|
|
406
|
+
role: z.string().optional(),
|
|
407
|
+
outfitLock: z.string().optional().describe("의상 고정 (예: navy hoodie)"),
|
|
408
|
+
forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
|
|
409
|
+
})).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
|
|
326
410
|
},
|
|
327
|
-
wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" }) => {
|
|
411
|
+
wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" , characters = [] }) => {
|
|
328
412
|
// 확장자 검증(임의 파일 업로드 차단) + 합산 크기 예산(서버 413 사전 차단).
|
|
329
413
|
for (const filePath of productImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "이미지" });
|
|
330
414
|
if (modelImagePath) assertAllowedInputFile(modelImagePath, ALLOWED_IMAGE_EXTS, { kind: "모델 이미지" });
|
|
@@ -340,6 +424,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
340
424
|
productName,
|
|
341
425
|
researchBrief,
|
|
342
426
|
sceneCount,
|
|
427
|
+
characters,
|
|
343
428
|
selectedCharacters: [],
|
|
344
429
|
skillOverride: null,
|
|
345
430
|
targetCustomer,
|
|
@@ -367,11 +452,15 @@ export function registerCloudTools(server, config, api) {
|
|
|
367
452
|
aspectRatio: z.string().optional().describe("기본 9:16"),
|
|
368
453
|
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"),
|
|
369
454
|
returnImage: z.boolean().optional().describe("기본 true — 결과 이미지를 직접 보고 QC 할 수 있게 이미지 블록으로 함께 반환"),
|
|
455
|
+
identityLock: z.string().optional().describe("plan 응답 charactersUsed[].identityLock 을 그대로 — 프롬프트 말미에 리터럴 부착해 캐릭터 일관성 잠금 (paraphrase 금지)"),
|
|
370
456
|
resolution: z.string().optional().describe("기본 1K"),
|
|
371
457
|
referenceImageUrls: z.array(z.string()).max(6).optional().describe("참조 이미지 URL (씬1 앵커 등)"),
|
|
372
458
|
referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 이미지 로컬 경로 (제품 사진 등)"),
|
|
373
459
|
},
|
|
374
|
-
wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true }) => {
|
|
460
|
+
wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true, identityLock = "" }) => {
|
|
461
|
+
// identity_lock 리터럴 부착 — 이미 포함돼 있으면 재부착하지 않는다(멱등: 프롬프트 캐시 보존).
|
|
462
|
+
const lock = String(identityLock || "").trim();
|
|
463
|
+
if (lock && !prompt.includes(lock)) prompt = `${prompt.trim()}, ${lock}`;
|
|
375
464
|
for (const filePath of referenceImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 이미지" });
|
|
376
465
|
assertEncodedBudget(referenceImagePaths, { context: "참조 이미지" });
|
|
377
466
|
const referenceImages = [
|
|
@@ -690,9 +779,14 @@ export function registerCloudTools(server, config, api) {
|
|
|
690
779
|
emphasisKeywords: z.array(z.string()).max(12).optional().describe("강조 단어 목록 — 자막 라인 안에서 해당 단어만 다른 색/크기로 렌더 (plan_shorts_video의 emphasisKeywords 를 그대로 전달)"),
|
|
691
780
|
emphasisColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional().describe("강조 색 #RRGGBB (기본 #facc15 노랑)"),
|
|
692
781
|
emphasisSizeScale: z.number().optional().describe("강조 크기 배율 0.5~2 (기본 1.2)"),
|
|
782
|
+
channelFingerprint: z.object({
|
|
783
|
+
subtitleYPct: z.number().nullable().optional(),
|
|
784
|
+
subtitleLines: z.number().nullable().optional(),
|
|
785
|
+
hasBgm: z.boolean().nullable().optional(),
|
|
786
|
+
}).passthrough().optional().describe("measure_channel_spec 이 반환한 fingerprint — 자막 세로 위치·BGM 유무를 참고 채널에 맞춥니다"),
|
|
693
787
|
transitionMode: z.enum(["fade", "none"]).optional().describe("기본 fade"),
|
|
694
788
|
},
|
|
695
|
-
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" }) => {
|
|
789
|
+
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", channelFingerprint = null }) => {
|
|
696
790
|
// 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
|
|
697
791
|
if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
|
|
698
792
|
if (bgmAudioPath) assertAllowedInputFile(bgmAudioPath, new Set([".mp3", ".wav"]), { kind: "BGM 오디오" });
|
|
@@ -717,6 +811,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
717
811
|
})),
|
|
718
812
|
emphasisColor,
|
|
719
813
|
emphasisKeywords,
|
|
814
|
+
channelFingerprint,
|
|
720
815
|
emphasisSizeScale,
|
|
721
816
|
subtitleLines,
|
|
722
817
|
titleText,
|
|
@@ -854,6 +949,17 @@ export function registerCloudTools(server, config, api) {
|
|
|
854
949
|
christianContentType: z.enum(["youtube-narration", "prayer", "devotional", "comfort-message"]).optional().describe("styleId=christian일 때 유형"),
|
|
855
950
|
insightFigureNames: z.string().optional().describe("styleId=insight일 때 인물명(쉼표 구분)"),
|
|
856
951
|
userOpinion: z.string().optional().describe("본문에 자연스럽게 녹일 작성자 의견"),
|
|
952
|
+
channelFingerprint: z.object({
|
|
953
|
+
durationTargetSec: z.number().nullable().optional(),
|
|
954
|
+
cutIntervalSec: z.number().nullable().optional(),
|
|
955
|
+
cpsTarget: z.number().nullable().optional(),
|
|
956
|
+
subtitleYPct: z.number().nullable().optional(),
|
|
957
|
+
subtitleLines: z.number().nullable().optional(),
|
|
958
|
+
endingStyle: z.string().nullable().optional(),
|
|
959
|
+
emphasisCount: z.number().nullable().optional(),
|
|
960
|
+
hasBgm: z.boolean().nullable().optional(),
|
|
961
|
+
sampleSize: z.number().optional(),
|
|
962
|
+
}).passthrough().optional().describe("measure_channel_spec 의 fingerprint — 실측 규격(길이·컷·말끝)을 대본에 강제합니다"),
|
|
857
963
|
},
|
|
858
964
|
wrapCloudHandler(config, async ({
|
|
859
965
|
title,
|
|
@@ -867,12 +973,15 @@ export function registerCloudTools(server, config, api) {
|
|
|
867
973
|
christianContentType = "youtube-narration",
|
|
868
974
|
insightFigureNames = "",
|
|
869
975
|
userOpinion = "",
|
|
976
|
+
channelFingerprint = null,
|
|
870
977
|
}) => {
|
|
871
978
|
const SCRIPT_MODEL_MAP = { gemini: "gemini-3.5-flash", opus: "claude-opus-4-8", sonnet: "claude-sonnet-5" };
|
|
872
979
|
const payload = await api.request("/api/tracker/ai/script", {
|
|
873
980
|
body: {
|
|
874
981
|
apiModel: SCRIPT_MODEL_MAP[model] || SCRIPT_MODEL_MAP.gemini,
|
|
875
|
-
|
|
982
|
+
// 지문이 있으면 styleProfile 껍데기에 실어 보낸다 — 서버 script-prompts 가
|
|
983
|
+
// channelStyleProfile.fingerprint 에서 실측 규격 지시문을 뽑는다.
|
|
984
|
+
channelStyleProfile: channelFingerprint ? { fingerprint: channelFingerprint } : null,
|
|
876
985
|
channelStyleReferenceText: "",
|
|
877
986
|
christianContentType,
|
|
878
987
|
contentFormat,
|
|
@@ -1015,9 +1124,16 @@ export function registerCloudTools(server, config, api) {
|
|
|
1015
1124
|
sceneSeconds: z.number().int().min(3).max(8).optional().describe("씬 길이(초), 기본 5"),
|
|
1016
1125
|
splitMode: z.enum(["fixed", "auto"]).optional().describe("씬 분할, 기본 fixed"),
|
|
1017
1126
|
videoStylePreset: z.string().optional().describe("기본 seedance-music-video"),
|
|
1127
|
+
characters: z.array(z.object({
|
|
1128
|
+
name: z.string().describe("캐릭터 이름"),
|
|
1129
|
+
appearance: z.string().optional().describe("외모 앵커 (얼굴형·헤어·피부톤·의상 등)"),
|
|
1130
|
+
role: z.string().optional(),
|
|
1131
|
+
outfitLock: z.string().optional().describe("의상 고정 (예: navy hoodie)"),
|
|
1132
|
+
forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
|
|
1133
|
+
})).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
|
|
1018
1134
|
aspectRatio: z.string().optional().describe("기본 9:16"),
|
|
1019
1135
|
},
|
|
1020
|
-
wrapCloudHandler(config, async ({ durationSec, lyrics = "", conceptBrief = "", instrumental = false, sceneSeconds = 5, splitMode = "fixed", videoStylePreset = "seedance-music-video", aspectRatio = "9:16" }) => {
|
|
1136
|
+
wrapCloudHandler(config, async ({ durationSec, lyrics = "", conceptBrief = "", instrumental = false, sceneSeconds = 5, splitMode = "fixed", videoStylePreset = "seedance-music-video", aspectRatio = "9:16" , characters = [] }) => {
|
|
1021
1137
|
if (!instrumental && !String(lyrics).trim()) {
|
|
1022
1138
|
return textResult("lyrics를 입력하거나 instrumental=true로 지정하세요.", { isError: true });
|
|
1023
1139
|
}
|
|
@@ -1030,6 +1146,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
1030
1146
|
instrumental,
|
|
1031
1147
|
lyrics,
|
|
1032
1148
|
sceneSeconds,
|
|
1149
|
+
characters,
|
|
1033
1150
|
selectedCharacters: [],
|
|
1034
1151
|
songMeta: {},
|
|
1035
1152
|
splitMode,
|
|
@@ -1105,6 +1222,9 @@ export function registerCloudTools(server, config, api) {
|
|
|
1105
1222
|
id: video?.id,
|
|
1106
1223
|
keywords: video?.keywords,
|
|
1107
1224
|
recommendedPreset: video?.recommendedPreset,
|
|
1225
|
+
// 박힌(하드섭) 자막 관측 — prepare_remake_timeline 의 sourceVideos[].burnedSubtitle 에
|
|
1226
|
+
// 그대로 넘기면 subtitleCropMode:"auto" 가 해당 소스만 하단 크롭한다.
|
|
1227
|
+
burnedSubtitle: video?.burnedSubtitle,
|
|
1108
1228
|
sceneCatalog: (video?.sceneCatalog || []).map((scene) => ({
|
|
1109
1229
|
actionTags: scene?.actionTags,
|
|
1110
1230
|
ambienceScore: scene?.ambienceScore,
|
|
@@ -1128,6 +1248,85 @@ export function registerCloudTools(server, config, api) {
|
|
|
1128
1248
|
}),
|
|
1129
1249
|
);
|
|
1130
1250
|
|
|
1251
|
+
// ── 짜집기 렌더 준비 — 컷 세트 → TTS + 타임라인 페이로드 ────────────────────
|
|
1252
|
+
server.tool(
|
|
1253
|
+
"prepare_remake_timeline",
|
|
1254
|
+
"짜집기(리메이크) 렌더 준비 — analyze_edit_points 의 sceneCatalog 에서 고른 컷들로 구성한 컷 세트를 서버에 보내 TTS 합성 + 렌더 타임라인을 만듭니다. 반환된 payloadPath 를 render_start 에 넘기면 이 PC 에서 원본 다운로드 + 렌더가 수행됩니다. [N]/[SN] 행의 audioContent 는 유료 TTS 로 합성되므로 실행 전 사용자 확인을 받으세요.",
|
|
1255
|
+
{
|
|
1256
|
+
title: z.string().optional().describe("영상 상단 타이틀 텍스트 (없으면 생략)"),
|
|
1257
|
+
sourceVideos: z.array(z.object({
|
|
1258
|
+
id: z.string().describe("YouTube video id (11자)"),
|
|
1259
|
+
title: z.string().optional(),
|
|
1260
|
+
burnedSubtitle: z.object({
|
|
1261
|
+
present: z.boolean(),
|
|
1262
|
+
position: z.string().optional().describe("bottom/top/center/none"),
|
|
1263
|
+
bandPercent: z.number().optional().describe("자막 세로 점유율(%) — 보통 12~22"),
|
|
1264
|
+
}).optional().describe("analyze_edit_points 가 반환한 관측값을 그대로 — subtitleCropMode:auto 판단 근거"),
|
|
1265
|
+
})).min(1).max(5).describe("컷을 가져올 원본 영상 목록"),
|
|
1266
|
+
rows: z.array(z.object({
|
|
1267
|
+
mode: z.enum(["N", "S", "A", "SN"]).describe("N=TTS 내레이션(원본 무음) / S=원본 대사 유지 / A=현장음 유지 / SN=원본 대사+TTS"),
|
|
1268
|
+
durationSec: z.number().describe("행 길이(초) — S/A 는 클립 자연 길이가 우선"),
|
|
1269
|
+
audioContent: z.string().optional().describe("N/SN 의 내레이션 텍스트 (하단 자막으로도 burn-in)"),
|
|
1270
|
+
effectSubtitle: z.string().optional().describe("효과 자막 (짧은 박스 자막, 예: 팩트 폭행)"),
|
|
1271
|
+
clipRefs: z.array(z.object({
|
|
1272
|
+
videoId: z.string().describe("sourceVideos 의 id"),
|
|
1273
|
+
startMs: z.number().describe("원본에서 자를 시작(ms) — sceneCatalog 의 startMs"),
|
|
1274
|
+
endMs: z.number().describe("원본에서 자를 끝(ms)"),
|
|
1275
|
+
})).min(1).max(6).describe("이 행에 이어 붙일 원본 구간들"),
|
|
1276
|
+
})).min(1).max(40).describe("타임라인 행 — 순서대로 이어 붙습니다"),
|
|
1277
|
+
provider: z.enum(["typecast", "elevenlabs", "supertone"]).optional().describe("TTS 공급자, 기본 typecast (recommend_voice 로 보이스를 먼저 고르세요)"),
|
|
1278
|
+
voiceId: z.string().optional().describe("보이스 ID (생략 시 공급자 기본)"),
|
|
1279
|
+
aspectRatio: z.enum(["9:16", "16:9", "1:1"]).optional().describe("기본 9:16"),
|
|
1280
|
+
subtitleCropMode: z.enum(["off", "always", "auto"]).optional().describe("원본 박힌 자막 하단 크롭 — auto 는 burnedSubtitle.present 소스만"),
|
|
1281
|
+
},
|
|
1282
|
+
wrapCloudHandler(config, async ({ title = "", sourceVideos, rows, provider = "typecast", voiceId = "", aspectRatio = "9:16", subtitleCropMode = "off" }) => {
|
|
1283
|
+
const sourceIds = new Set(sourceVideos.map((source) => source.id));
|
|
1284
|
+
const badRef = rows.flatMap((row) => row.clipRefs).find((ref) => !sourceIds.has(ref.videoId));
|
|
1285
|
+
if (badRef) {
|
|
1286
|
+
return textResult(`clipRefs 의 videoId "${badRef.videoId}" 가 sourceVideos 에 없습니다.`, { isError: true });
|
|
1287
|
+
}
|
|
1288
|
+
const version = {
|
|
1289
|
+
id: "mcp-remake",
|
|
1290
|
+
title,
|
|
1291
|
+
sourceVideos,
|
|
1292
|
+
rows: rows.map((row) => ({
|
|
1293
|
+
...row,
|
|
1294
|
+
clipRefs: row.clipRefs.map((ref) => ({
|
|
1295
|
+
...ref,
|
|
1296
|
+
durationSec: Math.max(0.3, (ref.endMs - ref.startMs) / 1000),
|
|
1297
|
+
})),
|
|
1298
|
+
})),
|
|
1299
|
+
};
|
|
1300
|
+
const payload = await api.request("/api/tracker/remake/prepare", {
|
|
1301
|
+
body: {
|
|
1302
|
+
aspectRatio,
|
|
1303
|
+
costEventId: createUsageEventId("mcp-remake-tts"),
|
|
1304
|
+
provider,
|
|
1305
|
+
subtitleCropMode,
|
|
1306
|
+
version,
|
|
1307
|
+
voiceId,
|
|
1308
|
+
},
|
|
1309
|
+
method: "POST",
|
|
1310
|
+
timeoutMs: 300_000,
|
|
1311
|
+
});
|
|
1312
|
+
if (!payload?.payload) {
|
|
1313
|
+
return textResult(`짜집기 준비 실패: ${payload?.error || "타임라인이 비어 있습니다."}`, { isError: true });
|
|
1314
|
+
}
|
|
1315
|
+
const handle = savePayloadHandle(config.stateDir, payload.payload, "remake");
|
|
1316
|
+
return jsonResult({
|
|
1317
|
+
costUsd: payload.costUsd ?? 0,
|
|
1318
|
+
next: "render_start 에 payloadPath 를 넘겨 이 PC 에서 렌더하세요 (원본 다운로드 포함 — 수 분 걸릴 수 있음).",
|
|
1319
|
+
payloadPath: handle.filePath,
|
|
1320
|
+
sizeBytes: handle.bytes,
|
|
1321
|
+
totalDurationMs: payload.totalDurationMs ?? 0,
|
|
1322
|
+
ttsAttempted: payload.ttsAttempted ?? 0,
|
|
1323
|
+
ttsCharCount: payload.ttsCharCount ?? 0,
|
|
1324
|
+
...(payload.ttsLastError ? { ttsLastError: payload.ttsLastError } : {}),
|
|
1325
|
+
ttsSucceeded: payload.ttsSucceeded ?? 0,
|
|
1326
|
+
});
|
|
1327
|
+
}),
|
|
1328
|
+
);
|
|
1329
|
+
|
|
1131
1330
|
// ── 시간 동기 자막 TTS — 쇼츠 하단자막용 ─────────────────────────────────────
|
|
1132
1331
|
server.tool(
|
|
1133
1332
|
"tts_narration_with_captions",
|
|
@@ -1256,6 +1455,97 @@ export function registerCloudTools(server, config, api) {
|
|
|
1256
1455
|
}),
|
|
1257
1456
|
);
|
|
1258
1457
|
|
|
1458
|
+
server.tool(
|
|
1459
|
+
"measure_channel_spec",
|
|
1460
|
+
"벤치마크 채널의 영상 표본(유튜브 URL ≤5)을 실측해 채널 규격 지문(길이·컷간격·컷/분·라우드니스·무음비율·자막 위치·말끝 스타일)을 만듭니다. 컴패니언 앱이 다운로드·샷감지·오디오 추출을 수행하고 서버가 집계합니다. 반환된 fingerprint 를 plan_shorts_video 의 channelFingerprint 로 넘기면 대본이 그 규격을 강제합니다.",
|
|
1461
|
+
{
|
|
1462
|
+
videoUrls: z.array(z.string()).min(1).max(5).describe("표본 유튜브 URL 또는 11자 ID (조회수 상위 영상 권장)"),
|
|
1463
|
+
},
|
|
1464
|
+
wrapCloudHandler(config, async ({ videoUrls }) => {
|
|
1465
|
+
const specs = [];
|
|
1466
|
+
const notes = [];
|
|
1467
|
+
// 라우드니스·무음은 여기서 재고 "숫자만" 보낸다. 오디오를 서버로 올리면 표본 3편만
|
|
1468
|
+
// 넘어도 서버 본문 상한(16MB)을 넘겨 요청 전체가 죽고, 그걸 피하려 트림을 줄이면
|
|
1469
|
+
// 표본이 많을수록 측정 구간이 짧아진다. 웹(src/lib/channelFingerprint.js)도 같은 이유로
|
|
1470
|
+
// 브라우저에서 재고 숫자만 보낸다.
|
|
1471
|
+
const audioTrimSeconds = 120;
|
|
1472
|
+
for (const [index, rawUrl] of videoUrls.entries()) {
|
|
1473
|
+
const watchUrl = canonicalYoutubeWatchUrl(rawUrl);
|
|
1474
|
+
if (!watchUrl) {
|
|
1475
|
+
notes.push(`표본 ${index + 1}: 유튜브 URL 아님 — 건너뜀`);
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
try {
|
|
1479
|
+
// 샷 감지 (컷수·길이·프레임) — 컴패니언 로컬 ffmpeg.
|
|
1480
|
+
const shotsResp = await fetch(`${config.companionUrl}/api/shots`, {
|
|
1481
|
+
body: JSON.stringify({ maxShots: COMPANION_MAX_SHOTS, minGapMs: 1400, sceneThreshold: 0.22, url: watchUrl, width: 480 }),
|
|
1482
|
+
headers: { "Content-Type": "application/json" },
|
|
1483
|
+
method: "POST",
|
|
1484
|
+
signal: AbortSignal.timeout(240_000),
|
|
1485
|
+
});
|
|
1486
|
+
const shotsPayload = await shotsResp.json().catch(() => null);
|
|
1487
|
+
if (!shotsResp.ok || !Array.isArray(shotsPayload?.shots) || !shotsPayload.shots.length) {
|
|
1488
|
+
notes.push(`표본 ${index + 1}: 샷 감지 실패(${shotsPayload?.error || shotsResp.status})`);
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
const shots = shotsPayload.shots;
|
|
1492
|
+
const durationSec = Math.round((shots.at(-1)?.endMs || 0) / 100) / 10;
|
|
1493
|
+
const frames = shots
|
|
1494
|
+
.filter((shot) => shot?.imageBase64)
|
|
1495
|
+
.slice(0, 8)
|
|
1496
|
+
.map((shot) => `data:${shot?.mimeType || "image/jpeg"};base64,${shot.imageBase64}`);
|
|
1497
|
+
// 컴패니언은 감지된 컷을 maxShots 개로 다운샘플해 돌려준다(downsampleShotStarts).
|
|
1498
|
+
// 상한에 걸린 목록의 길이는 컷 수의 하한일 뿐이라 "실측"으로 쓸 수 없다 —
|
|
1499
|
+
// 빠른 컷 채널일수록 컷/분이 심하게 낮게 잡혀 대본 지시문을 오염시킨다.
|
|
1500
|
+
// 지어낸 숫자를 보내느니 축을 비우고 사유를 남긴다.
|
|
1501
|
+
const cutCountClamped = shots.length >= COMPANION_MAX_SHOTS;
|
|
1502
|
+
if (cutCountClamped) {
|
|
1503
|
+
notes.push(`표본 ${index + 1}: 감지 컷이 컴패니언 상한(${COMPANION_MAX_SHOTS})에 도달 — 컷 수는 하한값이라 컷 리듬 축에서 제외`);
|
|
1504
|
+
}
|
|
1505
|
+
// 오디오 — 여기서 실측해 숫자만 싣는다(서버 DSP 와 동일 알고리즘, wav-dsp.mjs).
|
|
1506
|
+
let loudnessDbfs = null;
|
|
1507
|
+
let silenceRatio = null;
|
|
1508
|
+
try {
|
|
1509
|
+
const wav = await extractCompanionAudio(config.companionUrl, { url: watchUrl });
|
|
1510
|
+
const trimmed = trimWavToSeconds(wav, audioTrimSeconds);
|
|
1511
|
+
loudnessDbfs = loudnessDbfsFromWav(trimmed);
|
|
1512
|
+
silenceRatio = silenceRatioFromWav(trimmed);
|
|
1513
|
+
if (loudnessDbfs === null) notes.push(`표본 ${index + 1}: WAV 파싱 실패 — 라우드니스 축 제외`);
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
notes.push(`표본 ${index + 1}: 오디오 추출 실패(${String(error?.message || error).slice(0, 80)}) — 라우드니스 축 제외`);
|
|
1516
|
+
}
|
|
1517
|
+
specs.push({
|
|
1518
|
+
...(cutCountClamped ? {} : { cutCount: shots.length }),
|
|
1519
|
+
durationSec,
|
|
1520
|
+
frames,
|
|
1521
|
+
...(loudnessDbfs !== null ? { loudnessDbfs } : {}),
|
|
1522
|
+
...(silenceRatio !== null ? { silenceRatio } : {}),
|
|
1523
|
+
videoId: watchUrl.slice(-11),
|
|
1524
|
+
});
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
notes.push(`표본 ${index + 1}: ${String(error?.message || error).slice(0, 100)}`);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
if (!specs.length) {
|
|
1530
|
+
return textResult(`표본 실측에 모두 실패했습니다. 컴패니언 앱 실행 여부를 확인하세요.\n${notes.join("\n")}`, { isError: true });
|
|
1531
|
+
}
|
|
1532
|
+
const payload = await api.request("/api/tracker/gemini/channel-fingerprint", {
|
|
1533
|
+
body: { measuredAt: new Date().toISOString(), videos: specs },
|
|
1534
|
+
method: "POST",
|
|
1535
|
+
timeoutMs: 300_000,
|
|
1536
|
+
});
|
|
1537
|
+
if (payload?.ok === false || !payload?.fingerprint) {
|
|
1538
|
+
return textResult(`지문 집계 실패: ${payload?.error || "알 수 없는 오류"}`, { isError: true });
|
|
1539
|
+
}
|
|
1540
|
+
return jsonResult({
|
|
1541
|
+
fingerprint: payload.fingerprint,
|
|
1542
|
+
next: "이 fingerprint 객체를 plan_shorts_video 의 channelFingerprint 로 그대로 넘기세요 — 대본이 실측 규격(길이·컷·말속도·말끝)을 따르게 됩니다.",
|
|
1543
|
+
notes: notes.length ? notes : undefined,
|
|
1544
|
+
sampleCount: specs.length,
|
|
1545
|
+
});
|
|
1546
|
+
}),
|
|
1547
|
+
);
|
|
1548
|
+
|
|
1259
1549
|
server.tool(
|
|
1260
1550
|
"verify_scene_image",
|
|
1261
1551
|
"생성된 키프레임 이미지를 서버 비전(Gemini)이 QC 판정합니다 — 캐릭터 참조 대조·아티팩트·프롬프트 이행. 이미지 블록을 직접 볼 수 없는 클라이언트의 폴백이며, 판정 JSON(pass/violations/regenerationHint)만 읽으면 됩니다. 불합격이면 regenerationHint 를 프롬프트에 반영해 generate_scene_image 를 재호출하세요.",
|
|
@@ -1332,6 +1622,35 @@ export function registerCloudTools(server, config, api) {
|
|
|
1332
1622
|
}),
|
|
1333
1623
|
);
|
|
1334
1624
|
|
|
1625
|
+
server.tool(
|
|
1626
|
+
"verify_render_darkness",
|
|
1627
|
+
"렌더된 mp4 의 검은 프레임(검은컷)을 휘도 실측으로 잡습니다 — 컷 경계 시각들을 넘기면 그 지점만, 생략하면 1초 간격 전수 샘플. darkCount>0 이면 해당 시각의 씬 전환/소스를 점검하세요. AI 호출 없음(무과금).",
|
|
1628
|
+
{
|
|
1629
|
+
videoUrl: z.string().describe("검사할 mp4 URL (렌더 결과 업로드본 또는 씬 videoUrl)"),
|
|
1630
|
+
timestamps: z.array(z.number().min(0)).max(120).optional().describe("검사 시각(초) — 컷 경계 ±0.15s 권장. 생략 시 1초 간격 자동"),
|
|
1631
|
+
darkThreshold: z.number().int().min(1).max(80).optional().describe("검정 판정 휘도 상한(0~255), 기본 16"),
|
|
1632
|
+
},
|
|
1633
|
+
wrapCloudHandler(config, async ({ videoUrl, timestamps = [], darkThreshold = 16 }) => {
|
|
1634
|
+
const payload = await api.request("/api/tracker/video/darkness", {
|
|
1635
|
+
body: { darkThreshold, timestamps, videoUrl },
|
|
1636
|
+
method: "POST",
|
|
1637
|
+
timeoutMs: 300_000,
|
|
1638
|
+
});
|
|
1639
|
+
if (payload?.ok === false) {
|
|
1640
|
+
return textResult(`휘도 측정 실패: ${payload?.error || "알 수 없는 오류"}`, { isError: true });
|
|
1641
|
+
}
|
|
1642
|
+
return jsonResult({
|
|
1643
|
+
darkCount: payload?.darkCount,
|
|
1644
|
+
darkSamples: payload?.darkSamples,
|
|
1645
|
+
pass: (payload?.darkCount || 0) === 0,
|
|
1646
|
+
sampleCount: payload?.sampleCount,
|
|
1647
|
+
verdictNote: (payload?.darkCount || 0) > 0
|
|
1648
|
+
? "검은 프레임 검출 — 해당 시각의 씬 소스/전환을 점검하고 재스티치하세요 (transitionMode fade 사용 여부 확인)."
|
|
1649
|
+
: "검은컷 없음.",
|
|
1650
|
+
});
|
|
1651
|
+
}),
|
|
1652
|
+
);
|
|
1653
|
+
|
|
1335
1654
|
// ── 상세페이지(PDP) ─────────────────────────────────────────────────────────
|
|
1336
1655
|
server.tool(
|
|
1337
1656
|
"generate_pdp",
|
package/lib/companion-tools.mjs
CHANGED
|
@@ -42,6 +42,36 @@ export function describeCompanionState(triage, remoteEnv) {
|
|
|
42
42
|
return `컴패니언은 실행 중이지만 렌더 런타임이 없습니다 (${triage.detail}). 컴패니언 앱에서 렌더 런타임 설치를 실행하세요.`;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// 렌더 영수증 대조 — 브리지가 실측한 summary.measured 를 요청 규격과 비교해 불일치를 나열한다.
|
|
46
|
+
// 구버전 컴패니언(브리지에 measured 없음)은 빈 배열 + receipt 없음으로 안전 강등.
|
|
47
|
+
function computeRenderMismatches(summary) {
|
|
48
|
+
const requested = summary?.requested;
|
|
49
|
+
const measured = summary?.measured;
|
|
50
|
+
if (!requested || !measured) return [];
|
|
51
|
+
const mismatches = [];
|
|
52
|
+
const reqDuration = Number(requested.durationSec);
|
|
53
|
+
const gotDuration = Number(measured.durationSec);
|
|
54
|
+
if (Number.isFinite(reqDuration) && Number.isFinite(gotDuration)
|
|
55
|
+
&& Math.abs(reqDuration - gotDuration) > Math.max(0.5, reqDuration * 0.03)) {
|
|
56
|
+
mismatches.push(`길이 불일치: 요청 ${reqDuration}s vs 실측 ${gotDuration}s`);
|
|
57
|
+
}
|
|
58
|
+
for (const axis of ["width", "height"]) {
|
|
59
|
+
if (Number.isFinite(Number(measured[axis])) && Number(requested[axis]) !== Number(measured[axis])) {
|
|
60
|
+
mismatches.push(`${axis} 불일치: 요청 ${requested[axis]} vs 실측 ${measured[axis]}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (requested.hasAudio === true && measured.hasAudio === false) {
|
|
64
|
+
mismatches.push("오디오 누락: 오디오 트랙을 요청했지만 산출물에 없습니다");
|
|
65
|
+
}
|
|
66
|
+
if (summary?.decodeClean === false) {
|
|
67
|
+
mismatches.push(`디코드 오류 감지${measured.decodeErrorSample ? `: ${measured.decodeErrorSample}` : ""}`);
|
|
68
|
+
}
|
|
69
|
+
if (Number(summary?.fileBytes) === 0) {
|
|
70
|
+
mismatches.push("산출물 파일이 0바이트입니다");
|
|
71
|
+
}
|
|
72
|
+
return mismatches;
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
export function registerCompanionTools(server, config) {
|
|
46
76
|
const remoteEnv = detectRemoteEnvironment();
|
|
47
77
|
|
|
@@ -209,11 +239,21 @@ export function registerCompanionTools(server, config) {
|
|
|
209
239
|
} catch {
|
|
210
240
|
// 영속화 실패는 무시
|
|
211
241
|
}
|
|
242
|
+
const receipt = job.status === "succeeded" && job.summary
|
|
243
|
+
? { mismatches: computeRenderMismatches(job.summary), summary: job.summary }
|
|
244
|
+
: null;
|
|
212
245
|
return textResult(JSON.stringify({
|
|
213
246
|
etaSec: job.progress?.etaSec ?? null,
|
|
214
247
|
percent: job.progress?.percent ?? 0,
|
|
215
248
|
stage: job.stage || "",
|
|
216
249
|
status: job.status || "",
|
|
250
|
+
...(receipt ? {
|
|
251
|
+
...(receipt.mismatches.length ? {
|
|
252
|
+
mismatches: receipt.mismatches,
|
|
253
|
+
warning: "렌더 영수증 불일치 — 저장 전 원인을 확인하고 필요하면 다시 렌더하세요.",
|
|
254
|
+
} : {}),
|
|
255
|
+
summary: receipt.summary,
|
|
256
|
+
} : {}),
|
|
217
257
|
}, null, 2));
|
|
218
258
|
},
|
|
219
259
|
);
|
|
@@ -226,6 +266,13 @@ export function registerCompanionTools(server, config) {
|
|
|
226
266
|
fileName: z.string().optional().describe("저장할 파일명 (생략 시 서버 제안 이름)"),
|
|
227
267
|
},
|
|
228
268
|
async ({ jobId, fileName }) => {
|
|
269
|
+
// 출력은 저장 성공 시 컴패니언에서 삭제되므로 영수증은 다운로드 전에 읽는다.
|
|
270
|
+
let receiptSummary = null;
|
|
271
|
+
try {
|
|
272
|
+
receiptSummary = (await getRenderJob(config.companionUrl, jobId))?.payload?.summary || null;
|
|
273
|
+
} catch {
|
|
274
|
+
// 영수증 조회 실패가 다운로드를 막지 않는다 (구버전 컴패니언 호환).
|
|
275
|
+
}
|
|
229
276
|
const saved = await downloadRenderOutput(config.companionUrl, jobId, config.outputDir, {
|
|
230
277
|
suggestedName: fileName || "",
|
|
231
278
|
});
|
|
@@ -242,10 +289,16 @@ export function registerCompanionTools(server, config) {
|
|
|
242
289
|
} catch {
|
|
243
290
|
// 영속화 실패는 무시
|
|
244
291
|
}
|
|
292
|
+
const mismatches = receiptSummary ? computeRenderMismatches(receiptSummary) : [];
|
|
245
293
|
return textResult(JSON.stringify({
|
|
246
294
|
bytes: saved.bytes,
|
|
247
295
|
filePath: saved.filePath,
|
|
296
|
+
...(mismatches.length ? {
|
|
297
|
+
mismatches,
|
|
298
|
+
warning: "렌더 영수증 불일치 — 파일을 확인하고 필요하면 다시 렌더하세요.",
|
|
299
|
+
} : {}),
|
|
248
300
|
note: "파일은 이 PC에 저장되었습니다.",
|
|
301
|
+
...(receiptSummary ? { summary: receiptSummary } : {}),
|
|
249
302
|
}, null, 2));
|
|
250
303
|
},
|
|
251
304
|
);
|
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.9.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/wav-dsp.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// WAV 라우드니스·무음 실측 — server/channel-fingerprint.mjs 의 동일 알고리즘을 MCP 로 이식한 것.
|
|
2
|
+
// 이식하는 이유: 지문 측정에서 오디오를 서버로 올리면 표본 3편만 넘어도 서버 본문 상한(16MB)을
|
|
3
|
+
// 넘겨 요청 전체가 죽는다. 웹(src/lib/channelFingerprint.js)도 같은 이유로 브라우저에서 재고
|
|
4
|
+
// 숫자만 보낸다. MCP 는 별도 npm 패키지라 server/ 를 import 할 수 없어 코드를 복제하되,
|
|
5
|
+
// wav-dsp.test.mjs 가 서버 구현과의 수치 일치를 계약으로 고정한다.
|
|
6
|
+
//
|
|
7
|
+
// 세 구현(서버·웹·MCP)은 반드시 같은 숫자를 내야 한다. 한쪽을 고치면 나머지도 같이 고칠 것.
|
|
8
|
+
|
|
9
|
+
export function parseWavPcm(buffer) {
|
|
10
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 44) return null;
|
|
11
|
+
if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") return null;
|
|
12
|
+
let offset = 12;
|
|
13
|
+
let fmt = null;
|
|
14
|
+
let dataOffset = -1;
|
|
15
|
+
let dataLen = 0;
|
|
16
|
+
while (offset + 8 <= buffer.length) {
|
|
17
|
+
const id = buffer.toString("ascii", offset, offset + 4);
|
|
18
|
+
const size = buffer.readUInt32LE(offset + 4);
|
|
19
|
+
const body = offset + 8;
|
|
20
|
+
// fmt 청크 헤더는 있으나 본문(16바이트)이 잘린 버퍼는 건너뛴다 — 읽기 범위 초과 대신 null.
|
|
21
|
+
if (id === "fmt " && body + 16 <= buffer.length) {
|
|
22
|
+
fmt = {
|
|
23
|
+
audioFormat: buffer.readUInt16LE(body),
|
|
24
|
+
channels: buffer.readUInt16LE(body + 2),
|
|
25
|
+
sampleRate: buffer.readUInt32LE(body + 4),
|
|
26
|
+
bitsPerSample: buffer.readUInt16LE(body + 14),
|
|
27
|
+
};
|
|
28
|
+
} else if (id === "data") {
|
|
29
|
+
dataOffset = body;
|
|
30
|
+
dataLen = Math.min(size, buffer.length - body);
|
|
31
|
+
}
|
|
32
|
+
offset = body + size + (size % 2); // chunks are word-aligned
|
|
33
|
+
}
|
|
34
|
+
if (!fmt || dataOffset < 0 || fmt.bitsPerSample !== 16 || fmt.audioFormat !== 1) return null;
|
|
35
|
+
return { ...fmt, dataOffset, dataLen };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 16-bit PCM 표본을 순회하며 콜백. 첫 채널만 사용(모노 가정, 스테레오면 좌채널).
|
|
39
|
+
function forEachSample(buffer, wav, fn) {
|
|
40
|
+
const step = 2 * Math.max(1, wav.channels);
|
|
41
|
+
for (let i = wav.dataOffset; i + 1 < wav.dataOffset + wav.dataLen; i += step) {
|
|
42
|
+
fn(buffer.readInt16LE(i) / 32768);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// RMS → dBFS. 무음(전부 0)이면 -Infinity 대신 -100 으로 바닥 처리.
|
|
47
|
+
export function loudnessDbfsFromWav(buffer) {
|
|
48
|
+
const wav = parseWavPcm(buffer);
|
|
49
|
+
if (!wav) return null;
|
|
50
|
+
let sumSq = 0;
|
|
51
|
+
let n = 0;
|
|
52
|
+
forEachSample(buffer, wav, (s) => { sumSq += s * s; n += 1; });
|
|
53
|
+
if (!n) return null;
|
|
54
|
+
const rms = Math.sqrt(sumSq / n);
|
|
55
|
+
if (rms <= 0) return -100;
|
|
56
|
+
return Math.max(-100, Math.round(20 * Math.log10(rms) * 10) / 10);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 무음 비율 — 짧은 창(20ms)의 RMS 가 임계(dBFS) 아래인 시간 비율. 0~1.
|
|
60
|
+
export function silenceRatioFromWav(buffer, { thresholdDb = -45, windowMs = 20 } = {}) {
|
|
61
|
+
const wav = parseWavPcm(buffer);
|
|
62
|
+
if (!wav) return null;
|
|
63
|
+
const win = Math.max(1, Math.floor((wav.sampleRate * windowMs) / 1000));
|
|
64
|
+
const thresholdRms = Math.pow(10, thresholdDb / 20);
|
|
65
|
+
let windows = 0;
|
|
66
|
+
let silent = 0;
|
|
67
|
+
let sumSq = 0;
|
|
68
|
+
let count = 0;
|
|
69
|
+
forEachSample(buffer, wav, (s) => {
|
|
70
|
+
sumSq += s * s;
|
|
71
|
+
count += 1;
|
|
72
|
+
if (count >= win) {
|
|
73
|
+
const rms = Math.sqrt(sumSq / count);
|
|
74
|
+
windows += 1;
|
|
75
|
+
if (rms < thresholdRms) silent += 1;
|
|
76
|
+
sumSq = 0;
|
|
77
|
+
count = 0;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
if (!windows) return null;
|
|
81
|
+
return Math.round((silent / windows) * 1000) / 1000;
|
|
82
|
+
}
|