aimakeall-mcp 0.8.0 → 0.9.1
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 +1 -1
- package/lib/cloud-tools.mjs +59 -34
- package/lib/config.mjs +1 -1
- package/lib/tool-contract.test.mjs +80 -0
- package/lib/wav-dsp.mjs +82 -0
- package/package.json +1 -1
package/README.md
CHANGED
package/lib/cloud-tools.mjs
CHANGED
|
@@ -23,16 +23,15 @@ 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
|
|
|
32
|
-
// 컴패니언 계약 상수 — companion/index.cjs 의 MAX_SHOT_COUNT
|
|
33
|
-
// 오디오는 extractCompanionAudio 기본값(16kHz)·16bit·모노 = 초당 32,000 바이트.
|
|
33
|
+
// 컴패니언 계약 상수 — companion/index.cjs 의 MAX_SHOT_COUNT 와 맞춘다.
|
|
34
34
|
const COMPANION_MAX_SHOTS = 18;
|
|
35
|
-
const COMPANION_AUDIO_BYTES_PER_SECOND = 16_000 * 2;
|
|
36
35
|
|
|
37
36
|
function assertEncodedBudget(filePaths, { context = "요청" } = {}) {
|
|
38
37
|
let total = 0;
|
|
@@ -253,9 +252,9 @@ export function registerCloudTools(server, config, api) {
|
|
|
253
252
|
categoryId: z.enum(["community-shorts", "viral-shorts", "insight"]).optional().describe("기본 community-shorts. insight=명언(통찰) 쇼츠"),
|
|
254
253
|
targetCustomer: z.string().optional(),
|
|
255
254
|
tone: z.string().optional().describe("기본 '자동 추천'"),
|
|
256
|
-
sceneCount: z.number().int().min(1).max(10).optional().describe("
|
|
255
|
+
sceneCount: z.number().int().min(1).max(10).optional().describe("씬 수. 생략하면 channelFingerprint 의 목표 길이로 자동 산출(지문도 없으면 3)"),
|
|
257
256
|
channelFingerprint: z.object({
|
|
258
|
-
sampleSize: z.number().
|
|
257
|
+
sampleSize: z.number().describe("표본 수 — 서버는 이 값이 0보다 커야 지문을 규격으로 인정한다(없으면 조용히 무시됨)"),
|
|
259
258
|
durationTargetSec: z.number().nullable().optional(),
|
|
260
259
|
cutIntervalSec: z.number().nullable().optional(),
|
|
261
260
|
cutsPerMin: z.number().nullable().optional(),
|
|
@@ -276,7 +275,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
276
275
|
forbidden: z.string().optional().describe("금지 변형 (기본: different face, different hairstyle)"),
|
|
277
276
|
})).max(4).optional().describe("등장 캐릭터 — 서버가 씬마다 identity_lock 을 imagePrompt 에 강제 주입해 캐릭터 일관성을 잠급니다"),
|
|
278
277
|
},
|
|
279
|
-
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 }) => {
|
|
280
279
|
if (!String(topic).trim() && !String(copy).trim()) {
|
|
281
280
|
return textResult("topic 또는 copy 중 하나는 입력해야 합니다.", { isError: true });
|
|
282
281
|
}
|
|
@@ -780,9 +779,14 @@ export function registerCloudTools(server, config, api) {
|
|
|
780
779
|
emphasisKeywords: z.array(z.string()).max(12).optional().describe("강조 단어 목록 — 자막 라인 안에서 해당 단어만 다른 색/크기로 렌더 (plan_shorts_video의 emphasisKeywords 를 그대로 전달)"),
|
|
781
780
|
emphasisColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식").optional().describe("강조 색 #RRGGBB (기본 #facc15 노랑)"),
|
|
782
781
|
emphasisSizeScale: z.number().optional().describe("강조 크기 배율 0.5~2 (기본 1.2)"),
|
|
783
|
-
|
|
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 유무를 참고 채널에 맞춥니다"),
|
|
787
|
+
transitionMode: z.enum(["fade", "none"]).optional().describe("기본 none(하드컷). fade 는 씬이 맞대기로 붙는 배치에서 컷마다 검은 딥을 만든다 — 명시 요청 시에만 쓸 것"),
|
|
784
788
|
},
|
|
785
|
-
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 = "
|
|
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 = "none", channelFingerprint = null }) => {
|
|
786
790
|
// 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
|
|
787
791
|
if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
|
|
788
792
|
if (bgmAudioPath) assertAllowedInputFile(bgmAudioPath, new Set([".mp3", ".wav"]), { kind: "BGM 오디오" });
|
|
@@ -807,6 +811,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
807
811
|
})),
|
|
808
812
|
emphasisColor,
|
|
809
813
|
emphasisKeywords,
|
|
814
|
+
channelFingerprint,
|
|
810
815
|
emphasisSizeScale,
|
|
811
816
|
subtitleLines,
|
|
812
817
|
titleText,
|
|
@@ -937,13 +942,24 @@ export function registerCloudTools(server, config, api) {
|
|
|
937
942
|
storyText: z.string().optional().describe("핵심 줄거리·내용 브리프"),
|
|
938
943
|
styleId: z.enum(["standard", "senior", "insight", "webnovel", "christian", "community-shorts", "viral-shorts"]).optional().describe("기본 standard. senior=시니어(쉽고 친절), insight=명언(통찰)"),
|
|
939
944
|
model: z.enum(["gemini", "opus", "sonnet"]).optional().describe("기본 gemini(가성비). opus/sonnet=Claude"),
|
|
940
|
-
targetLength: z.number().int().min(500).max(
|
|
945
|
+
targetLength: z.number().int().min(500).max(12000).optional().describe("목표 글자수, 기본 5000. 서버 출력 상한(8192토큰 ≈ 한국어 12,300자)을 넘으면 대본이 중간에 잘리므로 12000 이 상한이다"),
|
|
941
946
|
targetLanguageLabel: z.string().optional().describe("출력 언어 라벨, 기본 '한국 (한국어)'"),
|
|
942
947
|
contentFormat: z.enum(["longform", "shortform"]).optional().describe("기본 longform (standard/senior/insight는 항상 longform)"),
|
|
943
948
|
webnovelSubtypeId: z.enum(["modern-webnovel", "regression-fantasy", "romance-fantasy", "martial-arts", "light-novel", "hunter-fantasy", "revenge-soda", "healing-daily", "relationship-drama", "literary-serial"]).optional().describe("styleId=webnovel일 때 장르"),
|
|
944
949
|
christianContentType: z.enum(["youtube-narration", "prayer", "devotional", "comfort-message"]).optional().describe("styleId=christian일 때 유형"),
|
|
945
950
|
insightFigureNames: z.string().optional().describe("styleId=insight일 때 인물명(쉼표 구분)"),
|
|
946
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().describe("표본 수 — 서버는 이 값이 0보다 커야 지문을 규격으로 인정한다(없으면 조용히 무시됨)"),
|
|
962
|
+
}).passthrough().optional().describe("measure_channel_spec 의 fingerprint — 실측 규격(길이·컷·말끝)을 대본에 강제합니다"),
|
|
947
963
|
},
|
|
948
964
|
wrapCloudHandler(config, async ({
|
|
949
965
|
title,
|
|
@@ -957,12 +973,15 @@ export function registerCloudTools(server, config, api) {
|
|
|
957
973
|
christianContentType = "youtube-narration",
|
|
958
974
|
insightFigureNames = "",
|
|
959
975
|
userOpinion = "",
|
|
976
|
+
channelFingerprint = null,
|
|
960
977
|
}) => {
|
|
961
978
|
const SCRIPT_MODEL_MAP = { gemini: "gemini-3.5-flash", opus: "claude-opus-4-8", sonnet: "claude-sonnet-5" };
|
|
962
979
|
const payload = await api.request("/api/tracker/ai/script", {
|
|
963
980
|
body: {
|
|
964
981
|
apiModel: SCRIPT_MODEL_MAP[model] || SCRIPT_MODEL_MAP.gemini,
|
|
965
|
-
|
|
982
|
+
// 지문이 있으면 styleProfile 껍데기에 실어 보낸다 — 서버 script-prompts 가
|
|
983
|
+
// channelStyleProfile.fingerprint 에서 실측 규격 지시문을 뽑는다.
|
|
984
|
+
channelStyleProfile: channelFingerprint ? { fingerprint: channelFingerprint } : null,
|
|
966
985
|
channelStyleReferenceText: "",
|
|
967
986
|
christianContentType,
|
|
968
987
|
contentFormat,
|
|
@@ -983,7 +1002,9 @@ export function registerCloudTools(server, config, api) {
|
|
|
983
1002
|
webnovelSubtypeId,
|
|
984
1003
|
},
|
|
985
1004
|
method: "POST",
|
|
986
|
-
|
|
1005
|
+
// 서버 최악 예산(리서치 40s + 생성 150s×2 = 340s)보다 크게 — 역전되면
|
|
1006
|
+
// 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다.
|
|
1007
|
+
timeoutMs: 420_000,
|
|
987
1008
|
});
|
|
988
1009
|
const text = String(payload?.text || "").trim();
|
|
989
1010
|
if (!text) return textResult("대본 생성 결과가 비어 있습니다. 제목/브리프를 보강해 다시 시도하세요.", { isError: true });
|
|
@@ -1004,7 +1025,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
1004
1025
|
purpose: z.enum(["investment", "owner-occupancy", "move-up", "verification"]).optional().describe("기본 investment"),
|
|
1005
1026
|
budget: z.string().optional().describe("예산/가격대"),
|
|
1006
1027
|
notes: z.string().optional(),
|
|
1007
|
-
targetLength: z.number().int().min(500).max(
|
|
1028
|
+
targetLength: z.number().int().min(500).max(12000).optional().describe("목표 글자수, 기본 5000. 서버 출력 상한(8192토큰 ≈ 한국어 12,300자)을 넘으면 대본이 중간에 잘리므로 12000 이 상한이다"),
|
|
1008
1029
|
targetLanguageLabel: z.string().optional().describe("기본 '한국 (한국어)'"),
|
|
1009
1030
|
},
|
|
1010
1031
|
wrapCloudHandler(config, async ({ analysisType = "apartment-report", primaryTarget = "", secondaryTarget = "", region = "", title = "", storyText = "", purpose = "investment", budget = "", notes = "", targetLength = 5000, targetLanguageLabel = "한국 (한국어)" }) => {
|
|
@@ -1034,7 +1055,9 @@ export function registerCloudTools(server, config, api) {
|
|
|
1034
1055
|
title,
|
|
1035
1056
|
},
|
|
1036
1057
|
method: "POST",
|
|
1037
|
-
|
|
1058
|
+
// 서버 최악 예산(리서치 40s + 생성 150s×2 = 340s)보다 크게 — 역전되면
|
|
1059
|
+
// 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다.
|
|
1060
|
+
timeoutMs: 420_000,
|
|
1038
1061
|
});
|
|
1039
1062
|
const text = String(payload?.text || "").trim();
|
|
1040
1063
|
if (!text) return textResult("부동산 분석 대본 결과가 비어 있습니다.", { isError: true });
|
|
@@ -1055,7 +1078,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
1055
1078
|
purpose: z.enum(["startup", "investment", "site-selection", "verification"]).optional().describe("기본 startup"),
|
|
1056
1079
|
budget: z.string().optional().describe("예산/운영 여력"),
|
|
1057
1080
|
notes: z.string().optional(),
|
|
1058
|
-
targetLength: z.number().int().min(500).max(
|
|
1081
|
+
targetLength: z.number().int().min(500).max(12000).optional().describe("목표 글자수, 기본 5000. 서버 출력 상한(8192토큰 ≈ 한국어 12,300자)을 넘으면 대본이 중간에 잘리므로 12000 이 상한이다"),
|
|
1059
1082
|
targetLanguageLabel: z.string().optional().describe("기본 '한국 (한국어)'"),
|
|
1060
1083
|
},
|
|
1061
1084
|
wrapCloudHandler(config, async ({ analysisType = "commercial-district-report", primaryTarget = "", secondaryTarget = "", businessType = "", title = "", storyText = "", purpose = "startup", budget = "", notes = "", targetLength = 5000, targetLanguageLabel = "한국 (한국어)" }) => {
|
|
@@ -1085,7 +1108,9 @@ export function registerCloudTools(server, config, api) {
|
|
|
1085
1108
|
title,
|
|
1086
1109
|
},
|
|
1087
1110
|
method: "POST",
|
|
1088
|
-
|
|
1111
|
+
// 서버 최악 예산(리서치 40s + 생성 150s×2 = 340s)보다 크게 — 역전되면
|
|
1112
|
+
// 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다.
|
|
1113
|
+
timeoutMs: 420_000,
|
|
1089
1114
|
});
|
|
1090
1115
|
const text = String(payload?.text || "").trim();
|
|
1091
1116
|
if (!text) return textResult("상권 분석 대본 결과가 비어 있습니다.", { isError: true });
|
|
@@ -1438,20 +1463,18 @@ export function registerCloudTools(server, config, api) {
|
|
|
1438
1463
|
|
|
1439
1464
|
server.tool(
|
|
1440
1465
|
"measure_channel_spec",
|
|
1441
|
-
"벤치마크 채널의 영상 표본(유튜브 URL ≤5)을 실측해 채널 규격 지문(길이·컷간격·컷/분·라우드니스·무음비율·자막 위치·말끝 스타일)을 만듭니다. 컴패니언 앱이 다운로드·샷감지·오디오 추출을 수행하고 서버가 집계합니다. 반환된 fingerprint 를 plan_shorts_video 의 channelFingerprint 로 넘기면 대본이 그 규격을 강제합니다.",
|
|
1466
|
+
"벤치마크 채널의 영상 표본(유튜브 URL ≤5)을 실측해 채널 규격 지문(길이·컷간격·컷/분·라우드니스·무음비율·자막 위치·말끝 스타일)을 만듭니다. 말속도는 측정하지 않습니다. 컴패니언 앱이 다운로드·샷감지·오디오 추출을 수행하고 서버가 집계합니다. 반환된 fingerprint 를 plan_shorts_video 의 channelFingerprint 로 넘기면 대본이 그 규격을 강제합니다.",
|
|
1442
1467
|
{
|
|
1443
1468
|
videoUrls: z.array(z.string()).min(1).max(5).describe("표본 유튜브 URL 또는 11자 ID (조회수 상위 영상 권장)"),
|
|
1444
1469
|
},
|
|
1445
1470
|
wrapCloudHandler(config, async ({ videoUrls }) => {
|
|
1446
1471
|
const specs = [];
|
|
1447
1472
|
const notes = [];
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1451
|
-
|
|
1452
|
-
const
|
|
1453
|
-
const audioTrimSeconds = Math.max(20, Math.min(120, Math.floor(audioBudgetChars / videoUrls.length / base64CharsPerAudioSecond)));
|
|
1454
|
-
let audioCharsUsed = 0;
|
|
1473
|
+
// 라우드니스·무음은 여기서 재고 "숫자만" 보낸다. 오디오를 서버로 올리면 표본 3편만
|
|
1474
|
+
// 넘어도 서버 본문 상한(16MB)을 넘겨 요청 전체가 죽고, 그걸 피하려 트림을 줄이면
|
|
1475
|
+
// 표본이 많을수록 측정 구간이 짧아진다. 웹(src/lib/channelFingerprint.js)도 같은 이유로
|
|
1476
|
+
// 브라우저에서 재고 숫자만 보낸다.
|
|
1477
|
+
const audioTrimSeconds = 120;
|
|
1455
1478
|
for (const [index, rawUrl] of videoUrls.entries()) {
|
|
1456
1479
|
const watchUrl = canonicalYoutubeWatchUrl(rawUrl);
|
|
1457
1480
|
if (!watchUrl) {
|
|
@@ -1485,31 +1508,33 @@ export function registerCloudTools(server, config, api) {
|
|
|
1485
1508
|
if (cutCountClamped) {
|
|
1486
1509
|
notes.push(`표본 ${index + 1}: 감지 컷이 컴패니언 상한(${COMPANION_MAX_SHOTS})에 도달 — 컷 수는 하한값이라 컷 리듬 축에서 제외`);
|
|
1487
1510
|
}
|
|
1488
|
-
// 오디오 —
|
|
1489
|
-
let
|
|
1511
|
+
// 오디오 — 여기서 실측해 숫자만 싣는다(서버 DSP 와 동일 알고리즘, wav-dsp.mjs).
|
|
1512
|
+
let loudnessDbfs = null;
|
|
1513
|
+
let silenceRatio = null;
|
|
1490
1514
|
try {
|
|
1491
1515
|
const wav = await extractCompanionAudio(config.companionUrl, { url: watchUrl });
|
|
1492
|
-
const
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
}
|
|
1496
|
-
audioBase64 = encoded;
|
|
1497
|
-
audioCharsUsed += encoded.length;
|
|
1498
|
-
}
|
|
1516
|
+
const trimmed = trimWavToSeconds(wav, audioTrimSeconds);
|
|
1517
|
+
loudnessDbfs = loudnessDbfsFromWav(trimmed);
|
|
1518
|
+
silenceRatio = silenceRatioFromWav(trimmed);
|
|
1519
|
+
if (loudnessDbfs === null) notes.push(`표본 ${index + 1}: WAV 파싱 실패 — 라우드니스 축 제외`);
|
|
1499
1520
|
} catch (error) {
|
|
1500
1521
|
notes.push(`표본 ${index + 1}: 오디오 추출 실패(${String(error?.message || error).slice(0, 80)}) — 라우드니스 축 제외`);
|
|
1501
1522
|
}
|
|
1502
1523
|
specs.push({
|
|
1503
|
-
audioBase64,
|
|
1504
1524
|
...(cutCountClamped ? {} : { cutCount: shots.length }),
|
|
1505
1525
|
durationSec,
|
|
1506
1526
|
frames,
|
|
1527
|
+
...(loudnessDbfs !== null ? { loudnessDbfs } : {}),
|
|
1528
|
+
...(silenceRatio !== null ? { silenceRatio } : {}),
|
|
1507
1529
|
videoId: watchUrl.slice(-11),
|
|
1508
1530
|
});
|
|
1509
1531
|
} catch (error) {
|
|
1510
1532
|
notes.push(`표본 ${index + 1}: ${String(error?.message || error).slice(0, 100)}`);
|
|
1511
1533
|
}
|
|
1512
1534
|
}
|
|
1535
|
+
// 말속도(cpsTarget)는 타임코드 전사가 있어야 산출된다. 이 도구는 전사를 하지 않으므로
|
|
1536
|
+
// 그 축은 비워 둔다 — 재지 않은 것을 잰 척하지 않는다.
|
|
1537
|
+
notes.push("말속도(cpsTarget)는 측정하지 않습니다 — 타임코드 전사가 필요합니다.");
|
|
1513
1538
|
if (!specs.length) {
|
|
1514
1539
|
return textResult(`표본 실측에 모두 실패했습니다. 컴패니언 앱 실행 여부를 확인하세요.\n${notes.join("\n")}`, { isError: true });
|
|
1515
1540
|
}
|
|
@@ -1523,7 +1548,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
1523
1548
|
}
|
|
1524
1549
|
return jsonResult({
|
|
1525
1550
|
fingerprint: payload.fingerprint,
|
|
1526
|
-
next: "이 fingerprint 객체를 plan_shorts_video 의 channelFingerprint 로 그대로 넘기세요 —
|
|
1551
|
+
next: "이 fingerprint 객체를 plan_shorts_video·stitch_timeline·write_script 의 channelFingerprint 로 그대로 넘기세요 — 길이·컷 리듬·말끝·자막 위치·BGM 이 이 규격을 따릅니다.",
|
|
1527
1552
|
notes: notes.length ? notes : undefined,
|
|
1528
1553
|
sampleCount: specs.length,
|
|
1529
1554
|
});
|
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.1";
|
|
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";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// MCP 도구 ↔ 서버 계약 회귀.
|
|
2
|
+
// 감사에서 확인된 실제 결함들을 고정한다 — 전부 "테스트는 통과하는데 사용자에겐 깨져 보이던" 종류다.
|
|
3
|
+
//
|
|
4
|
+
// 이 파일은 zod·cloud-tools 를 import 하지 않는다. mcp/ 는 별도 npm 패키지라 그 의존성이
|
|
5
|
+
// 저장소 루트의 npm ci 에는 없고, 배포 게이트(prep.sh)는 루트에서 npm test 를 돌린다.
|
|
6
|
+
// 다른 mcp 테스트들도 같은 이유로 의존성 없이 작성돼 있다.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
|
|
11
|
+
const SOURCE = readFileSync(new URL("./cloud-tools.mjs", import.meta.url), "utf8");
|
|
12
|
+
|
|
13
|
+
/** 특정 api.request 호출 뒤에 붙은 timeoutMs 값을 읽는다. */
|
|
14
|
+
function clientBudgetFor(routePath) {
|
|
15
|
+
const at = SOURCE.indexOf(`"${routePath}"`);
|
|
16
|
+
assert.ok(at > 0, `${routePath} 호출을 찾지 못했다`);
|
|
17
|
+
const found = SOURCE.slice(at, at + 2600).match(/timeoutMs:\s*([0-9_]+)/);
|
|
18
|
+
assert.ok(found, `${routePath}: timeoutMs 를 찾지 못했다`);
|
|
19
|
+
return Number(found[1].replace(/_/g, ""));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("stitch_timeline 전환 기본값은 none — fade 는 맞대기 배치에서 컷마다 검은 딥을 만든다", () => {
|
|
23
|
+
// 0.8.0 이 서버·웹 기본값만 none 으로 바꾸고 MCP 를 놓쳐 MCP 렌더에만 딥이 남아 있었다.
|
|
24
|
+
// MCP 는 transitionMode 를 항상 명시 전송하므로 서버 기본값이 적용되지 않는다.
|
|
25
|
+
assert.match(SOURCE, /transitionMode = "none"/, "MCP 핸들러 기본값이 none 이 아니다");
|
|
26
|
+
assert.doesNotMatch(SOURCE, /transitionMode = "fade"/, "fade 기본값이 남아 있다");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("대본 도구의 targetLength 상한이 서버 출력 상한 안에 있다", () => {
|
|
30
|
+
// 서버는 max_tokens 8192 고정이라 한국어 약 12,300자가 한계다. 20000 을 광고하면
|
|
31
|
+
// 프롬프트에 도달 불가한 목표가 나가고, 잘림 감지가 없어 끊긴 대본이 정상 반환된다.
|
|
32
|
+
assert.doesNotMatch(SOURCE, /targetLength: z\.number\(\)\.int\(\)\.min\(500\)\.max\(20000\)/,
|
|
33
|
+
"도달 불가한 20000 상한이 남아 있다");
|
|
34
|
+
const caps = [...SOURCE.matchAll(/targetLength: z\.number\(\)\.int\(\)\.min\(500\)\.max\((\d+)\)/g)]
|
|
35
|
+
.map((m) => Number(m[1]));
|
|
36
|
+
assert.equal(caps.length, 3, `대본 도구 3종을 찾지 못했다 (${caps.length}개)`);
|
|
37
|
+
for (const cap of caps) assert.ok(cap <= 12000, `상한 ${cap} 이 서버 한계를 넘는다`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("지문 파라미터는 sampleSize 를 요구한다 — 없으면 서버가 조용히 무시한다", () => {
|
|
41
|
+
// 서버 buildFingerprintDirectives 는 sampleSize > 0 이 아니면 빈 배열을 반환한다.
|
|
42
|
+
// 선택 필드로 두면 부분 지문이 통과해 아무 일도 안 일어나는데 성공처럼 보인다.
|
|
43
|
+
assert.doesNotMatch(SOURCE, /sampleSize: z\.number\(\)\.optional\(\)/,
|
|
44
|
+
"sampleSize 가 아직 선택 필드다");
|
|
45
|
+
const required = [...SOURCE.matchAll(/sampleSize: z\.number\(\)\.describe\(/g)].length;
|
|
46
|
+
assert.ok(required >= 2, `sampleSize 필수 선언이 부족하다 (${required}곳)`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("measure_channel_spec 은 재지 않는 축(말속도)을 약속하지 않는다", () => {
|
|
50
|
+
// cpsTarget 은 타임코드 전사가 있어야 산출되는데 이 도구는 전사를 하지 않는다.
|
|
51
|
+
const at = SOURCE.indexOf('"measure_channel_spec"');
|
|
52
|
+
assert.ok(at > 0);
|
|
53
|
+
const block = SOURCE.slice(at, at + 1200);
|
|
54
|
+
assert.match(block, /말속도는 측정하지 않습니다/, "재지 않는 축을 약속하고 있다");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("대본 도구의 클라이언트 예산이 서버 최악 예산보다 크다", () => {
|
|
58
|
+
// 역전되면 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다 — 회수 불가능한 지출.
|
|
59
|
+
// 서버 최악: 리서치 8초 + 생성 150초 × 2회 ≈ 310초.
|
|
60
|
+
for (const route of [
|
|
61
|
+
"/api/tracker/ai/script",
|
|
62
|
+
"/api/tracker/ai/real-estate-report",
|
|
63
|
+
"/api/tracker/ai/market-analysis",
|
|
64
|
+
]) {
|
|
65
|
+
const budget = clientBudgetFor(route);
|
|
66
|
+
assert.ok(budget >= 400_000, `${route}: 클라이언트 예산 ${budget}ms 가 서버 최악보다 작다`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("이 테스트 파일은 mcp 전용 의존성을 import 하지 않는다", () => {
|
|
71
|
+
// 배포 게이트는 저장소 루트에서 npm test 를 돌리는데 zod 는 mcp/ 에만 있다.
|
|
72
|
+
// (실제로 이 규칙을 어겨 배포가 한 번 막혔다.)
|
|
73
|
+
const self = readFileSync(new URL("./tool-contract.test.mjs", import.meta.url), "utf8");
|
|
74
|
+
const importLines = self.split("\n").filter((line) => /^\s*import\s/.test(line));
|
|
75
|
+
for (const line of importLines) {
|
|
76
|
+
assert.doesNotMatch(line, /["']zod["']/, `zod 를 import 하면 루트 npm test 에서 깨진다: ${line.trim()}`);
|
|
77
|
+
assert.doesNotMatch(line, /cloud-tools\.mjs/, `cloud-tools 는 zod 를 import 하므로 같이 깨진다: ${line.trim()}`);
|
|
78
|
+
}
|
|
79
|
+
assert.ok(importLines.length >= 3, "import 문을 찾지 못했다 — 검사가 무력화됐다");
|
|
80
|
+
});
|
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
|
+
}
|