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,153 @@
|
|
|
1
|
+
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
|
|
6
|
+
import { sanitizeOutputFilename } from "./payload-guard.mjs";
|
|
7
|
+
|
|
8
|
+
// 로컬 컴패니언(127.0.0.1:9876) 클라이언트 — 웹 프런트의 renderJobClient 계약을 따른다.
|
|
9
|
+
// 비브라우저 클라이언트는 Origin 헤더를 보내지 않아야 컴패니언 게이트를 그대로 통과한다.
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
12
|
+
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
|
|
13
|
+
|
|
14
|
+
async function requestJson(baseUrl, pathName, { method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
15
|
+
const response = await fetch(`${baseUrl}${pathName}`, {
|
|
16
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
17
|
+
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
|
18
|
+
method,
|
|
19
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
20
|
+
});
|
|
21
|
+
const text = await response.text();
|
|
22
|
+
let payload = null;
|
|
23
|
+
try {
|
|
24
|
+
payload = text ? JSON.parse(text) : null;
|
|
25
|
+
} catch {
|
|
26
|
+
payload = null;
|
|
27
|
+
}
|
|
28
|
+
return { payload, status: response.status };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function getCompanionHealth(companionUrl) {
|
|
32
|
+
return requestJson(companionUrl, "/health");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function getMoviePyHealth(companionUrl) {
|
|
36
|
+
// moviepy 헬스는 파이썬 프로브를 돌리므로 여유 있게.
|
|
37
|
+
return requestJson(companionUrl, "/api/moviepy/health", { timeoutMs: 60_000 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 컴패니언 상태를 4가지로 진단 — 상태별로 다른 안내를 내보내기 위함.
|
|
41
|
+
// 반환: { state: "ready"|"not-running"|"port-conflict"|"runtime-missing", detail }
|
|
42
|
+
export async function triageCompanion(companionUrl) {
|
|
43
|
+
let health;
|
|
44
|
+
try {
|
|
45
|
+
health = await getCompanionHealth(companionUrl);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { detail: String(error?.cause?.code || error?.message || error), state: "not-running" };
|
|
48
|
+
}
|
|
49
|
+
const app = String(health.payload?.app || "");
|
|
50
|
+
if (!health.payload?.ok || !app.includes("Companion")) {
|
|
51
|
+
return { detail: `9876 포트 응답이 컴패니언이 아님 (app=${app || "?"})`, state: "port-conflict" };
|
|
52
|
+
}
|
|
53
|
+
let moviepy;
|
|
54
|
+
try {
|
|
55
|
+
moviepy = await getMoviePyHealth(companionUrl);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { detail: String(error?.message || error), state: "runtime-missing" };
|
|
58
|
+
}
|
|
59
|
+
if (!moviepy.payload?.ok || moviepy.payload?.moviepy?.available !== true) {
|
|
60
|
+
return {
|
|
61
|
+
detail: String(moviepy.payload?.moviepy?.reason || "MoviePy 런타임을 찾지 못했습니다."),
|
|
62
|
+
state: "runtime-missing",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
detail: `moviepy ${moviepy.payload.moviepy.version} (${moviepy.payload.moviepy.label})`,
|
|
67
|
+
state: "ready",
|
|
68
|
+
version: String(health.payload?.version || ""),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function createRenderJob(companionUrl, payload) {
|
|
73
|
+
return requestJson(companionUrl, "/api/moviepy/render-jobs", {
|
|
74
|
+
body: payload,
|
|
75
|
+
method: "POST",
|
|
76
|
+
timeoutMs: 60_000,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function getRenderJob(companionUrl, jobId) {
|
|
81
|
+
return requestJson(companionUrl, `/api/moviepy/render-jobs/${encodeURIComponent(String(jobId || ""))}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Content-Disposition 에서 파일명 추출 — filename*=UTF-8'' 우선, 없으면 filename=.
|
|
85
|
+
export function parseContentDispositionFilename(headerValue) {
|
|
86
|
+
const raw = String(headerValue || "");
|
|
87
|
+
const star = raw.match(/filename\*=UTF-8''([^;]+)/iu);
|
|
88
|
+
if (star) {
|
|
89
|
+
try {
|
|
90
|
+
return decodeURIComponent(star[1].trim());
|
|
91
|
+
} catch {
|
|
92
|
+
// fall through
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const plain = raw.match(/filename="?([^";]+)"?/iu);
|
|
96
|
+
return plain ? plain[1].trim() : "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 완료된 잡의 mp4 를 스트리밍으로 저장 — .part 로 받아 원자적 rename.
|
|
101
|
+
* 성공 시 컴패니언이 파일을 삭제(1회성)하므로 저장 성공 후에만 소비된 것으로 본다.
|
|
102
|
+
* 반환: { filePath, bytes } 또는 { error, status }
|
|
103
|
+
*/
|
|
104
|
+
// 사용된 파일명이 이미 있으면 -2, -3 … 을 붙여 기존 출력을 덮어쓰지 않는다.
|
|
105
|
+
function uniqueOutputPath(dir, fileName) {
|
|
106
|
+
const ext = path.extname(fileName);
|
|
107
|
+
const base = fileName.slice(0, fileName.length - ext.length);
|
|
108
|
+
let candidate = path.join(dir, fileName);
|
|
109
|
+
let counter = 2;
|
|
110
|
+
while (existsSync(candidate)) {
|
|
111
|
+
candidate = path.join(dir, `${base}-${counter}${ext}`);
|
|
112
|
+
counter += 1;
|
|
113
|
+
}
|
|
114
|
+
return candidate;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function downloadRenderOutput(companionUrl, jobId, outputDir, { suggestedName = "" } = {}) {
|
|
118
|
+
let response;
|
|
119
|
+
try {
|
|
120
|
+
response = await fetch(
|
|
121
|
+
`${companionUrl}/api/moviepy/render-jobs/${encodeURIComponent(String(jobId || ""))}/output`,
|
|
122
|
+
{ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) },
|
|
123
|
+
);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return { error: `출력 다운로드 중 컴패니언 연결 오류: ${String(error?.message || error)}`, status: 0 };
|
|
126
|
+
}
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
let detail = "";
|
|
129
|
+
try {
|
|
130
|
+
detail = JSON.parse(await response.text())?.error || "";
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore
|
|
133
|
+
}
|
|
134
|
+
return { error: detail || `출력 다운로드 실패 (HTTP ${response.status})`, status: response.status };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const headerName = parseContentDispositionFilename(response.headers.get("content-disposition"));
|
|
138
|
+
let fileName = sanitizeOutputFilename(suggestedName || headerName, `render-${jobId}.mp4`);
|
|
139
|
+
if (!/\.[A-Za-z0-9]{1,5}$/u.test(fileName)) fileName += ".mp4"; // 확장자 없으면 mp4 보정
|
|
140
|
+
mkdirSync(outputDir, { recursive: true });
|
|
141
|
+
const finalPath = uniqueOutputPath(outputDir, fileName);
|
|
142
|
+
const partPath = `${finalPath}.part`;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(partPath, { mode: 0o600 }));
|
|
146
|
+
renameSync(partPath, finalPath);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// 중단 시 부분 파일 정리 — 잡은 다운로드 실패로 남아 재시도 가능(네트워크 abort 시 서버가 복구).
|
|
149
|
+
try { rmSync(partPath, { force: true }); } catch { /* ignore */ }
|
|
150
|
+
return { error: `출력 저장 중 오류: ${String(error?.message || error)} — render_status로 상태 확인 후 다시 시도하세요.`, status: 0 };
|
|
151
|
+
}
|
|
152
|
+
return { bytes: statSync(finalPath).size, filePath: finalPath };
|
|
153
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createRenderJob,
|
|
5
|
+
downloadRenderOutput,
|
|
6
|
+
getRenderJob,
|
|
7
|
+
triageCompanion,
|
|
8
|
+
} from "./companion-client.mjs";
|
|
9
|
+
import { detectRemoteEnvironment } from "./config.mjs";
|
|
10
|
+
import { readPayloadHandle } from "./media-store.mjs";
|
|
11
|
+
import {
|
|
12
|
+
checkRenderPayloadSize,
|
|
13
|
+
sanitizeRenderPayload,
|
|
14
|
+
} from "./payload-guard.mjs";
|
|
15
|
+
import {
|
|
16
|
+
listRenderJobs,
|
|
17
|
+
recordRenderJob,
|
|
18
|
+
updateRenderJob,
|
|
19
|
+
} from "./render-job-store.mjs";
|
|
20
|
+
|
|
21
|
+
// 컴패니언(로컬 렌더) 관련 MCP 툴 — 렌더는 100% 사용자 PC에서 수행된다.
|
|
22
|
+
|
|
23
|
+
function textResult(text, { isError = false } = {}) {
|
|
24
|
+
return { content: [{ text, type: "text" }], ...(isError ? { isError: true } : {}) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 컴패니언 미기동 4상태별 안내 — 리뷰에서 확정한 트리아지.
|
|
28
|
+
export function describeCompanionState(triage, remoteEnv) {
|
|
29
|
+
if (triage.state === "ready") return `컴패니언 정상 (${triage.detail})`;
|
|
30
|
+
if (triage.state === "not-running") {
|
|
31
|
+
if (remoteEnv === "ssh") {
|
|
32
|
+
return "이 세션은 원격(SSH)입니다. 로컬 PC에서 컴패니언을 실행한 뒤 터널을 여세요: ssh -R 9876:127.0.0.1:9876 <이 서버>";
|
|
33
|
+
}
|
|
34
|
+
if (remoteEnv === "wsl") {
|
|
35
|
+
return "WSL에서는 127.0.0.1이 Windows 호스트와 분리됩니다. Windows에서 컴패니언을 실행하고 WSL mirrored 네트워킹을 켜거나, AIMAKEALL_COMPANION_URL로 접근 가능한 주소를 지정하세요.";
|
|
36
|
+
}
|
|
37
|
+
return "AImakeAll 컴패니언이 실행 중이 아닙니다. aimakeall.com에서 컴패니언 앱을 설치·실행한 뒤 다시 시도하세요.";
|
|
38
|
+
}
|
|
39
|
+
if (triage.state === "port-conflict") {
|
|
40
|
+
return `9876 포트를 다른 프로그램이 사용 중입니다 (${triage.detail}). 해당 프로그램을 종료하거나 컴패니언 포트를 확인하세요.`;
|
|
41
|
+
}
|
|
42
|
+
return `컴패니언은 실행 중이지만 렌더 런타임이 없습니다 (${triage.detail}). 컴패니언 앱에서 렌더 런타임 설치를 실행하세요.`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registerCompanionTools(server, config) {
|
|
46
|
+
const remoteEnv = detectRemoteEnvironment();
|
|
47
|
+
|
|
48
|
+
server.tool(
|
|
49
|
+
"companion_status",
|
|
50
|
+
"로컬 렌더 컴패니언(사용자 PC)의 상태를 진단합니다. 렌더 작업 전에 먼저 호출하세요.",
|
|
51
|
+
{},
|
|
52
|
+
async () => {
|
|
53
|
+
const triage = await triageCompanion(config.companionUrl);
|
|
54
|
+
const guidance = describeCompanionState(triage, remoteEnv);
|
|
55
|
+
return textResult(JSON.stringify({ detail: triage.detail, guidance, state: triage.state }, null, 2), {
|
|
56
|
+
isError: triage.state !== "ready",
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
server.tool(
|
|
62
|
+
"render_start",
|
|
63
|
+
"타임라인 페이로드를 로컬 컴패니언에 제출해 mp4 렌더를 시작합니다. stitch_timeline이 반환한 payloadPath를 넘기는 것이 표준 경로입니다. 즉시 jobId를 반환하며, render_status로 폴링하고 render_result로 파일을 저장하세요. 렌더는 이 PC에서 수행됩니다.",
|
|
64
|
+
{
|
|
65
|
+
payloadPath: z.string().optional().describe("stitch_timeline이 반환한 매니페스트 파일 경로 (권장)"),
|
|
66
|
+
payload: z.record(z.any()).optional().describe("타임라인 페이로드 객체를 직접 전달 (소형일 때만)"),
|
|
67
|
+
projectTitle: z.string().optional().describe("잡 라벨용 프로젝트 이름"),
|
|
68
|
+
},
|
|
69
|
+
async ({ payloadPath, payload, projectTitle }) => {
|
|
70
|
+
const triage = await triageCompanion(config.companionUrl);
|
|
71
|
+
if (triage.state !== "ready") {
|
|
72
|
+
return textResult(describeCompanionState(triage, remoteEnv), { isError: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let resolvedPayload = payload;
|
|
76
|
+
if (payloadPath) {
|
|
77
|
+
try {
|
|
78
|
+
// stateDir/media 안의 핸들만 허용 — 임의 로컬 JSON 파일 읽기 차단.
|
|
79
|
+
resolvedPayload = readPayloadHandle(payloadPath, { stateDir: config.stateDir });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return textResult(`payloadPath를 읽지 못했습니다: ${String(error?.message || error)}`, { isError: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!resolvedPayload || typeof resolvedPayload !== "object") {
|
|
85
|
+
return textResult("payloadPath 또는 payload 중 하나는 필요합니다.", { isError: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { payload: cleaned, warnings } = sanitizeRenderPayload(resolvedPayload);
|
|
89
|
+
const size = checkRenderPayloadSize(cleaned);
|
|
90
|
+
if (!size.ok) {
|
|
91
|
+
return textResult(
|
|
92
|
+
`렌더 페이로드가 컴패니언 상한(32MB)을 초과합니다 (${Math.round(size.bytes / 1024 / 1024)}MB). 영상을 나눠서 렌더하세요.`,
|
|
93
|
+
{ isError: true },
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let result;
|
|
98
|
+
try {
|
|
99
|
+
result = await createRenderJob(config.companionUrl, cleaned);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return textResult(`컴패니언 렌더 요청 실패: ${String(error?.message || error)} — 컴패니언이 실행 중인지 확인하세요.`, { isError: true });
|
|
102
|
+
}
|
|
103
|
+
if (result.status === 429) {
|
|
104
|
+
return textResult(
|
|
105
|
+
"다른 렌더(웹 스튜디오 포함)가 이미 대기열에 있습니다. 끝난 뒤 다시 시도하세요.",
|
|
106
|
+
{ isError: true },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (result.status >= 400 || !result.payload?.jobId) {
|
|
110
|
+
return textResult(
|
|
111
|
+
`렌더 잡 생성 실패 (HTTP ${result.status}): ${result.payload?.error || "알 수 없는 오류"}`,
|
|
112
|
+
{ isError: true },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 잡 영속화는 복구용 best-effort — 실패해도 jobId 는 반드시 반환한다.
|
|
117
|
+
try {
|
|
118
|
+
recordRenderJob(config.stateDir, {
|
|
119
|
+
jobId: result.payload.jobId,
|
|
120
|
+
outputUrl: result.payload.outputUrl || "",
|
|
121
|
+
projectTitle: projectTitle || String(resolvedPayload?.projectTitle || ""),
|
|
122
|
+
statusUrl: result.payload.statusUrl || "",
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
// 영속화 실패는 무시
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return textResult(JSON.stringify({
|
|
129
|
+
jobId: result.payload.jobId,
|
|
130
|
+
next: "render_status로 진행률을 폴링하세요 (2~5초 간격 권장).",
|
|
131
|
+
status: result.payload.status || "queued",
|
|
132
|
+
warnings,
|
|
133
|
+
}, null, 2));
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
server.tool(
|
|
138
|
+
"render_status",
|
|
139
|
+
"렌더 잡의 진행률(percent/eta/stage)을 조회합니다.",
|
|
140
|
+
{ jobId: z.string().describe("render_start가 반환한 jobId") },
|
|
141
|
+
async ({ jobId }) => {
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = await getRenderJob(config.companionUrl, jobId);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return textResult(`컴패니언 상태 조회 실패: ${String(error?.message || error)} — 컴패니언이 실행 중인지 확인하세요.`, { isError: true });
|
|
147
|
+
}
|
|
148
|
+
if (result.status === 404) {
|
|
149
|
+
return textResult(
|
|
150
|
+
"잡을 찾을 수 없습니다. 컴패니언이 재시작되어 잡이 사라졌을 수 있습니다 — render_start로 다시 시작하세요. (list_render_jobs로 이 PC에서 시작한 잡 목록을 볼 수 있습니다)",
|
|
151
|
+
{ isError: true },
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (result.status >= 400) {
|
|
155
|
+
return textResult(`상태 조회 실패 (HTTP ${result.status})`, { isError: true });
|
|
156
|
+
}
|
|
157
|
+
const job = result.payload || {};
|
|
158
|
+
try {
|
|
159
|
+
updateRenderJob(config.stateDir, jobId, { status: job.status || "" });
|
|
160
|
+
} catch {
|
|
161
|
+
// 영속화 실패는 무시
|
|
162
|
+
}
|
|
163
|
+
return textResult(JSON.stringify({
|
|
164
|
+
etaSec: job.progress?.etaSec ?? null,
|
|
165
|
+
percent: job.progress?.percent ?? 0,
|
|
166
|
+
stage: job.stage || "",
|
|
167
|
+
status: job.status || "",
|
|
168
|
+
}, null, 2));
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
server.tool(
|
|
173
|
+
"render_result",
|
|
174
|
+
"완료된 렌더 잡의 mp4를 이 PC의 출력 폴더에 저장하고 절대 경로를 반환합니다. 출력은 1회성입니다(저장 성공 후 컴패니언에서 삭제됨).",
|
|
175
|
+
{
|
|
176
|
+
jobId: z.string().describe("render_start가 반환한 jobId"),
|
|
177
|
+
fileName: z.string().optional().describe("저장할 파일명 (생략 시 서버 제안 이름)"),
|
|
178
|
+
},
|
|
179
|
+
async ({ jobId, fileName }) => {
|
|
180
|
+
const saved = await downloadRenderOutput(config.companionUrl, jobId, config.outputDir, {
|
|
181
|
+
suggestedName: fileName || "",
|
|
182
|
+
});
|
|
183
|
+
if (saved.error) {
|
|
184
|
+
const hint = saved.status === 409
|
|
185
|
+
? " (아직 렌더가 진행 중입니다 — render_status로 완료를 확인한 뒤 다시 호출하세요)"
|
|
186
|
+
: saved.status === 410
|
|
187
|
+
? " (잡이 만료되어 출력이 삭제되었습니다 — render_start로 다시 렌더하세요)"
|
|
188
|
+
: "";
|
|
189
|
+
return textResult(`${saved.error}${hint}`, { isError: true });
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
updateRenderJob(config.stateDir, jobId, { filePath: saved.filePath, status: "downloaded" });
|
|
193
|
+
} catch {
|
|
194
|
+
// 영속화 실패는 무시
|
|
195
|
+
}
|
|
196
|
+
return textResult(JSON.stringify({
|
|
197
|
+
bytes: saved.bytes,
|
|
198
|
+
filePath: saved.filePath,
|
|
199
|
+
note: "파일은 이 PC에 저장되었습니다.",
|
|
200
|
+
}, null, 2));
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
server.tool(
|
|
205
|
+
"list_render_jobs",
|
|
206
|
+
"이 PC에서 최근 24시간 내 시작한 렌더 잡 목록(프록시 재시작 후 복구용)을 보여줍니다.",
|
|
207
|
+
{},
|
|
208
|
+
async () => textResult(JSON.stringify({ jobs: listRenderJobs(config.stateDir) }, null, 2)),
|
|
209
|
+
);
|
|
210
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// 프록시 버전 — 서버가 X-AImakeAll-MCP-Version 으로 하한을 강제(426)할 수 있다.
|
|
5
|
+
export const MCP_PROXY_VERSION = "0.1.0";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_API_BASE = "https://aimakeall.com";
|
|
8
|
+
export const DEFAULT_COMPANION_URL = "http://127.0.0.1:9876";
|
|
9
|
+
|
|
10
|
+
// PAT 형식 사전 검증 — 잘못 복사한 값으로 서버에 무의미한 401 트래픽을 만들지 않는다.
|
|
11
|
+
export const PAT_PATTERN = /^aio_pat_[A-Za-z0-9_-]{40,64}$/;
|
|
12
|
+
|
|
13
|
+
export function resolveConfig(env = process.env) {
|
|
14
|
+
const pat = String(env.AIMAKEALL_PAT || "").trim();
|
|
15
|
+
const apiBase = (String(env.AIMAKEALL_API_BASE || "").trim() || DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
16
|
+
const companionUrl = (String(env.AIMAKEALL_COMPANION_URL || "").trim() || DEFAULT_COMPANION_URL).replace(/\/+$/, "");
|
|
17
|
+
const outputDir = String(env.AIMAKEALL_OUTPUT_DIR || "").trim()
|
|
18
|
+
|| path.join(homedir(), "Downloads", "AImakeAll");
|
|
19
|
+
const stateDir = path.join(homedir(), ".aimakeall");
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
apiBase,
|
|
23
|
+
companionUrl,
|
|
24
|
+
outputDir,
|
|
25
|
+
pat,
|
|
26
|
+
patLooksValid: PAT_PATTERN.test(pat),
|
|
27
|
+
stateDir,
|
|
28
|
+
version: MCP_PROXY_VERSION,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 원격/컨테이너 환경 감지 — 컴패니언 부재 시 설치 안내 대신 SSH 터널 안내를 내보낸다.
|
|
33
|
+
export function detectRemoteEnvironment(env = process.env) {
|
|
34
|
+
if (String(env.SSH_CONNECTION || "").trim() || String(env.SSH_TTY || "").trim()) {
|
|
35
|
+
return "ssh";
|
|
36
|
+
}
|
|
37
|
+
if (String(env.WSL_DISTRO_NAME || "").trim()) {
|
|
38
|
+
return "wsl";
|
|
39
|
+
}
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { detectRemoteEnvironment, resolveConfig } from "./config.mjs";
|
|
5
|
+
import { parseContentDispositionFilename } from "./companion-client.mjs";
|
|
6
|
+
|
|
7
|
+
test("기본값 — API base·컴패니언 URL·출력 디렉터리", () => {
|
|
8
|
+
const config = resolveConfig({});
|
|
9
|
+
assert.equal(config.apiBase, "https://aimakeall.com");
|
|
10
|
+
assert.equal(config.companionUrl, "http://127.0.0.1:9876");
|
|
11
|
+
assert.ok(config.outputDir.endsWith("AImakeAll"));
|
|
12
|
+
assert.equal(config.pat, "");
|
|
13
|
+
assert.equal(config.patLooksValid, false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("env 오버라이드 + 후행 슬래시 정리 + PAT 형식 판정", () => {
|
|
17
|
+
const config = resolveConfig({
|
|
18
|
+
AIMAKEALL_API_BASE: "https://staging.example.com///",
|
|
19
|
+
AIMAKEALL_COMPANION_URL: "http://127.0.0.1:19876/",
|
|
20
|
+
AIMAKEALL_PAT: "aio_pat_0123456789012345678901234567890123456789",
|
|
21
|
+
});
|
|
22
|
+
assert.equal(config.apiBase, "https://staging.example.com");
|
|
23
|
+
assert.equal(config.companionUrl, "http://127.0.0.1:19876");
|
|
24
|
+
assert.equal(config.patLooksValid, true);
|
|
25
|
+
assert.equal(resolveConfig({ AIMAKEALL_PAT: "sk-wrong-kind" }).patLooksValid, false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("원격 환경 감지 — ssh/wsl/로컬", () => {
|
|
29
|
+
assert.equal(detectRemoteEnvironment({ SSH_CONNECTION: "1.2.3.4 5 6.7.8.9 22" }), "ssh");
|
|
30
|
+
assert.equal(detectRemoteEnvironment({ SSH_TTY: "/dev/pts/0" }), "ssh");
|
|
31
|
+
assert.equal(detectRemoteEnvironment({ WSL_DISTRO_NAME: "Ubuntu" }), "wsl");
|
|
32
|
+
assert.equal(detectRemoteEnvironment({}), "");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("Content-Disposition 파일명 파싱 — RFC5987 우선, 따옴표 폴백", () => {
|
|
36
|
+
assert.equal(
|
|
37
|
+
parseContentDispositionFilename("attachment; filename*=UTF-8''%EC%98%81%EC%83%81.mp4"),
|
|
38
|
+
"영상.mp4",
|
|
39
|
+
);
|
|
40
|
+
assert.equal(parseContentDispositionFilename('attachment; filename="out.mp4"'), "out.mp4");
|
|
41
|
+
assert.equal(parseContentDispositionFilename("attachment; filename=plain.mp4"), "plain.mp4");
|
|
42
|
+
assert.equal(parseContentDispositionFilename(""), "");
|
|
43
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { sanitizeOutputFilename } from "./payload-guard.mjs";
|
|
6
|
+
|
|
7
|
+
// 로컬 미디어 스테이징 — TTS 오디오·스티치 매니페스트 같은 대용량 산출물은
|
|
8
|
+
// MCP 툴 결과(모델 컨텍스트)로 돌려주지 않고 파일 경로 핸들로 주고받는다.
|
|
9
|
+
|
|
10
|
+
const MIME_BY_EXT = new Map([
|
|
11
|
+
[".jpeg", "image/jpeg"],
|
|
12
|
+
[".jpg", "image/jpeg"],
|
|
13
|
+
[".json", "application/json"],
|
|
14
|
+
[".mp3", "audio/mpeg"],
|
|
15
|
+
[".mp4", "video/mp4"],
|
|
16
|
+
[".png", "image/png"],
|
|
17
|
+
[".wav", "audio/wav"],
|
|
18
|
+
[".webp", "image/webp"],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// 입력 파일 경로에 허용되는 확장자 — 프롬프트 인젝션으로 임의 파일(.env/.ssh/키)을
|
|
22
|
+
// 서버에 업로드하는 exfil 경로를 막는다. 카테고리별로 분리해 잘못된 종류도 거른다.
|
|
23
|
+
export const ALLOWED_IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
|
|
24
|
+
export const ALLOWED_AUDIO_EXTS = new Set([".mp3", ".wav"]);
|
|
25
|
+
export const ALLOWED_VIDEO_EXTS = new Set([".mp4"]);
|
|
26
|
+
|
|
27
|
+
// data: URL 변환 상한(원본 바이트) — 서버 JSON 바디 상한 25MB 를 base64 팽창(4/3) 후에도
|
|
28
|
+
// 넘지 않도록 잡는다. 25MB * 3/4 ≈ 18.75MB 에서 바디 여유분을 빼 17MB.
|
|
29
|
+
export const MAX_DATA_URL_SOURCE_BYTES = 17 * 1024 * 1024;
|
|
30
|
+
// 서버 readJsonBody 의 하드 상한. 요청 전체(여러 파일 합산) 인코딩 크기를 이 아래로 유지한다.
|
|
31
|
+
export const SERVER_JSON_BODY_LIMIT_BYTES = 25 * 1024 * 1024;
|
|
32
|
+
// 미디어 보존 기간 — render-job-store 와 동일하게 48시간.
|
|
33
|
+
export const MEDIA_TTL_MS = 48 * 60 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
export function mediaDir(stateDir) {
|
|
36
|
+
return path.join(stateDir, "media");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// base64 data: URL 의 인코딩 후 바이트 수 (헤더 포함 근사).
|
|
40
|
+
export function encodedDataUrlBytes(sourceBytes, mimeType = "application/octet-stream") {
|
|
41
|
+
return `data:${mimeType};base64,`.length + Math.ceil(Number(sourceBytes || 0) / 3) * 4;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 입력 경로 검증 — 확장자 화이트리스트 + 존재 + 상한. 위반 시 한국어 예외.
|
|
45
|
+
export function assertAllowedInputFile(filePath, allowedExts, { kind = "파일" } = {}) {
|
|
46
|
+
const resolved = String(filePath || "").trim();
|
|
47
|
+
if (!resolved) throw new Error(`${kind} 경로가 비어 있습니다.`);
|
|
48
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
49
|
+
if (!allowedExts.has(ext)) {
|
|
50
|
+
throw new Error(`허용되지 않은 ${kind} 확장자입니다: ${ext || "(없음)"} — 허용: ${[...allowedExts].join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
if (!existsSync(resolved)) throw new Error(`${kind}을(를) 찾을 수 없습니다: ${resolved}`);
|
|
53
|
+
const stats = statSync(resolved);
|
|
54
|
+
if (!stats.isFile()) throw new Error(`${kind}이(가) 일반 파일이 아닙니다: ${resolved}`);
|
|
55
|
+
if (stats.size > MAX_DATA_URL_SOURCE_BYTES) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`${kind}이(가) 너무 큽니다 (${Math.round(stats.size / 1024 / 1024)}MB > ${Math.round(MAX_DATA_URL_SOURCE_BYTES / 1024 / 1024)}MB): ${resolved}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return { ext, size: stats.size };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function saveMediaBuffer(stateDir, name, buffer, { contentType = "" } = {}) {
|
|
64
|
+
const dir = mediaDir(stateDir);
|
|
65
|
+
mkdirSync(dir, { recursive: true });
|
|
66
|
+
const safeName = sanitizeOutputFilename(name, "media");
|
|
67
|
+
// 동일 밀리초 병렬 저장의 덮어쓰기 방지 — uuid 접미 + wx(존재 시 throw).
|
|
68
|
+
const filePath = path.join(dir, `${Date.now()}-${randomUUID().slice(0, 8)}-${safeName}`);
|
|
69
|
+
writeFileSync(filePath, buffer, { flag: "wx", mode: 0o600 });
|
|
70
|
+
return { bytes: buffer.length, contentType, filePath };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function guessMimeType(filePath, fallback = "application/octet-stream") {
|
|
74
|
+
return MIME_BY_EXT.get(path.extname(String(filePath || "")).toLowerCase()) || fallback;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 파일 → data: URL. 스티치 요청의 ttsAudioUrl/bgmAudioUrl 형식. 오디오 확장자만 허용.
|
|
78
|
+
export function fileToDataUrl(filePath, { mimeType = "", allowedExts = ALLOWED_AUDIO_EXTS, kind = "오디오" } = {}) {
|
|
79
|
+
assertAllowedInputFile(filePath, allowedExts, { kind });
|
|
80
|
+
const mime = mimeType || guessMimeType(filePath);
|
|
81
|
+
return `data:${mime};base64,${readFileSync(filePath).toString("base64")}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 파일 → {name, mimeType, base64} — commerce plan 의 productImages / 씬 참조 이미지 형식.
|
|
85
|
+
export function fileToImagePayload(filePath) {
|
|
86
|
+
assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "이미지" });
|
|
87
|
+
return {
|
|
88
|
+
base64: readFileSync(filePath).toString("base64"),
|
|
89
|
+
mimeType: guessMimeType(filePath, "image/jpeg"),
|
|
90
|
+
name: path.basename(filePath),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 스티치 매니페스트 핸들 — JSON 저장 후 경로 반환, render_start 가 경로로 읽는다.
|
|
95
|
+
export function savePayloadHandle(stateDir, payload, label = "stitch") {
|
|
96
|
+
return saveMediaBuffer(
|
|
97
|
+
stateDir,
|
|
98
|
+
`${label}-payload.json`,
|
|
99
|
+
Buffer.from(JSON.stringify(payload), "utf8"),
|
|
100
|
+
{ contentType: "application/json" },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// render_start 가 넘겨받은 핸들만 읽도록 stateDir/media 안으로 제한한다.
|
|
105
|
+
export function readPayloadHandle(filePath, { stateDir = "" } = {}) {
|
|
106
|
+
if (stateDir) {
|
|
107
|
+
const resolved = path.resolve(String(filePath || ""));
|
|
108
|
+
const base = path.resolve(mediaDir(stateDir));
|
|
109
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
|
110
|
+
throw new Error("payloadPath는 stitch_timeline이 반환한 경로여야 합니다.");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 보존 기간이 지난 스테이징 미디어 정리 — 숨김 디렉터리에 GB 단위로 쌓이는 것 방지.
|
|
117
|
+
export function pruneOldMedia(stateDir, { now = Date.now() } = {}) {
|
|
118
|
+
const dir = mediaDir(stateDir);
|
|
119
|
+
let removed = 0;
|
|
120
|
+
try {
|
|
121
|
+
for (const entry of readdirSync(dir)) {
|
|
122
|
+
const filePath = path.join(dir, entry);
|
|
123
|
+
try {
|
|
124
|
+
if (now - statSync(filePath).mtimeMs > MEDIA_TTL_MS) {
|
|
125
|
+
rmSync(filePath, { force: true });
|
|
126
|
+
removed += 1;
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// 개별 파일 오류는 무시
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// media 디렉터리 없음 등 — 무시
|
|
134
|
+
}
|
|
135
|
+
return removed;
|
|
136
|
+
}
|