aimakeall-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/index.mjs +38 -0
- package/lib/api-client.mjs +143 -0
- package/lib/cloud-tools.mjs +644 -0
- package/lib/companion-client.mjs +153 -0
- package/lib/companion-tools.mjs +210 -0
- package/lib/config.mjs +41 -0
- package/lib/config.test.mjs +43 -0
- package/lib/media-store.mjs +136 -0
- package/lib/media-store.test.mjs +99 -0
- package/lib/mp3-duration.mjs +96 -0
- package/lib/mp3-duration.test.mjs +50 -0
- package/lib/payload-guard.mjs +103 -0
- package/lib/payload-guard.test.mjs +76 -0
- package/lib/render-job-store.mjs +67 -0
- package/lib/render-job-store.test.mjs +46 -0
- package/lib/usage-event.mjs +7 -0
- package/package.json +25 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, utimesSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test, { after } from "node:test";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
ALLOWED_IMAGE_EXTS,
|
|
9
|
+
assertAllowedInputFile,
|
|
10
|
+
encodedDataUrlBytes,
|
|
11
|
+
fileToDataUrl,
|
|
12
|
+
fileToImagePayload,
|
|
13
|
+
guessMimeType,
|
|
14
|
+
MAX_DATA_URL_SOURCE_BYTES,
|
|
15
|
+
mediaDir,
|
|
16
|
+
pruneOldMedia,
|
|
17
|
+
readPayloadHandle,
|
|
18
|
+
saveMediaBuffer,
|
|
19
|
+
savePayloadHandle,
|
|
20
|
+
} from "./media-store.mjs";
|
|
21
|
+
import { createUsageEventId } from "./usage-event.mjs";
|
|
22
|
+
|
|
23
|
+
const stateDir = mkdtempSync(join(tmpdir(), "aio-mcp-media-"));
|
|
24
|
+
const scratch = mkdtempSync(join(tmpdir(), "aio-mcp-scratch-"));
|
|
25
|
+
after(() => {
|
|
26
|
+
rmSync(stateDir, { force: true, recursive: true });
|
|
27
|
+
rmSync(scratch, { force: true, recursive: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("버퍼 저장 → data URL 변환 roundtrip (유일 파일명)", () => {
|
|
31
|
+
const audioPath = join(scratch, "clip.mp3");
|
|
32
|
+
writeFileSync(audioPath, Buffer.from("hello-audio"));
|
|
33
|
+
const dataUrl = fileToDataUrl(audioPath);
|
|
34
|
+
assert.ok(dataUrl.startsWith("data:audio/mpeg;base64,"));
|
|
35
|
+
assert.equal(Buffer.from(dataUrl.split(",")[1], "base64").toString(), "hello-audio");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("saveMediaBuffer 는 동일 이름 병렬 저장에도 경로가 겹치지 않는다", () => {
|
|
39
|
+
const a = saveMediaBuffer(stateDir, "narration.mp3", Buffer.from("A"));
|
|
40
|
+
const b = saveMediaBuffer(stateDir, "narration.mp3", Buffer.from("B"));
|
|
41
|
+
assert.notEqual(a.filePath, b.filePath);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("확장자 화이트리스트 — 허용 외 확장자·존재하지 않는 파일 거부", () => {
|
|
45
|
+
const secret = join(scratch, "id_rsa");
|
|
46
|
+
writeFileSync(secret, "PRIVATE KEY");
|
|
47
|
+
assert.throws(() => assertAllowedInputFile(secret, ALLOWED_IMAGE_EXTS, { kind: "이미지" }), /허용되지 않은/u);
|
|
48
|
+
assert.throws(() => fileToImagePayload(secret), /허용되지 않은/u);
|
|
49
|
+
assert.throws(() => assertAllowedInputFile(join(scratch, "nope.png"), ALLOWED_IMAGE_EXTS), /찾을 수 없습니다/u);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("크기 상한 초과 파일은 거부", () => {
|
|
53
|
+
const big = join(scratch, "big.mp3");
|
|
54
|
+
writeFileSync(big, Buffer.alloc(MAX_DATA_URL_SOURCE_BYTES + 1));
|
|
55
|
+
assert.throws(() => fileToDataUrl(big), /너무 큽니다/u);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("이미지 페이로드 변환 — {name, mimeType, base64}", () => {
|
|
59
|
+
const imgPath = join(scratch, "product.png");
|
|
60
|
+
writeFileSync(imgPath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
61
|
+
const payload = fileToImagePayload(imgPath);
|
|
62
|
+
assert.equal(payload.name, "product.png");
|
|
63
|
+
assert.equal(payload.mimeType, "image/png");
|
|
64
|
+
assert.equal(Buffer.from(payload.base64, "base64").length, 4);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("encodedDataUrlBytes — base64 4/3 팽창 반영", () => {
|
|
68
|
+
assert.ok(encodedDataUrlBytes(3_000_000) > 4_000_000);
|
|
69
|
+
assert.ok(encodedDataUrlBytes(0) > 0); // 헤더만
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("payload 핸들 저장·복원 + stateDir 밖 경로 거부", () => {
|
|
73
|
+
const manifest = { assets: [{ id: "a" }], projectTitle: "테스트" };
|
|
74
|
+
const handle = savePayloadHandle(stateDir, manifest, "stitch");
|
|
75
|
+
assert.deepEqual(readPayloadHandle(handle.filePath, { stateDir }), manifest);
|
|
76
|
+
// stateDir/media 밖 경로는 거부
|
|
77
|
+
const outside = join(scratch, "evil.json");
|
|
78
|
+
writeFileSync(outside, JSON.stringify({ x: 1 }));
|
|
79
|
+
assert.throws(() => readPayloadHandle(outside, { stateDir }), /stitch_timeline이 반환한 경로/u);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("MIME 추정 — 확장자 기반, 미지정 폴백", () => {
|
|
83
|
+
assert.equal(guessMimeType("/x/a.MP4"), "video/mp4");
|
|
84
|
+
assert.equal(guessMimeType("/x/a.unknown"), "application/octet-stream");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("pruneOldMedia — TTL 지난 파일만 삭제", () => {
|
|
88
|
+
const fresh = saveMediaBuffer(stateDir, "fresh.mp3", Buffer.from("f"));
|
|
89
|
+
const stale = saveMediaBuffer(stateDir, "stale.mp3", Buffer.from("s"));
|
|
90
|
+
const old = (Date.now() - 3 * 24 * 60 * 60 * 1000) / 1000;
|
|
91
|
+
utimesSync(stale.filePath, old, old);
|
|
92
|
+
const removed = pruneOldMedia(stateDir);
|
|
93
|
+
assert.ok(removed >= 1);
|
|
94
|
+
assert.ok(mediaDir(stateDir).endsWith("media"));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("usage event id — 서버 규칙 준수", () => {
|
|
98
|
+
assert.match(createUsageEventId("shorts-plan"), /^[a-z0-9._:-]{1,120}$/iu);
|
|
99
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// MPEG 오디오(mp3) 실측 길이 계산 — 프레임을 순회해 CBR·VBR 모두 정확히 처리한다.
|
|
2
|
+
// 스티치 타임라인의 audioDurationSec 에 쓰이며, 여기가 틀리면 마지막 클립이
|
|
3
|
+
// 늘어나거나(정지 화면) 오디오가 잘린다. CBR 비트레이트 추정 대신 프레임 합산을 쓴다.
|
|
4
|
+
|
|
5
|
+
// [MPEG version index][layer index] → samples/frame
|
|
6
|
+
const SAMPLES_PER_FRAME = {
|
|
7
|
+
// MPEG1
|
|
8
|
+
3: { 3: 384, 2: 1152, 1: 1152 }, // layer I, II, III
|
|
9
|
+
// MPEG2
|
|
10
|
+
2: { 3: 384, 2: 1152, 1: 576 },
|
|
11
|
+
// MPEG2.5
|
|
12
|
+
0: { 3: 384, 2: 1152, 1: 576 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SAMPLE_RATES = {
|
|
16
|
+
3: [44100, 48000, 32000], // MPEG1
|
|
17
|
+
2: [22050, 24000, 16000], // MPEG2
|
|
18
|
+
0: [11025, 12000, 8000], // MPEG2.5
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// [versionBit][layer] → bitrate table (kbps), index by 4-bit bitrate field.
|
|
22
|
+
const BITRATES = {
|
|
23
|
+
// MPEG1
|
|
24
|
+
"3-3": [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448], // layer I
|
|
25
|
+
"3-2": [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384], // layer II
|
|
26
|
+
"3-1": [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320], // layer III
|
|
27
|
+
// MPEG2 / 2.5
|
|
28
|
+
"2-3": [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256],
|
|
29
|
+
"2-2": [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
|
|
30
|
+
"2-1": [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function skipId3v2(buffer) {
|
|
34
|
+
if (buffer.length >= 10 && buffer[0] === 0x49 && buffer[1] === 0x44 && buffer[2] === 0x33) {
|
|
35
|
+
// syncsafe 28-bit size
|
|
36
|
+
const size = (buffer[6] << 21) | (buffer[7] << 14) | (buffer[8] << 7) | buffer[9];
|
|
37
|
+
return 10 + size;
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// mp3 버퍼의 총 재생 길이(초). 파싱 실패 시 0 (호출자가 폴백 판단).
|
|
43
|
+
export function mp3DurationSec(buffer) {
|
|
44
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 4) return 0;
|
|
45
|
+
let offset = skipId3v2(buffer);
|
|
46
|
+
let totalSamples = 0;
|
|
47
|
+
let sampleRate = 0;
|
|
48
|
+
let frames = 0;
|
|
49
|
+
const limit = buffer.length - 4;
|
|
50
|
+
|
|
51
|
+
while (offset <= limit) {
|
|
52
|
+
// 프레임 싱크: 11비트 1
|
|
53
|
+
if (buffer[offset] !== 0xff || (buffer[offset + 1] & 0xe0) !== 0xe0) {
|
|
54
|
+
offset += 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const b1 = buffer[offset + 1];
|
|
58
|
+
const b2 = buffer[offset + 2];
|
|
59
|
+
const versionBits = (b1 >> 3) & 0x03; // 3=MPEG1, 2=MPEG2, 0=MPEG2.5, 1=reserved
|
|
60
|
+
const layerBits = (b1 >> 1) & 0x03; // 3=I, 2=II, 1=III, 0=reserved
|
|
61
|
+
const bitrateIdx = (b2 >> 4) & 0x0f;
|
|
62
|
+
const sampleRateIdx = (b2 >> 2) & 0x03;
|
|
63
|
+
const padding = (b2 >> 1) & 0x01;
|
|
64
|
+
|
|
65
|
+
if (versionBits === 1 || layerBits === 0 || bitrateIdx === 0 || bitrateIdx === 15 || sampleRateIdx === 3) {
|
|
66
|
+
offset += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const bitrateTable = BITRATES[`${versionBits === 3 ? 3 : 2}-${layerBits}`];
|
|
71
|
+
const bitrate = (bitrateTable ? bitrateTable[bitrateIdx] : 0) * 1000;
|
|
72
|
+
const rate = SAMPLE_RATES[versionBits]?.[sampleRateIdx] || 0;
|
|
73
|
+
const spf = SAMPLES_PER_FRAME[versionBits]?.[layerBits] || 0;
|
|
74
|
+
if (!bitrate || !rate || !spf) {
|
|
75
|
+
offset += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 프레임 바이트 길이
|
|
80
|
+
const frameLen = layerBits === 3
|
|
81
|
+
? Math.floor((12 * bitrate) / rate + padding) * 4 // layer I
|
|
82
|
+
: Math.floor((spf / 8 * bitrate) / rate) + padding;
|
|
83
|
+
if (frameLen < 4) {
|
|
84
|
+
offset += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
totalSamples += spf;
|
|
89
|
+
sampleRate = rate;
|
|
90
|
+
frames += 1;
|
|
91
|
+
offset += frameLen;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!frames || !sampleRate) return 0;
|
|
95
|
+
return Math.round((totalSamples / sampleRate) * 100) / 100;
|
|
96
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test, { after } from "node:test";
|
|
7
|
+
|
|
8
|
+
import { mp3DurationSec } from "./mp3-duration.mjs";
|
|
9
|
+
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "aio-mp3-test-"));
|
|
11
|
+
after(() => rmSync(dir, { force: true, recursive: true }));
|
|
12
|
+
|
|
13
|
+
function hasFfmpeg() {
|
|
14
|
+
try {
|
|
15
|
+
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("빈/짧은 버퍼는 0", () => {
|
|
23
|
+
assert.equal(mp3DurationSec(Buffer.alloc(0)), 0);
|
|
24
|
+
assert.equal(mp3DurationSec(Buffer.from([0xff])), 0);
|
|
25
|
+
assert.equal(mp3DurationSec("not a buffer"), 0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("mp3 프레임이 없으면 0 (오디오 아님)", () => {
|
|
29
|
+
assert.equal(mp3DurationSec(Buffer.alloc(4096, 0x11)), 0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("실측 CBR/VBR 길이 (ffmpeg 있을 때만)", { skip: !hasFfmpeg() && "ffmpeg 미설치" }, () => {
|
|
33
|
+
const cbr = join(dir, "cbr.mp3");
|
|
34
|
+
execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=3", "-c:a", "libmp3lame", "-b:a", "128k", cbr], { stdio: "ignore" });
|
|
35
|
+
assert.ok(existsSync(cbr));
|
|
36
|
+
const cbrDur = mp3DurationSec(readFileSync(cbr));
|
|
37
|
+
assert.ok(cbrDur >= 2.8 && cbrDur <= 3.3, `CBR 길이 ${cbrDur} 가 3초 근처가 아님`);
|
|
38
|
+
|
|
39
|
+
const vbr = join(dir, "vbr.mp3");
|
|
40
|
+
execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=5", "-c:a", "libmp3lame", "-q:a", "4", vbr], { stdio: "ignore" });
|
|
41
|
+
const vbrDur = mp3DurationSec(readFileSync(vbr));
|
|
42
|
+
assert.ok(vbrDur >= 4.7 && vbrDur <= 5.3, `VBR 길이 ${vbrDur} 가 5초 근처가 아님`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("ID3v2 태그가 앞에 있어도 파싱 (ffmpeg 있을 때만)", { skip: !hasFfmpeg() && "ffmpeg 미설치" }, () => {
|
|
46
|
+
const tagged = join(dir, "tagged.mp3");
|
|
47
|
+
execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=2", "-c:a", "libmp3lame", "-b:a", "192k", "-metadata", "title=테스트", tagged], { stdio: "ignore" });
|
|
48
|
+
const dur = mp3DurationSec(readFileSync(tagged));
|
|
49
|
+
assert.ok(dur >= 1.8 && dur <= 2.3, `태그 mp3 길이 ${dur} 가 2초 근처가 아님`);
|
|
50
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// 서버가 준비한 렌더 페이로드를 로컬 컴패니언에 전달하기 전 정화한다.
|
|
2
|
+
// 분리 배포 토폴로지에서 클라우드 응답은 로컬 파일 경로를 정당하게 쓸 일이 없으므로,
|
|
3
|
+
// path/companionFileUrl 자산은 제거하고 원격 URL 은 허용 호스트만 통과시킨다.
|
|
4
|
+
|
|
5
|
+
// 컴패니언의 렌더 바디 상한(MAX_MOVIEPY_RENDER_BODY_BYTES)과 동일하게 유지.
|
|
6
|
+
export const COMPANION_RENDER_BODY_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
const ALLOWED_YOUTUBE_HOSTS = new Set([
|
|
9
|
+
"youtube.com",
|
|
10
|
+
"www.youtube.com",
|
|
11
|
+
"m.youtube.com",
|
|
12
|
+
"music.youtube.com",
|
|
13
|
+
"youtu.be",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export function isAllowedYoutubeUrl(value) {
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(String(value || ""));
|
|
19
|
+
return (url.protocol === "https:" || url.protocol === "http:")
|
|
20
|
+
&& ALLOWED_YOUTUBE_HOSTS.has(url.hostname.toLowerCase());
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isHttpsUrl(value) {
|
|
27
|
+
try {
|
|
28
|
+
return new URL(String(value || "")).protocol === "https:";
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 렌더 페이로드 정화 — 반환: { payload, warnings }.
|
|
36
|
+
* - asset.path / asset.companionFileUrl 제거 (로컬 파일 탈취 경로 차단)
|
|
37
|
+
* - youtubeUrl 은 유튜브 호스트만, sourceUrl 은 https 만 허용 (SSRF-류 차단)
|
|
38
|
+
* - dataUrl 은 data: 스킴만
|
|
39
|
+
*/
|
|
40
|
+
export function sanitizeRenderPayload(input) {
|
|
41
|
+
const warnings = [];
|
|
42
|
+
const payload = structuredClone(input || {});
|
|
43
|
+
const assets = Array.isArray(payload.assets) ? payload.assets : [];
|
|
44
|
+
|
|
45
|
+
payload.assets = assets.map((asset, index) => {
|
|
46
|
+
if (!asset || typeof asset !== "object") return asset;
|
|
47
|
+
const cleaned = { ...asset };
|
|
48
|
+
const label = cleaned.id || `#${index}`;
|
|
49
|
+
|
|
50
|
+
if ("path" in cleaned) {
|
|
51
|
+
delete cleaned.path;
|
|
52
|
+
warnings.push(`자산 ${label}: 로컬 path 제거됨 (분리 배포에서 서버는 로컬 경로를 보낼 수 없습니다)`);
|
|
53
|
+
}
|
|
54
|
+
if ("companionFileUrl" in cleaned) {
|
|
55
|
+
delete cleaned.companionFileUrl;
|
|
56
|
+
warnings.push(`자산 ${label}: companionFileUrl 제거됨`);
|
|
57
|
+
}
|
|
58
|
+
if (cleaned.youtubeUrl && !isAllowedYoutubeUrl(cleaned.youtubeUrl)) {
|
|
59
|
+
delete cleaned.youtubeUrl;
|
|
60
|
+
warnings.push(`자산 ${label}: 허용되지 않은 youtubeUrl 제거됨`);
|
|
61
|
+
}
|
|
62
|
+
if (cleaned.sourceUrl && !isHttpsUrl(cleaned.sourceUrl)) {
|
|
63
|
+
delete cleaned.sourceUrl;
|
|
64
|
+
warnings.push(`자산 ${label}: https 가 아닌 sourceUrl 제거됨`);
|
|
65
|
+
}
|
|
66
|
+
// 컴패니언은 sourceUrl 부재 시 asset.url 로 폴백하므로(moviepy_bridge) url 도 동일 규칙 적용.
|
|
67
|
+
if (cleaned.url && !isHttpsUrl(cleaned.url)) {
|
|
68
|
+
delete cleaned.url;
|
|
69
|
+
warnings.push(`자산 ${label}: https 가 아닌 url 제거됨`);
|
|
70
|
+
}
|
|
71
|
+
if (cleaned.dataUrl && !String(cleaned.dataUrl).startsWith("data:")) {
|
|
72
|
+
delete cleaned.dataUrl;
|
|
73
|
+
warnings.push(`자산 ${label}: data: 스킴이 아닌 dataUrl 제거됨`);
|
|
74
|
+
}
|
|
75
|
+
return cleaned;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return { payload, warnings };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 컴패니언 제출 전 크기 검사 — 초과 시 사용자에게 행동 가능한 안내를 준다.
|
|
82
|
+
export function checkRenderPayloadSize(payload) {
|
|
83
|
+
const bytes = Buffer.byteLength(JSON.stringify(payload || {}), "utf8");
|
|
84
|
+
return {
|
|
85
|
+
bytes,
|
|
86
|
+
ok: bytes <= COMPANION_RENDER_BODY_LIMIT_BYTES,
|
|
87
|
+
limitBytes: COMPANION_RENDER_BODY_LIMIT_BYTES,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Windows 금지 문자·길이까지 안전한 출력 파일명.
|
|
92
|
+
export function sanitizeOutputFilename(name, fallback = "render.mp4") {
|
|
93
|
+
let base = String(name || "").trim();
|
|
94
|
+
if (!base) return fallback;
|
|
95
|
+
base = base.replace(/[<>:"/\\|?*\u0000-\u001f]/gu, "_").replace(/[. ]+$/u, "");
|
|
96
|
+
if (!base) return fallback;
|
|
97
|
+
if (base.length > 120) {
|
|
98
|
+
const extMatch = base.match(/\.[A-Za-z0-9]{1,5}$/u);
|
|
99
|
+
const ext = extMatch ? extMatch[0] : "";
|
|
100
|
+
base = base.slice(0, 120 - ext.length) + ext;
|
|
101
|
+
}
|
|
102
|
+
return base;
|
|
103
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
checkRenderPayloadSize,
|
|
6
|
+
COMPANION_RENDER_BODY_LIMIT_BYTES,
|
|
7
|
+
isAllowedYoutubeUrl,
|
|
8
|
+
sanitizeOutputFilename,
|
|
9
|
+
sanitizeRenderPayload,
|
|
10
|
+
} from "./payload-guard.mjs";
|
|
11
|
+
|
|
12
|
+
test("path·companionFileUrl 자산은 제거되고 경고가 남는다", () => {
|
|
13
|
+
const { payload, warnings } = sanitizeRenderPayload({
|
|
14
|
+
assets: [
|
|
15
|
+
{ id: "a", kind: "video", path: "/Users/x/private.mov", youtubeUrl: "https://www.youtube.com/watch?v=abc" },
|
|
16
|
+
{ id: "b", kind: "audio", companionFileUrl: "http://127.0.0.1:9876/api/files/p/f.mp3", dataUrl: "data:audio/mp3;base64,AAA" },
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
assert.equal(payload.assets[0].path, undefined);
|
|
20
|
+
assert.equal(payload.assets[0].youtubeUrl, "https://www.youtube.com/watch?v=abc");
|
|
21
|
+
assert.equal(payload.assets[1].companionFileUrl, undefined);
|
|
22
|
+
assert.equal(payload.assets[1].dataUrl, "data:audio/mp3;base64,AAA");
|
|
23
|
+
assert.equal(warnings.length, 2);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("유튜브 외 호스트 youtubeUrl·비 https sourceUrl/url·비 data dataUrl 은 제거", () => {
|
|
27
|
+
const { payload, warnings } = sanitizeRenderPayload({
|
|
28
|
+
assets: [
|
|
29
|
+
{ id: "a", youtubeUrl: "https://evil.example.com/watch?v=abc" },
|
|
30
|
+
{ id: "b", sourceUrl: "http://internal.host/file.mp4" },
|
|
31
|
+
{ id: "c", dataUrl: "https://not-data.example.com/x" },
|
|
32
|
+
{ id: "d", sourceUrl: "https://cdn.example.com/ok.mp4" },
|
|
33
|
+
{ id: "e", url: "http://169.254.169.254/latest/meta-data" },
|
|
34
|
+
{ id: "f", url: "https://cdn.example.com/ok.mp4" },
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
assert.equal(payload.assets[0].youtubeUrl, undefined);
|
|
38
|
+
assert.equal(payload.assets[1].sourceUrl, undefined);
|
|
39
|
+
assert.equal(payload.assets[2].dataUrl, undefined);
|
|
40
|
+
assert.equal(payload.assets[3].sourceUrl, "https://cdn.example.com/ok.mp4");
|
|
41
|
+
assert.equal(payload.assets[4].url, undefined); // 컴패니언이 sourceUrl 부재 시 url 로 폴백하므로 검증
|
|
42
|
+
assert.equal(payload.assets[5].url, "https://cdn.example.com/ok.mp4");
|
|
43
|
+
assert.equal(warnings.length, 4);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("입력 페이로드는 변형되지 않는다 (deep clone)", () => {
|
|
47
|
+
const input = { assets: [{ id: "a", path: "/tmp/x" }] };
|
|
48
|
+
sanitizeRenderPayload(input);
|
|
49
|
+
assert.equal(input.assets[0].path, "/tmp/x");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("isAllowedYoutubeUrl — 표준 호스트만", () => {
|
|
53
|
+
assert.equal(isAllowedYoutubeUrl("https://youtu.be/abc"), true);
|
|
54
|
+
assert.equal(isAllowedYoutubeUrl("https://m.youtube.com/watch?v=a"), true);
|
|
55
|
+
assert.equal(isAllowedYoutubeUrl("https://youtube.com.evil.com/watch"), false);
|
|
56
|
+
assert.equal(isAllowedYoutubeUrl("ftp://youtube.com/x"), false);
|
|
57
|
+
assert.equal(isAllowedYoutubeUrl("not-a-url"), false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("페이로드 크기 검사 — 32MB 상한", () => {
|
|
61
|
+
const small = checkRenderPayloadSize({ a: 1 });
|
|
62
|
+
assert.equal(small.ok, true);
|
|
63
|
+
assert.equal(small.limitBytes, COMPANION_RENDER_BODY_LIMIT_BYTES);
|
|
64
|
+
const big = checkRenderPayloadSize({ blob: "x".repeat(COMPANION_RENDER_BODY_LIMIT_BYTES) });
|
|
65
|
+
assert.equal(big.ok, false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("출력 파일명 정화 — 금지문자 치환·길이 상한·확장자 보존·폴백", () => {
|
|
69
|
+
assert.equal(sanitizeOutputFilename('내 영상: "최종"?.mp4'), "내 영상_ _최종__.mp4");
|
|
70
|
+
assert.equal(sanitizeOutputFilename("a-b c.mp4"), "a-b c.mp4");
|
|
71
|
+
assert.equal(sanitizeOutputFilename("", "fallback.mp4"), "fallback.mp4");
|
|
72
|
+
assert.equal(sanitizeOutputFilename("..."), "render.mp4");
|
|
73
|
+
const long = sanitizeOutputFilename(`${"가".repeat(200)}.mp4`);
|
|
74
|
+
assert.ok(long.length <= 120);
|
|
75
|
+
assert.ok(long.endsWith(".mp4"));
|
|
76
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// 렌더 잡 영속화 — npx 프록시가 재시작돼도 jobId 를 잃지 않도록
|
|
6
|
+
// ~/.aimakeall/render-jobs.json 에 기록한다. 컴패니언 잡 TTL(만료) 은 서버가 판정하므로
|
|
7
|
+
// 여기서는 오래된 항목만 정리한다.
|
|
8
|
+
|
|
9
|
+
const MAX_ENTRIES = 50;
|
|
10
|
+
const ENTRY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export function renderJobStorePath(stateDir) {
|
|
13
|
+
return path.join(stateDir, "render-jobs.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readEntries(filePath) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
19
|
+
return Array.isArray(parsed?.jobs) ? parsed.jobs : [];
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeEntries(filePath, jobs) {
|
|
26
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
27
|
+
// 여러 프록시 프로세스(세션마다 별도)가 같은 파일을 쓰므로 tmp 경로를 프로세스·uuid 로
|
|
28
|
+
// 유일하게 만들어 rename 레이스(ENOENT)를 막는다.
|
|
29
|
+
const tmpPath = `${filePath}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
30
|
+
writeFileSync(tmpPath, `${JSON.stringify({ jobs }, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
renameSync(tmpPath, filePath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pruneEntries(jobs, now) {
|
|
35
|
+
return jobs
|
|
36
|
+
.filter((job) => job && job.jobId && now - (Number(job.createdAtMs) || 0) < ENTRY_TTL_MS)
|
|
37
|
+
.slice(-MAX_ENTRIES);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function recordRenderJob(stateDir, { jobId, projectTitle = "", statusUrl = "", outputUrl = "" }, now = Date.now()) {
|
|
41
|
+
const filePath = renderJobStorePath(stateDir);
|
|
42
|
+
const jobs = pruneEntries(readEntries(filePath), now);
|
|
43
|
+
jobs.push({
|
|
44
|
+
createdAt: new Date(now).toISOString(),
|
|
45
|
+
createdAtMs: now,
|
|
46
|
+
jobId: String(jobId || ""),
|
|
47
|
+
outputUrl: String(outputUrl || ""),
|
|
48
|
+
projectTitle: String(projectTitle || ""),
|
|
49
|
+
statusUrl: String(statusUrl || ""),
|
|
50
|
+
});
|
|
51
|
+
writeEntries(filePath, jobs);
|
|
52
|
+
return jobs[jobs.length - 1];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listRenderJobs(stateDir, now = Date.now()) {
|
|
56
|
+
return pruneEntries(readEntries(renderJobStorePath(stateDir)), now).reverse();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function updateRenderJob(stateDir, jobId, patch = {}, now = Date.now()) {
|
|
60
|
+
const filePath = renderJobStorePath(stateDir);
|
|
61
|
+
const jobs = pruneEntries(readEntries(filePath), now);
|
|
62
|
+
const index = jobs.findIndex((job) => job.jobId === String(jobId || ""));
|
|
63
|
+
if (index < 0) return null;
|
|
64
|
+
jobs[index] = { ...jobs[index], ...patch };
|
|
65
|
+
writeEntries(filePath, jobs);
|
|
66
|
+
return jobs[index];
|
|
67
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test, { after } from "node:test";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
listRenderJobs,
|
|
9
|
+
recordRenderJob,
|
|
10
|
+
renderJobStorePath,
|
|
11
|
+
updateRenderJob,
|
|
12
|
+
} from "./render-job-store.mjs";
|
|
13
|
+
|
|
14
|
+
const stateDir = mkdtempSync(join(tmpdir(), "aio-mcp-jobstore-"));
|
|
15
|
+
after(() => rmSync(stateDir, { force: true, recursive: true }));
|
|
16
|
+
|
|
17
|
+
test("기록 → 목록(최신순) → 갱신 roundtrip", () => {
|
|
18
|
+
recordRenderJob(stateDir, { jobId: "job-1", projectTitle: "첫번째" });
|
|
19
|
+
recordRenderJob(stateDir, { jobId: "job-2", projectTitle: "두번째", statusUrl: "/api/moviepy/render-jobs/job-2" });
|
|
20
|
+
|
|
21
|
+
const jobs = listRenderJobs(stateDir);
|
|
22
|
+
assert.equal(jobs.length, 2);
|
|
23
|
+
assert.equal(jobs[0].jobId, "job-2");
|
|
24
|
+
assert.equal(jobs[1].projectTitle, "첫번째");
|
|
25
|
+
|
|
26
|
+
const updated = updateRenderJob(stateDir, "job-1", { status: "succeeded", filePath: "/tmp/a.mp4" });
|
|
27
|
+
assert.equal(updated.status, "succeeded");
|
|
28
|
+
assert.equal(listRenderJobs(stateDir).find((job) => job.jobId === "job-1").filePath, "/tmp/a.mp4");
|
|
29
|
+
assert.equal(updateRenderJob(stateDir, "missing", { status: "x" }), null);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("24시간 지난 항목은 정리된다", () => {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
recordRenderJob(stateDir, { jobId: "old-job" }, now - 25 * 60 * 60 * 1000);
|
|
35
|
+
const jobs = listRenderJobs(stateDir, now);
|
|
36
|
+
assert.equal(jobs.some((job) => job.jobId === "old-job"), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("손상된 저장 파일은 빈 목록으로 복구된다", () => {
|
|
40
|
+
const filePath = renderJobStorePath(stateDir);
|
|
41
|
+
assert.ok(readFileSync(filePath, "utf8").includes("job-2"));
|
|
42
|
+
rmSync(filePath);
|
|
43
|
+
assert.deepEqual(listRenderJobs(stateDir), []);
|
|
44
|
+
recordRenderJob(stateDir, { jobId: "after-reset" });
|
|
45
|
+
assert.equal(listRenderJobs(stateDir)[0].jobId, "after-reset");
|
|
46
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// 서버 usage-event id 규칙(/^[a-z0-9._:-]{1,120}$/i)에 맞는 클라이언트 이벤트 id.
|
|
4
|
+
// 비용 원장(user_cost_ledger)의 멱등 키로 쓰인다 — 재시도 시 중복 과금 방지.
|
|
5
|
+
export function createUsageEventId(prefix) {
|
|
6
|
+
return `${prefix}:started-${Date.now()}:${randomUUID()}`;
|
|
7
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aimakeall-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AImakeAll MCP 서버 — Claude Code/Codex에서 자연어로 영상 기획·생성·렌더·퍼블리시 (렌더는 로컬 컴패니언)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"aimakeall-mcp": "index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.mjs",
|
|
11
|
+
"lib/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.17"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test lib/*.test.mjs"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
22
|
+
"zod": "^3.25.1"
|
|
23
|
+
},
|
|
24
|
+
"license": "UNLICENSED"
|
|
25
|
+
}
|