aimakeall-mcp 0.9.1 → 0.10.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 +5 -4
- package/lib/audio-duration.mjs +57 -0
- package/lib/audio-duration.test.mjs +89 -0
- package/lib/cloud-tools.mjs +78 -87
- package/lib/companion-client.mjs +29 -14
- package/lib/companion-client.test.mjs +71 -0
- package/lib/config.mjs +1 -1
- package/lib/generation-jobs.mjs +39 -0
- package/lib/generation-jobs.test.mjs +34 -0
- package/lib/media-store.mjs +8 -3
- package/lib/media-store.test.mjs +21 -1
- package/lib/render-job-store.mjs +107 -31
- package/lib/render-job-store.test.mjs +115 -12
- 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.10.0"],
|
|
20
20
|
"env": { "AIMAKEALL_PAT": "aio_pat_..." }
|
|
21
21
|
}
|
|
22
22
|
}
|
|
@@ -35,13 +35,14 @@ Claude Code·Codex 같은 MCP 클라이언트에서 자연어로 AImakeAll 영
|
|
|
35
35
|
## 기본 플로우 (쇼츠)
|
|
36
36
|
|
|
37
37
|
```
|
|
38
|
-
plan_shorts_video → (씬마다) generate_scene_image → [QC: 동봉 이미지 직접 확인 또는 verify_scene_image] → generate_scene_video_prompt → generate_scene_video → [QC: 동봉 프레임 확인 또는 verify_scene_video]
|
|
38
|
+
plan_shorts_video → (씬마다) generate_scene_image → get_generation_job(completed까지 조회) → [QC: 동봉 이미지 직접 확인 또는 verify_scene_image] → generate_scene_video_prompt → generate_scene_video → get_generation_job(completed까지 조회) → [QC: 동봉 프레임 확인 또는 verify_scene_video]
|
|
39
39
|
→ (보이스 미지정 시) recommend_voice → tts_narration_with_captions(하단자막·강조용) 또는 tts_narration → stitch_timeline → render_start → render_status(폴링) → render_result
|
|
40
40
|
→ ai_publish_metadata → publish_youtube
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
-
|
|
44
|
-
-
|
|
43
|
+
- 이미지·영상 생성은 즉시 `generationJobId`를 반환합니다. `get_generation_job`으로 상태를 조회하고 `completed`의 `imageUrl`/`videoUrl`을 다음 단계에 사용하세요. 연결이 끊겼을 때는 재생성 전에 `list_generation_jobs`로 기존 작업을 확인하세요. 완료 결과는 서버 DB에 48시간 보관되며 계정 소유자만 조회할 수 있습니다.
|
|
44
|
+
- 서버 자체가 생성 중 재시작되면 작업은 `interrupted`로 표시됩니다. 이미 접수된 `providerTasks`와 비용 원장을 확인해야 하며, 자동으로 유료 생성을 재실행하지 않습니다. 이 기능에는 0.10.0 대응 서버가 필요합니다.
|
|
45
|
+
- 렌더 잡은 `~/.aimakeall/render-jobs/` 아래 작업별 파일에 기록되어 프록시 재시작 후 `list_render_jobs`로 복구할 수 있습니다. 기존 `render-jobs.json`도 계속 읽을 수 있습니다.
|
|
45
46
|
- 대용량 산출물(TTS mp3, 스티치 매니페스트)은 파일 경로로 주고받아 모델 컨텍스트를 오염시키지 않습니다.
|
|
46
47
|
- `recommend_voice`는 주제·샘플 영상(유튜브)에 맞는 보이스를 자동 선정합니다 — 샘플 화자 분석은 컴패니언 앱이 실행 중일 때만 동작합니다.
|
|
47
48
|
- 자체검증(QC): 씬 이미지/영상 결과에 이미지·프레임 블록이 동봉되어 에이전트가 직접 보고 판정합니다. 비전 미지원 클라이언트는 `verify_scene_image`/`verify_scene_video`(서버 Gemini 판정)를 쓰세요 — 불합격 시 `regenerationHint`를 반영해 재생성.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { mp3DurationSec } from "./mp3-duration.mjs";
|
|
5
|
+
|
|
6
|
+
// WAV duration comes from sample frames, never an assumed MP3 bitrate. Walk RIFF
|
|
7
|
+
// chunks because encoders may include JUNK/LIST metadata before the audio data.
|
|
8
|
+
export function wavDurationSec(buffer) {
|
|
9
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 44
|
|
10
|
+
|| buffer.toString("ascii", 0, 4) !== "RIFF"
|
|
11
|
+
|| buffer.toString("ascii", 8, 12) !== "WAVE") return 0;
|
|
12
|
+
let format = null;
|
|
13
|
+
let dataBytes = 0;
|
|
14
|
+
for (let offset = 12; offset + 8 <= buffer.length;) {
|
|
15
|
+
const id = buffer.toString("ascii", offset, offset + 4);
|
|
16
|
+
const size = buffer.readUInt32LE(offset + 4);
|
|
17
|
+
const body = offset + 8;
|
|
18
|
+
if (id === "fmt " && size >= 16 && body + size <= buffer.length) {
|
|
19
|
+
let encoding = buffer.readUInt16LE(body);
|
|
20
|
+
if (encoding === 0xfffe && size >= 40) {
|
|
21
|
+
// WAVE_FORMAT_EXTENSIBLE PCM / IEEE_FLOAT subformat GUID.
|
|
22
|
+
const guidTail = buffer.subarray(body + 28, body + 40).toString("hex");
|
|
23
|
+
encoding = guidTail === "00001000800000aa00389b71" ? buffer.readUInt32LE(body + 24) : 0;
|
|
24
|
+
}
|
|
25
|
+
format = {
|
|
26
|
+
encoding,
|
|
27
|
+
channels: buffer.readUInt16LE(body + 2),
|
|
28
|
+
sampleRate: buffer.readUInt32LE(body + 4),
|
|
29
|
+
blockAlign: buffer.readUInt16LE(body + 12),
|
|
30
|
+
bits: buffer.readUInt16LE(body + 14),
|
|
31
|
+
};
|
|
32
|
+
} else if (id === "data") {
|
|
33
|
+
dataBytes += Math.min(size, buffer.length - body);
|
|
34
|
+
}
|
|
35
|
+
offset = body + size + (size % 2);
|
|
36
|
+
}
|
|
37
|
+
if (!format || !format.channels || !format.sampleRate) return 0;
|
|
38
|
+
const allowedBits = format.encoding === 1 ? [8, 16, 24, 32] : format.encoding === 3 ? [32, 64] : [];
|
|
39
|
+
if (!allowedBits.includes(format.bits)) return 0;
|
|
40
|
+
const blockAlign = format.channels * (format.bits / 8);
|
|
41
|
+
if (format.blockAlign !== blockAlign) return 0;
|
|
42
|
+
return Math.round((Math.floor(dataBytes / blockAlign) / format.sampleRate) * 100) / 100;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function audioDurationSecFromFile(filePath) {
|
|
46
|
+
const buffer = readFileSync(filePath);
|
|
47
|
+
if (path.extname(filePath).toLowerCase() === ".wav"
|
|
48
|
+
|| (buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WAVE")) {
|
|
49
|
+
const seconds = wavDurationSec(buffer);
|
|
50
|
+
if (seconds > 0) return seconds;
|
|
51
|
+
throw new Error("WAV 길이를 읽지 못했습니다. PCM WAV로 변환하거나 audioDurationSec에 실제 길이를 지정하세요.");
|
|
52
|
+
}
|
|
53
|
+
const seconds = mp3DurationSec(buffer);
|
|
54
|
+
if (seconds > 0) return seconds;
|
|
55
|
+
// Preserve the existing approximation only for unparseable MP3 inputs.
|
|
56
|
+
return Math.round((buffer.length * 8) / (128 * 1000) * 10) / 10;
|
|
57
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import { audioDurationSecFromFile, wavDurationSec } from "./audio-duration.mjs";
|
|
8
|
+
import { fileToDataUrl } from "./media-store.mjs";
|
|
9
|
+
|
|
10
|
+
function makeWav({ seconds = 10, rate = 16000, channels = 1, bits = 16, encoding = 1, metadata = false, extensible = false } = {}) {
|
|
11
|
+
const formatBytes = extensible ? 40 : 16;
|
|
12
|
+
const header = Buffer.alloc(20 + formatBytes);
|
|
13
|
+
header.write("RIFF");
|
|
14
|
+
header.write("WAVE", 8);
|
|
15
|
+
header.write("fmt ", 12);
|
|
16
|
+
header.writeUInt32LE(formatBytes, 16);
|
|
17
|
+
header.writeUInt16LE(extensible ? 0xfffe : encoding, 20);
|
|
18
|
+
header.writeUInt16LE(channels, 22);
|
|
19
|
+
header.writeUInt32LE(rate, 24);
|
|
20
|
+
const blockAlign = channels * bits / 8;
|
|
21
|
+
header.writeUInt32LE(rate * blockAlign, 28);
|
|
22
|
+
header.writeUInt16LE(blockAlign, 32);
|
|
23
|
+
header.writeUInt16LE(bits, 34);
|
|
24
|
+
if (extensible) {
|
|
25
|
+
header.writeUInt16LE(22, 36);
|
|
26
|
+
header.writeUInt16LE(bits, 38);
|
|
27
|
+
header.writeUInt32LE(encoding, 44);
|
|
28
|
+
Buffer.from("00001000800000aa00389b71", "hex").copy(header, 48);
|
|
29
|
+
}
|
|
30
|
+
const junk = metadata ? Buffer.from([0x4a, 0x55, 0x4e, 0x4b, 3, 0, 0, 0, 1, 2, 3, 0]) : Buffer.alloc(0);
|
|
31
|
+
const data = Buffer.alloc(8 + Math.round(seconds * rate) * blockAlign);
|
|
32
|
+
data.write("data");
|
|
33
|
+
data.writeUInt32LE(data.length - 8, 4);
|
|
34
|
+
const buffer = Buffer.concat([header, junk, data]);
|
|
35
|
+
buffer.writeUInt32LE(buffer.length - 8, 4);
|
|
36
|
+
return buffer;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("WAV 길이는 128kbps 추정이 아닌 실제 샘플 수다", () => {
|
|
40
|
+
for (const options of [
|
|
41
|
+
{},
|
|
42
|
+
{ rate: 48000, channels: 2, bits: 24, metadata: true },
|
|
43
|
+
{ rate: 44100, channels: 2, bits: 32, encoding: 3 },
|
|
44
|
+
{ rate: 48000, channels: 2, bits: 24, extensible: true },
|
|
45
|
+
{ rate: 8000, bits: 8 },
|
|
46
|
+
]) {
|
|
47
|
+
assert.equal(wavDurationSec(makeWav(options)), 10, JSON.stringify(options));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("WAV byteRate 자기신고 값으로 길이가 왜곡되지 않는다", () => {
|
|
52
|
+
const wav = makeWav();
|
|
53
|
+
wav.writeUInt32LE(16000, 28);
|
|
54
|
+
assert.equal(wavDurationSec(wav), 10);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("실제 WAV 파일 자동 길이와 data URL MIME을 유지", (t) => {
|
|
58
|
+
const dir = mkdtempSync(path.join(tmpdir(), "aio-wav-duration-"));
|
|
59
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
60
|
+
const file = path.join(dir, "ten-seconds.wav");
|
|
61
|
+
writeFileSync(file, makeWav());
|
|
62
|
+
assert.equal(audioDurationSecFromFile(file), 10);
|
|
63
|
+
assert.match(fileToDataUrl(file), /^data:audio\/wav;base64,/u);
|
|
64
|
+
const source = readFileSync(new URL("./cloud-tools.mjs", import.meta.url), "utf8");
|
|
65
|
+
assert.match(source, /import \{ audioDurationSecFromFile \} from "\.\/audio-duration\.mjs"/u);
|
|
66
|
+
assert.match(source, /ttsAudioPath \? audioDurationSecFromFile\(ttsAudioPath\)/u);
|
|
67
|
+
assert.match(source, /ttsAudioUrl: ttsAudioPath \? fileToDataUrl\(ttsAudioPath\) : ""/u);
|
|
68
|
+
assert.match(source, /bgmAudioUrl: bgmAudioPath \? fileToDataUrl\(bgmAudioPath\) : ""/u);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("손상·미지원 WAV는 MP3 비트레이트로 잘못 추정하지 않는다", (t) => {
|
|
72
|
+
const dir = mkdtempSync(path.join(tmpdir(), "aio-invalid-wav-"));
|
|
73
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
74
|
+
const file = path.join(dir, "unsupported.wav");
|
|
75
|
+
writeFileSync(file, makeWav({ encoding: 6 }));
|
|
76
|
+
assert.throws(() => audioDurationSecFromFile(file), /audioDurationSec/u);
|
|
77
|
+
assert.equal(wavDurationSec(Buffer.alloc(44)), 0);
|
|
78
|
+
assert.equal(wavDurationSec(makeWav().subarray(0, 30)), 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("MP3 길이 프레임 파싱은 유지된다", (t) => {
|
|
82
|
+
const dir = mkdtempSync(path.join(tmpdir(), "aio-mp3-duration-"));
|
|
83
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
84
|
+
const frame = Buffer.alloc(417);
|
|
85
|
+
Buffer.from([0xff, 0xfb, 0x90, 0]).copy(frame);
|
|
86
|
+
const file = path.join(dir, "audio.mp3");
|
|
87
|
+
writeFileSync(file, Buffer.concat(Array.from({ length: 100 }, () => frame)));
|
|
88
|
+
assert.equal(audioDurationSecFromFile(file), 2.61);
|
|
89
|
+
});
|
package/lib/cloud-tools.mjs
CHANGED
|
@@ -9,8 +9,8 @@ import { AimakeallApiError } from "./api-client.mjs";
|
|
|
9
9
|
import { extractCompanionAudio } from "./companion-client.mjs";
|
|
10
10
|
import {
|
|
11
11
|
ALLOWED_IMAGE_EXTS,
|
|
12
|
-
ALLOWED_VIDEO_EXTS,
|
|
13
12
|
assertAllowedInputFile,
|
|
13
|
+
assertAllowedVideoUpload,
|
|
14
14
|
encodedDataUrlBytes,
|
|
15
15
|
fileToDataUrl,
|
|
16
16
|
fileToImagePayload,
|
|
@@ -20,9 +20,10 @@ import {
|
|
|
20
20
|
savePayloadHandle,
|
|
21
21
|
SERVER_JSON_BODY_LIMIT_BYTES,
|
|
22
22
|
} from "./media-store.mjs";
|
|
23
|
-
import {
|
|
23
|
+
import { audioDurationSecFromFile } from "./audio-duration.mjs";
|
|
24
24
|
import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
|
|
25
25
|
import { createUsageEventId } from "./usage-event.mjs";
|
|
26
|
+
import { fetchGenerationJob, GENERATION_JOB_PATH, submitGenerationJob } from "./generation-jobs.mjs";
|
|
26
27
|
import { loudnessDbfsFromWav, silenceRatioFromWav } from "./wav-dsp.mjs";
|
|
27
28
|
import { trimWavToSeconds } from "./wav-trim.mjs";
|
|
28
29
|
|
|
@@ -74,18 +75,6 @@ function isSafePublicImageUrl(rawUrl) {
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
// 실측 mp3 길이(초) — 파싱 실패 시에만 CBR-128 근사로 폴백.
|
|
78
|
-
function audioDurationSecFromFile(filePath) {
|
|
79
|
-
try {
|
|
80
|
-
const real = mp3DurationSec(readFileSync(filePath));
|
|
81
|
-
if (real > 0) return real;
|
|
82
|
-
} catch {
|
|
83
|
-
// fall through
|
|
84
|
-
}
|
|
85
|
-
const size = statSync(filePath).size;
|
|
86
|
-
return Math.round((size * 8) / (128 * 1000) * 10) / 10;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
78
|
// aimakeall.com 클라우드 툴 — PAT 필요. 기획·씬 생성·TTS·SUNO·스티치·퍼블리시.
|
|
90
79
|
// 렌더는 여기 없다: 스티치가 만든 타임라인 페이로드를 render_start(로컬 컴패니언)로 넘긴다.
|
|
91
80
|
// 대용량 산출물(오디오·매니페스트·base64 이미지)은 모델 컨텍스트로 돌려주지 않고
|
|
@@ -191,6 +180,64 @@ function trimAnalysis(analysis) {
|
|
|
191
180
|
}
|
|
192
181
|
|
|
193
182
|
export function registerCloudTools(server, config, api) {
|
|
183
|
+
async function completedGenerationResult(job, includePreview) {
|
|
184
|
+
const payload = job.result || {};
|
|
185
|
+
const resultBody = { generationJobId: job.id, status: job.status, kind: job.kind,
|
|
186
|
+
model: payload.model, taskId: payload.taskId, providerTasks: job.providerTasks };
|
|
187
|
+
if (job.kind === "image") {
|
|
188
|
+
resultBody.imageUrl = payload.imageUrl || undefined;
|
|
189
|
+
let inlineImage = null;
|
|
190
|
+
if (!payload.imageUrl && payload.imageDataUrl) {
|
|
191
|
+
const match = String(payload.imageDataUrl).match(/^data:([^;]+);base64,(.+)$/s);
|
|
192
|
+
if (match) {
|
|
193
|
+
const ext = match[1].includes("png") ? "png" : "jpg";
|
|
194
|
+
resultBody.savedPath = saveMediaBuffer(config.stateDir, `scene-image.${ext}`, Buffer.from(match[2], "base64")).filePath;
|
|
195
|
+
if (includePreview && ALLOWED_INLINE_IMAGE_MIME.has(match[1].toLowerCase()) && match[2].length <= MAX_INLINE_IMAGE_BYTES * 4 / 3) {
|
|
196
|
+
inlineImage = { base64: match[2], mimeType: match[1].toLowerCase() };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} else if (includePreview && payload.imageUrl) {
|
|
200
|
+
inlineImage = await downloadImageAsBase64(payload.imageUrl);
|
|
201
|
+
}
|
|
202
|
+
resultBody.qc = inlineImage ? "동봉 이미지를 확인한 뒤 다음 단계로 진행하세요."
|
|
203
|
+
: "verify_scene_image로 검증하세요. imageUrl이 없으면 savedPath를 imagePath로 넘기세요.";
|
|
204
|
+
return inlineImage ? jsonWithImagesResult(resultBody, [inlineImage]) : jsonResult(resultBody);
|
|
205
|
+
}
|
|
206
|
+
Object.assign(resultBody, { videoUrl: payload.videoUrl, durationLabel: payload.durationLabel, resolvedRouteLabel: payload.resolvedRouteLabel });
|
|
207
|
+
let frames = [];
|
|
208
|
+
if (includePreview && payload.videoUrl) {
|
|
209
|
+
try {
|
|
210
|
+
const response = await api.request("/api/tracker/video/frames", {
|
|
211
|
+
method: "POST", body: { count: 3, maxWidth: 480, videoUrl: payload.videoUrl }, timeoutMs: 20_000,
|
|
212
|
+
});
|
|
213
|
+
if (response?.ok && Array.isArray(response.frames)) frames = response.frames;
|
|
214
|
+
} catch { /* A preview failure must not hide the generated video. */ }
|
|
215
|
+
}
|
|
216
|
+
resultBody.qc = frames.length ? "동봉 프레임을 확인한 뒤 stitch_timeline으로 진행하세요." : "verify_scene_video로 영상 품질을 확인하세요.";
|
|
217
|
+
return frames.length ? jsonWithImagesResult(resultBody, frames) : jsonResult(resultBody);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
server.tool(
|
|
221
|
+
"get_generation_job",
|
|
222
|
+
"generate_scene_image/video가 반환한 generationJobId의 상태·결과를 조회합니다. running이면 잠시 뒤 다시 조회하고, completed의 imageUrl/videoUrl로 다음 단계를 진행하세요. MCP를 재시작해도 같은 ID로 회수할 수 있습니다.",
|
|
223
|
+
{ generationJobId: z.string(), includePreview: z.boolean().optional().describe("이미지/대표 프레임 미리보기 포함. 미지정 시 생성 요청의 returnImage/returnFrames 설정 유지") },
|
|
224
|
+
wrapCloudHandler(config, async ({ generationJobId, includePreview }) => {
|
|
225
|
+
const job = await fetchGenerationJob(api, generationJobId);
|
|
226
|
+
if (job.status === "completed") return completedGenerationResult(job, includePreview ?? job.includePreview ?? true);
|
|
227
|
+
return textResult(JSON.stringify({ generationJobId: job.id, kind: job.kind, status: job.status,
|
|
228
|
+
providerTasks: job.providerTasks, error: job.error,
|
|
229
|
+
guidance: job.status === "running" ? "생성 중입니다. 5~10초 후 이 ID를 다시 조회하세요. 새로 생성하지 마세요." : "비용 원장과 providerTasks를 먼저 확인하세요. 실패·중단 작업을 자동 재생성하지 마세요." }, null, 2),
|
|
230
|
+
{ isError: job.status === "failed" || job.status === "interrupted" });
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
server.tool(
|
|
235
|
+
"list_generation_jobs",
|
|
236
|
+
"내 계정의 최근 이미지·영상 생성 작업 ID와 상태를 조회합니다. 연결이 끊기거나 MCP를 재시작했을 때 결과를 복구하고 중복 생성을 피하는 데 사용하세요.",
|
|
237
|
+
{},
|
|
238
|
+
wrapCloudHandler(config, async () => jsonResult(await api.request(GENERATION_JOB_PATH, { timeoutMs: 30_000 }))),
|
|
239
|
+
);
|
|
240
|
+
|
|
194
241
|
server.tool(
|
|
195
242
|
"get_usage",
|
|
196
243
|
"내 플랜과 기능별 렌더 쿼터 현황을 조회합니다. 생성 작업 전에 확인하세요.",
|
|
@@ -446,8 +493,9 @@ export function registerCloudTools(server, config, api) {
|
|
|
446
493
|
|
|
447
494
|
server.tool(
|
|
448
495
|
"generate_scene_image",
|
|
449
|
-
"씬
|
|
496
|
+
"씬 이미지 생성 작업을 접수하고 즉시 generationJobId를 반환합니다. get_generation_job으로 completed 결과의 imageUrl을 받은 뒤 씬 영상 입력에 쓰세요. 첫 씬/캐릭터/제품 이미지를 referenceImageUrls·referenceImagePaths로 전달하면 일관성 유지에 도움이 됩니다. 완료 이미지는 직접 확인하거나 verify_scene_image로 검증하세요.",
|
|
450
497
|
{
|
|
498
|
+
requestId: z.string().optional().describe("접수 응답 유실 후 같은 작업을 재확인할 때 기존 generationJobId를 지정합니다. 동일 ID·입력은 중복 생성하지 않습니다."),
|
|
451
499
|
prompt: z.string().describe("이미지 프롬프트 (plan 결과의 imagePrompt)"),
|
|
452
500
|
aspectRatio: z.string().optional().describe("기본 9:16"),
|
|
453
501
|
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"),
|
|
@@ -457,7 +505,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
457
505
|
referenceImageUrls: z.array(z.string()).max(6).optional().describe("참조 이미지 URL (씬1 앵커 등)"),
|
|
458
506
|
referenceImagePaths: z.array(z.string()).max(4).optional().describe("참조 이미지 로컬 경로 (제품 사진 등)"),
|
|
459
507
|
},
|
|
460
|
-
wrapCloudHandler(config, async ({ prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true, identityLock = "" }) => {
|
|
508
|
+
wrapCloudHandler(config, async ({ requestId, prompt, aspectRatio = "9:16", model = "gpt-image-2-beta", resolution = "1K", referenceImageUrls = [], referenceImagePaths = [], returnImage = true, identityLock = "" }) => {
|
|
461
509
|
// identity_lock 리터럴 부착 — 이미 포함돼 있으면 재부착하지 않는다(멱등: 프롬프트 캐시 보존).
|
|
462
510
|
const lock = String(identityLock || "").trim();
|
|
463
511
|
if (lock && !prompt.includes(lock)) prompt = `${prompt.trim()}, ${lock}`;
|
|
@@ -471,8 +519,9 @@ export function registerCloudTools(server, config, api) {
|
|
|
471
519
|
...referenceImageUrls.map((url, index) => ({ name: `ref-${index + 1}`, previewUrl: url })),
|
|
472
520
|
].slice(0, 6);
|
|
473
521
|
|
|
474
|
-
|
|
475
|
-
|
|
522
|
+
return jsonResult(await submitGenerationJob(api, {
|
|
523
|
+
kind: "image", includePreview: returnImage, requestId,
|
|
524
|
+
input: {
|
|
476
525
|
aspectRatio,
|
|
477
526
|
model,
|
|
478
527
|
prompt,
|
|
@@ -481,38 +530,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
481
530
|
resolution,
|
|
482
531
|
webSearchEnabled: true,
|
|
483
532
|
},
|
|
484
|
-
|
|
485
|
-
timeoutMs: 240_000,
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
let imageUrl = String(payload?.imageUrl || "");
|
|
489
|
-
let savedPath = "";
|
|
490
|
-
let inlineImage = null;
|
|
491
|
-
if (!imageUrl && payload?.imageDataUrl) {
|
|
492
|
-
// URL 없이 base64만 온 경우 — 컨텍스트로 돌려주지 않고 파일로 저장.
|
|
493
|
-
const match = String(payload.imageDataUrl).match(/^data:([^;]+);base64,(.+)$/s);
|
|
494
|
-
if (match) {
|
|
495
|
-
const ext = match[1].includes("png") ? "png" : "jpg";
|
|
496
|
-
const saved = saveMediaBuffer(config.stateDir, `scene-image.${ext}`, Buffer.from(match[2], "base64"));
|
|
497
|
-
savedPath = saved.filePath;
|
|
498
|
-
const inlineMime = String(match[1]).toLowerCase();
|
|
499
|
-
if (returnImage && ALLOWED_INLINE_IMAGE_MIME.has(inlineMime) && match[2].length <= MAX_INLINE_IMAGE_BYTES * 4 / 3) {
|
|
500
|
-
inlineImage = { base64: match[2], mimeType: inlineMime };
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
} else if (imageUrl && returnImage) {
|
|
504
|
-
inlineImage = await downloadImageAsBase64(imageUrl);
|
|
505
|
-
}
|
|
506
|
-
const resultBody = {
|
|
507
|
-
imageUrl: imageUrl || undefined,
|
|
508
|
-
model: payload?.model,
|
|
509
|
-
qc: inlineImage
|
|
510
|
-
? "동봉된 이미지를 직접 확인하세요: 캐릭터 앵커 일치·손가락/글자 왜곡·프롬프트 이행. 불합격이면 사유를 프롬프트에 반영해 재생성하세요. 이미지를 볼 수 없는 클라이언트라면 verify_scene_image 를 호출하세요."
|
|
511
|
-
: "이미지를 동봉하지 못했습니다 — verify_scene_image 로 서버측 검증을 수행하세요 (imageUrl 이 없으면 savedPath 를 imagePath 로 넘기세요).",
|
|
512
|
-
savedPath: savedPath || undefined,
|
|
513
|
-
taskId: payload?.taskId,
|
|
514
|
-
};
|
|
515
|
-
return inlineImage ? jsonWithImagesResult(resultBody, [inlineImage]) : jsonResult(resultBody);
|
|
533
|
+
}));
|
|
516
534
|
}),
|
|
517
535
|
);
|
|
518
536
|
|
|
@@ -559,8 +577,9 @@ export function registerCloudTools(server, config, api) {
|
|
|
559
577
|
|
|
560
578
|
server.tool(
|
|
561
579
|
"generate_scene_video",
|
|
562
|
-
"씬
|
|
580
|
+
"씬 영상 생성 작업을 접수하고 즉시 generationJobId를 반환합니다. get_generation_job으로 completed가 될 때까지 조회한 뒤 videoUrl을 stitch_timeline에 넣으세요. 접수만 된 상태에서 새로 생성하지 마세요.",
|
|
563
581
|
{
|
|
582
|
+
requestId: z.string().optional().describe("접수 응답 유실 후 동일 입력을 재접수할 때 기존 generationJobId를 지정합니다."),
|
|
564
583
|
prompt: z.string().describe("영상 프롬프트 (generate_scene_video_prompt의 videoPrompt)"),
|
|
565
584
|
sceneImageUrl: z.string().optional().describe("씬 이미지 URL (i2v 입력)"),
|
|
566
585
|
aspectRatio: z.string().optional().describe("기본 9:16"),
|
|
@@ -569,9 +588,10 @@ export function registerCloudTools(server, config, api) {
|
|
|
569
588
|
quality: z.string().optional().describe("기본 720p"),
|
|
570
589
|
returnFrames: z.boolean().optional().describe("기본 true — 결과 영상의 대표 프레임 3장을 직접 보고 QC 할 수 있게 이미지 블록으로 반환"),
|
|
571
590
|
},
|
|
572
|
-
wrapCloudHandler(config, async ({ prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p", returnFrames = true }) => {
|
|
573
|
-
|
|
574
|
-
|
|
591
|
+
wrapCloudHandler(config, async ({ requestId, prompt, sceneImageUrl = "", aspectRatio = "9:16", durationSec = 8, modelId = "kie-grok-imagine", quality = "720p", returnFrames = true }) => {
|
|
592
|
+
return jsonResult(await submitGenerationJob(api, {
|
|
593
|
+
kind: "video", includePreview: returnFrames, requestId,
|
|
594
|
+
input: {
|
|
575
595
|
aspectRatio,
|
|
576
596
|
durationLabel: `${Math.max(1, Math.ceil(durationSec))}s`,
|
|
577
597
|
generationPlan: {
|
|
@@ -587,35 +607,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
587
607
|
selectedVideoModel: { id: modelId },
|
|
588
608
|
videoGenerationSettings: { inputMode: sceneImageUrl ? "image" : "text", quality },
|
|
589
609
|
},
|
|
590
|
-
|
|
591
|
-
// 서버 최악 경로(KIE 900s + Evolink 폴백 3회×900s)를 넘겨 잡아 조기 abort 로 과금-미회수를 방지.
|
|
592
|
-
timeoutMs: 1_800_000,
|
|
593
|
-
});
|
|
594
|
-
// 대표 프레임 동봉(1층 QC) — 실패는 조용히 넘기고 verify_scene_video 안내로 대체.
|
|
595
|
-
let frames = [];
|
|
596
|
-
if (returnFrames && payload?.videoUrl) {
|
|
597
|
-
try {
|
|
598
|
-
const frameResp = await api.request("/api/tracker/video/frames", {
|
|
599
|
-
body: { count: 3, maxWidth: 480, videoUrl: payload.videoUrl },
|
|
600
|
-
method: "POST",
|
|
601
|
-
timeoutMs: 60_000, // 과금 완료된 결과를 오래 붙들지 않기 — 실패해도 verify 폴백 안내
|
|
602
|
-
});
|
|
603
|
-
if (frameResp?.ok && Array.isArray(frameResp?.frames)) frames = frameResp.frames;
|
|
604
|
-
} catch {
|
|
605
|
-
frames = [];
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
const resultBody = {
|
|
609
|
-
durationLabel: payload?.durationLabel,
|
|
610
|
-
model: payload?.model,
|
|
611
|
-
qc: frames.length
|
|
612
|
-
? "동봉된 대표 프레임 3장을 직접 확인하세요: 캐릭터 붕괴·모션 파탄·왜곡. 불합격이면 프롬프트를 교정해 재생성하세요. 프레임을 볼 수 없는 클라이언트라면 verify_scene_video 를 호출하세요."
|
|
613
|
-
: "프레임을 동봉하지 못했습니다 — verify_scene_video 로 서버측 검증을 수행하세요.",
|
|
614
|
-
resolvedRouteLabel: payload?.resolvedRouteLabel,
|
|
615
|
-
taskId: payload?.taskId,
|
|
616
|
-
videoUrl: payload?.videoUrl,
|
|
617
|
-
};
|
|
618
|
-
return frames.length ? jsonWithImagesResult(resultBody, frames) : jsonResult(resultBody);
|
|
610
|
+
}));
|
|
619
611
|
}),
|
|
620
612
|
);
|
|
621
613
|
|
|
@@ -798,7 +790,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
798
790
|
const body = {
|
|
799
791
|
aspectRatio,
|
|
800
792
|
audioDurationSec: resolvedAudioDurationSec,
|
|
801
|
-
bgmAudioUrl: bgmAudioPath ? fileToDataUrl(bgmAudioPath
|
|
793
|
+
bgmAudioUrl: bgmAudioPath ? fileToDataUrl(bgmAudioPath) : "",
|
|
802
794
|
bgmVolume,
|
|
803
795
|
featureKey,
|
|
804
796
|
muteVideoAudio,
|
|
@@ -816,7 +808,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
816
808
|
subtitleLines,
|
|
817
809
|
titleText,
|
|
818
810
|
transitionMode,
|
|
819
|
-
ttsAudioUrl: ttsAudioPath ? fileToDataUrl(ttsAudioPath
|
|
811
|
+
ttsAudioUrl: ttsAudioPath ? fileToDataUrl(ttsAudioPath) : "",
|
|
820
812
|
};
|
|
821
813
|
const response = await api.request("/api/tracker/commerce-video/stitch", {
|
|
822
814
|
body,
|
|
@@ -895,8 +887,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
895
887
|
notifySubscribers: z.boolean().optional().describe("기본 true"),
|
|
896
888
|
},
|
|
897
889
|
wrapCloudHandler(config, async ({ videoPath, title, description = "", tags = [], categoryId = "", visibility = "private", madeForKids = false, notifySubscribers = true }) => {
|
|
898
|
-
|
|
899
|
-
const stats = statSync(videoPath);
|
|
890
|
+
const stats = assertAllowedVideoUpload(videoPath);
|
|
900
891
|
// 메타데이터가 쿼리스트링을 타므로 한국어 설명의 퍼센트 인코딩 팽창으로 헤더 상한(16KB)을
|
|
901
892
|
// 넘지 않도록 보수적으로 제한한다.
|
|
902
893
|
const safeTitle = title.slice(0, 100);
|
package/lib/companion-client.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, createWriteStream, mkdirSync, openSync, renameSync, rmSync, statSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { Readable } from "node:stream";
|
|
4
5
|
import { pipeline } from "node:stream/promises";
|
|
@@ -157,20 +158,31 @@ export function parseContentDispositionFilename(headerValue) {
|
|
|
157
158
|
* 성공 시 컴패니언이 파일을 삭제(1회성)하므로 저장 성공 후에만 소비된 것으로 본다.
|
|
158
159
|
* 반환: { filePath, bytes } 또는 { error, status }
|
|
159
160
|
*/
|
|
160
|
-
//
|
|
161
|
-
|
|
161
|
+
// wx 로 최종 경로를 먼저 예약한다. 존재 검사만 하면 여러 프로세스가 같은
|
|
162
|
+
// 이름과 .part 파일을 골라 서로의 다운로드를 덮어쓴다.
|
|
163
|
+
function reserveOutputPath(dir, fileName) {
|
|
162
164
|
const ext = path.extname(fileName);
|
|
163
165
|
const base = fileName.slice(0, fileName.length - ext.length);
|
|
164
166
|
let candidate = path.join(dir, fileName);
|
|
165
167
|
let counter = 2;
|
|
166
|
-
while (
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
while (true) {
|
|
169
|
+
try {
|
|
170
|
+
closeSync(openSync(candidate, "wx", 0o600));
|
|
171
|
+
return candidate;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error?.code !== "EEXIST") throw error;
|
|
174
|
+
candidate = path.join(dir, `${base}-${counter}${ext}`);
|
|
175
|
+
counter += 1;
|
|
176
|
+
}
|
|
169
177
|
}
|
|
170
|
-
return candidate;
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
export async function downloadRenderOutput(companionUrl, jobId, outputDir, { suggestedName = "" } = {}) {
|
|
181
|
+
try {
|
|
182
|
+
mkdirSync(outputDir, { recursive: true });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return { error: `출력 폴더를 준비하지 못했습니다: ${String(error?.message || error)}`, status: 0 };
|
|
185
|
+
}
|
|
174
186
|
let response;
|
|
175
187
|
try {
|
|
176
188
|
response = await fetch(
|
|
@@ -193,17 +205,20 @@ export async function downloadRenderOutput(companionUrl, jobId, outputDir, { sug
|
|
|
193
205
|
const headerName = parseContentDispositionFilename(response.headers.get("content-disposition"));
|
|
194
206
|
let fileName = sanitizeOutputFilename(suggestedName || headerName, `render-${jobId}.mp4`);
|
|
195
207
|
if (!/\.[A-Za-z0-9]{1,5}$/u.test(fileName)) fileName += ".mp4"; // 확장자 없으면 mp4 보정
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const partPath = `${finalPath}.part`;
|
|
199
|
-
|
|
208
|
+
let finalPath = "";
|
|
209
|
+
let partPath = "";
|
|
200
210
|
try {
|
|
201
|
-
|
|
211
|
+
finalPath = reserveOutputPath(outputDir, fileName);
|
|
212
|
+
partPath = `${finalPath}.${randomUUID()}.part`;
|
|
213
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(partPath, { flags: "wx", mode: 0o600 }));
|
|
214
|
+
const bytes = statSync(partPath).size;
|
|
202
215
|
renameSync(partPath, finalPath);
|
|
216
|
+
return { bytes, filePath: finalPath };
|
|
203
217
|
} catch (error) {
|
|
204
218
|
// 중단 시 부분 파일 정리 — 잡은 다운로드 실패로 남아 재시도 가능(네트워크 abort 시 서버가 복구).
|
|
205
|
-
|
|
219
|
+
await response.body?.cancel().catch(() => {});
|
|
220
|
+
if (partPath) { try { rmSync(partPath, { force: true }); } catch { /* ignore */ } }
|
|
221
|
+
if (finalPath) { try { rmSync(finalPath, { force: true }); } catch { /* ignore */ } }
|
|
206
222
|
return { error: `출력 저장 중 오류: ${String(error?.message || error)} — render_status로 상태 확인 후 다시 시도하세요.`, status: 0 };
|
|
207
223
|
}
|
|
208
|
-
return { bytes: statSync(finalPath).size, filePath: finalPath };
|
|
209
224
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import { downloadRenderOutput } from "./companion-client.mjs";
|
|
8
|
+
|
|
9
|
+
function fixture(t) {
|
|
10
|
+
const dir = mkdtempSync(path.join(tmpdir(), "aio-download-test-"));
|
|
11
|
+
const originalFetch = globalThis.fetch;
|
|
12
|
+
t.after(() => {
|
|
13
|
+
globalThis.fetch = originalFetch;
|
|
14
|
+
rmSync(dir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("동시 같은 이름 다운로드는 독립 파일로 저장하고 기존 출력도 보존", async (t) => {
|
|
20
|
+
const dir = fixture(t);
|
|
21
|
+
writeFileSync(path.join(dir, "same.mp4"), "EXISTING");
|
|
22
|
+
globalThis.fetch = async (url) => {
|
|
23
|
+
const text = String(url).includes("job-a/") ? "AAAACCCC" : "BBBBDDDD";
|
|
24
|
+
return new Response(new ReadableStream({
|
|
25
|
+
start(controller) {
|
|
26
|
+
controller.enqueue(Buffer.from(text.slice(0, 4)));
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
controller.enqueue(Buffer.from(text.slice(4)));
|
|
29
|
+
controller.close();
|
|
30
|
+
}, 20);
|
|
31
|
+
},
|
|
32
|
+
}), { headers: { "content-disposition": 'attachment; filename="same.mp4"' } });
|
|
33
|
+
};
|
|
34
|
+
const results = await Promise.all([
|
|
35
|
+
downloadRenderOutput("http://mock.invalid", "job-a", dir),
|
|
36
|
+
downloadRenderOutput("http://mock.invalid", "job-b", dir),
|
|
37
|
+
]);
|
|
38
|
+
assert.ok(results.every((result) => !result.error), JSON.stringify(results));
|
|
39
|
+
assert.equal(new Set(results.map((result) => result.filePath)).size, 2);
|
|
40
|
+
assert.deepEqual(results.map((result) => readFileSync(result.filePath, "utf8")), ["AAAACCCC", "BBBBDDDD"]);
|
|
41
|
+
assert.deepEqual(results.map((result) => result.bytes), [8, 8]);
|
|
42
|
+
assert.equal(readFileSync(path.join(dir, "same.mp4"), "utf8"), "EXISTING");
|
|
43
|
+
assert.deepEqual(readdirSync(dir).sort(), ["same-2.mp4", "same-3.mp4", "same.mp4"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("중단된 다운로드는 자기 임시 파일·예약만 정리한다", async (t) => {
|
|
47
|
+
const dir = fixture(t);
|
|
48
|
+
writeFileSync(path.join(dir, "same.mp4"), "EXISTING");
|
|
49
|
+
globalThis.fetch = async () => new Response(new ReadableStream({
|
|
50
|
+
start(controller) {
|
|
51
|
+
controller.enqueue(Buffer.from("partial"));
|
|
52
|
+
setTimeout(() => controller.error(new Error("fixture connection lost")), 10);
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
const result = await downloadRenderOutput("http://mock.invalid", "job", dir, { suggestedName: "same.mp4" });
|
|
56
|
+
assert.match(result.error, /fixture connection lost/u);
|
|
57
|
+
assert.deepEqual(readdirSync(dir), ["same.mp4"]);
|
|
58
|
+
assert.equal(readFileSync(path.join(dir, "same.mp4"), "utf8"), "EXISTING");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("출력 폴더 오류는 일회성 다운로드 요청 전에 반환", async (t) => {
|
|
62
|
+
const dir = fixture(t);
|
|
63
|
+
const notDirectory = path.join(dir, "file");
|
|
64
|
+
writeFileSync(notDirectory, "keep");
|
|
65
|
+
let called = false;
|
|
66
|
+
globalThis.fetch = async () => { called = true; throw new Error("must not fetch"); };
|
|
67
|
+
const result = await downloadRenderOutput("http://mock.invalid", "job", notDirectory);
|
|
68
|
+
assert.ok(result.error);
|
|
69
|
+
assert.equal(called, false);
|
|
70
|
+
assert.equal(readFileSync(notDirectory, "utf8"), "keep");
|
|
71
|
+
});
|
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.10.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";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const GENERATION_JOB_PATH = "/api/tracker/mcp/generation-jobs";
|
|
4
|
+
const JOB_ID = /^[a-zA-Z0-9_-]{16,80}$/;
|
|
5
|
+
|
|
6
|
+
export function assertGenerationJobId(id) {
|
|
7
|
+
if (!JOB_ID.test(String(id || ""))) throw new Error("유효한 generationJobId가 필요합니다.");
|
|
8
|
+
return id;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Submission never waits for a provider. The server owns the long-running work;
|
|
12
|
+
// both its result and accepted task IDs remain queryable after MCP restarts.
|
|
13
|
+
export async function submitGenerationJob(api, { kind, input, requestId = randomUUID(), includePreview = true }) {
|
|
14
|
+
assertGenerationJobId(requestId);
|
|
15
|
+
try {
|
|
16
|
+
const response = await api.request(GENERATION_JOB_PATH, {
|
|
17
|
+
method: "POST", body: { id: requestId, kind, input, includePreview }, timeoutMs: 30_000,
|
|
18
|
+
});
|
|
19
|
+
if (response?.job?.id !== requestId) throw new Error("생성 작업 접수 응답을 확인할 수 없습니다.");
|
|
20
|
+
return { generationJobId: requestId, status: response.job.status, kind, includePreview,
|
|
21
|
+
existing: response.existing === true, nextTool: "get_generation_job",
|
|
22
|
+
guidance: "생성 작업을 접수했습니다. get_generation_job으로 조회해 completed 결과를 확인한 뒤 다음 단계로 진행하세요. 같은 장면을 다시 생성하지 마세요." };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (Number(error?.status) >= 400 && Number(error?.status) < 500) throw error;
|
|
25
|
+
// A lost acceptance response does not establish failure: don't submit a new
|
|
26
|
+
// paid job. The caller retains the ID, and list_generation_jobs also recovers it.
|
|
27
|
+
return { generationJobId: requestId, status: "unknown", kind, includePreview,
|
|
28
|
+
nextTool: "get_generation_job",
|
|
29
|
+
guidance: "접수 응답이 끊겼습니다. 이 작업 ID 또는 list_generation_jobs로 먼저 확인하세요. 새 작업을 재생성하지 마세요.",
|
|
30
|
+
error: String(error?.message || error) };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function fetchGenerationJob(api, generationJobId) {
|
|
35
|
+
const id = assertGenerationJobId(generationJobId);
|
|
36
|
+
const response = await api.request(`${GENERATION_JOB_PATH}/${encodeURIComponent(id)}`, { timeoutMs: 30_000 });
|
|
37
|
+
if (!response?.job || response.job.id !== id) throw new Error("생성 작업 상태 응답이 올바르지 않습니다.");
|
|
38
|
+
return response.job;
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { fetchGenerationJob, submitGenerationJob } from "./generation-jobs.mjs";
|
|
4
|
+
|
|
5
|
+
const id = "generation-client-test-0001";
|
|
6
|
+
test("submission returns a recoverable handle without waiting for a completed image or video", async () => {
|
|
7
|
+
const calls = [];
|
|
8
|
+
const api = { async request(route, options) { calls.push({ route, options }); return { job: { id, status: "running" } }; } };
|
|
9
|
+
const result = await submitGenerationJob(api, { requestId: id, kind: "image", input: { prompt: "rain" } });
|
|
10
|
+
assert.equal(result.generationJobId, id);
|
|
11
|
+
assert.equal(result.nextTool, "get_generation_job");
|
|
12
|
+
assert.equal(calls.length, 1);
|
|
13
|
+
assert.equal(calls[0].options.body.id, id);
|
|
14
|
+
assert.equal(calls[0].route, "/api/tracker/mcp/generation-jobs");
|
|
15
|
+
});
|
|
16
|
+
test("a lost acceptance response preserves the ID and never resubmits paid work automatically", async () => {
|
|
17
|
+
let attempts = 0;
|
|
18
|
+
const api = { async request() { attempts++; throw new TypeError("fetch failed"); } };
|
|
19
|
+
const result = await submitGenerationJob(api, { requestId: id, kind: "video", input: {} });
|
|
20
|
+
assert.equal(result.status, "unknown");
|
|
21
|
+
assert.equal(result.generationJobId, id);
|
|
22
|
+
assert.equal(attempts, 1);
|
|
23
|
+
});
|
|
24
|
+
test("authorization and capacity rejections remain actionable errors", async () => {
|
|
25
|
+
for (const status of [401, 403, 409, 429]) {
|
|
26
|
+
const error = Object.assign(new Error("rejected"), { status });
|
|
27
|
+
await assert.rejects(submitGenerationJob({ request: async () => { throw error; } }, { requestId: id, kind: "image", input: {} }), error);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
test("polls retrieve completed results, check response identity and reject path injection", async () => {
|
|
31
|
+
assert.equal((await fetchGenerationJob({ request: async () => ({ job: { id, status: "completed", result: { videoUrl: "test" } } }) }, id)).result.videoUrl, "test");
|
|
32
|
+
await assert.rejects(fetchGenerationJob({ request: async () => ({ job: { id: "another" } }) }, id));
|
|
33
|
+
await assert.rejects(fetchGenerationJob({ request: async () => { throw new Error("must not call"); } }, "../background-jobs"), /generationJobId/);
|
|
34
|
+
});
|
package/lib/media-store.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export function encodedDataUrlBytes(sourceBytes, mimeType = "application/octet-s
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// 입력 경로 검증 — 확장자 화이트리스트 + 존재 + 상한. 위반 시 한국어 예외.
|
|
45
|
-
export function assertAllowedInputFile(filePath, allowedExts, { kind = "파일" } = {}) {
|
|
45
|
+
export function assertAllowedInputFile(filePath, allowedExts, { kind = "파일", maxBytes = MAX_DATA_URL_SOURCE_BYTES } = {}) {
|
|
46
46
|
const resolved = String(filePath || "").trim();
|
|
47
47
|
if (!resolved) throw new Error(`${kind} 경로가 비어 있습니다.`);
|
|
48
48
|
const ext = path.extname(resolved).toLowerCase();
|
|
@@ -52,14 +52,19 @@ export function assertAllowedInputFile(filePath, allowedExts, { kind = "파일"
|
|
|
52
52
|
if (!existsSync(resolved)) throw new Error(`${kind}을(를) 찾을 수 없습니다: ${resolved}`);
|
|
53
53
|
const stats = statSync(resolved);
|
|
54
54
|
if (!stats.isFile()) throw new Error(`${kind}이(가) 일반 파일이 아닙니다: ${resolved}`);
|
|
55
|
-
if (stats.size >
|
|
55
|
+
if (stats.size > maxBytes) {
|
|
56
56
|
throw new Error(
|
|
57
|
-
`${kind}이(가) 너무 큽니다 (${Math.round(stats.size / 1024 / 1024)}MB > ${Math.round(
|
|
57
|
+
`${kind}이(가) 너무 큽니다 (${Math.round(stats.size / 1024 / 1024)}MB > ${Math.round(maxBytes / 1024 / 1024)}MB): ${resolved}`,
|
|
58
58
|
);
|
|
59
59
|
}
|
|
60
60
|
return { ext, size: stats.size };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// raw body 업로드는 base64/JSON 상한과 무관하다. 확장자·일반 파일 검증은 유지한다.
|
|
64
|
+
export function assertAllowedVideoUpload(filePath) {
|
|
65
|
+
return assertAllowedInputFile(filePath, ALLOWED_VIDEO_EXTS, { kind: "영상 파일", maxBytes: Infinity });
|
|
66
|
+
}
|
|
67
|
+
|
|
63
68
|
export function saveMediaBuffer(stateDir, name, buffer, { contentType = "" } = {}) {
|
|
64
69
|
const dir = mediaDir(stateDir);
|
|
65
70
|
mkdirSync(dir, { recursive: true });
|
package/lib/media-store.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync, utimesSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, utimesSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test, { after } from "node:test";
|
|
@@ -7,6 +7,7 @@ import test, { after } from "node:test";
|
|
|
7
7
|
import {
|
|
8
8
|
ALLOWED_IMAGE_EXTS,
|
|
9
9
|
assertAllowedInputFile,
|
|
10
|
+
assertAllowedVideoUpload,
|
|
10
11
|
encodedDataUrlBytes,
|
|
11
12
|
fileToDataUrl,
|
|
12
13
|
fileToImagePayload,
|
|
@@ -55,6 +56,25 @@ test("크기 상한 초과 파일은 거부", () => {
|
|
|
55
56
|
assert.throws(() => fileToDataUrl(big), /너무 큽니다/u);
|
|
56
57
|
});
|
|
57
58
|
|
|
59
|
+
test("스트리밍 MP4는 17MiB를 넘어도 허용하되 파일 종류 검증은 유지", () => {
|
|
60
|
+
const big = saveMediaBuffer(scratch, "large.mp4", Buffer.alloc(MAX_DATA_URL_SOURCE_BYTES + 1)).filePath;
|
|
61
|
+
assert.equal(assertAllowedVideoUpload(big).size, MAX_DATA_URL_SOURCE_BYTES + 1);
|
|
62
|
+
assert.throws(() => assertAllowedVideoUpload(join(scratch, "id_rsa")), /허용되지 않은/u);
|
|
63
|
+
assert.throws(() => assertAllowedVideoUpload(join(scratch, "missing.mp4")), /찾을 수 없습니다/u);
|
|
64
|
+
assert.throws(() => fileToDataUrl(big), /허용되지 않은/u);
|
|
65
|
+
// The binary upload exception must never lift the data-URL audio/image limit.
|
|
66
|
+
const audio = saveMediaBuffer(scratch, "large.wav", Buffer.alloc(MAX_DATA_URL_SOURCE_BYTES + 1)).filePath;
|
|
67
|
+
assert.throws(() => fileToDataUrl(audio), /너무 큽니다/u);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("publish_youtube는 스트리밍 전용 검사기를 사용한다", () => {
|
|
71
|
+
const source = readFileSync(new URL("./cloud-tools.mjs", import.meta.url), "utf8");
|
|
72
|
+
const publish = source.slice(source.indexOf('"publish_youtube"'), source.indexOf('"write_script"'));
|
|
73
|
+
assert.match(publish, /const stats = assertAllowedVideoUpload\(videoPath\)/u);
|
|
74
|
+
assert.doesNotMatch(publish, /assertAllowedInputFile\(/u);
|
|
75
|
+
assert.match(publish, /requestStreamUpload\(/u);
|
|
76
|
+
});
|
|
77
|
+
|
|
58
78
|
test("이미지 페이로드 변환 — {name, mimeType, base64}", () => {
|
|
59
79
|
const imgPath = join(scratch, "product.png");
|
|
60
80
|
writeFileSync(imgPath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
package/lib/render-job-store.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
// 렌더 잡 영속화 — npx 프록시가 재시작돼도 jobId 를 잃지 않도록
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// 잡별 디렉터리로 분리해 여러 프록시가 공유 배열을 read-modify-write 하며
|
|
7
|
+
// 다른 잡을 잃어버리지 않게 한다. 구버전 render-jobs.json 은 계속 읽되 덮어쓰지 않는다.
|
|
8
|
+
// 같은 잡의 서로 다른 필드도 별도 원자 파일로 저장해 동시 패치를 보존한다.
|
|
8
9
|
|
|
9
10
|
const MAX_ENTRIES = 50;
|
|
10
11
|
const ENTRY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -13,55 +14,130 @@ export function renderJobStorePath(stateDir) {
|
|
|
13
14
|
return path.join(stateDir, "render-jobs.json");
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
function
|
|
17
|
+
export function renderJobRecordsDir(stateDir) {
|
|
18
|
+
return path.join(stateDir, "render-jobs");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function digest(value) {
|
|
22
|
+
return createHash("sha256").update(String(value)).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function jobDirectory(stateDir, jobId) {
|
|
26
|
+
return path.join(renderJobRecordsDir(stateDir), digest(jobId));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readJson(filePath) {
|
|
17
30
|
try {
|
|
18
|
-
|
|
19
|
-
return Array.isArray(parsed?.jobs) ? parsed.jobs : [];
|
|
31
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
20
32
|
} catch {
|
|
21
|
-
return
|
|
33
|
+
return null;
|
|
22
34
|
}
|
|
23
35
|
}
|
|
24
36
|
|
|
25
|
-
function
|
|
37
|
+
function readDirectory(dir) {
|
|
38
|
+
try { return readdirSync(dir); } catch { return []; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeJsonAtomic(filePath, value) {
|
|
26
42
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
44
|
+
try {
|
|
45
|
+
writeFileSync(tmpPath, `${JSON.stringify(value)}\n`, { flag: "wx", mode: 0o600 });
|
|
46
|
+
renameSync(tmpPath, filePath);
|
|
47
|
+
} finally {
|
|
48
|
+
rmSync(tmpPath, { force: true });
|
|
49
|
+
}
|
|
32
50
|
}
|
|
33
51
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
const IDENTITY_FIELDS = new Set(["jobId", "createdAt", "createdAtMs"]);
|
|
53
|
+
|
|
54
|
+
function readStoredJob(dir, fallback = null) {
|
|
55
|
+
const record = readJson(path.join(dir, "record.json"));
|
|
56
|
+
let job = record?.job?.jobId ? record.job : fallback;
|
|
57
|
+
if (!job?.jobId) return null;
|
|
58
|
+
for (const name of readDirectory(dir)) {
|
|
59
|
+
if (!/^[a-f0-9]{64}\.field\.json$/u.test(name)) continue;
|
|
60
|
+
const patch = readJson(path.join(dir, name));
|
|
61
|
+
if (typeof patch?.field !== "string" || IDENTITY_FIELDS.has(patch.field)) continue;
|
|
62
|
+
job = { ...job, [patch.field]: patch.value };
|
|
63
|
+
}
|
|
64
|
+
return { job, order: String(record?.order || "") };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readAllJobs(stateDir) {
|
|
68
|
+
const legacy = readJson(renderJobStorePath(stateDir));
|
|
69
|
+
const entries = new Map();
|
|
70
|
+
for (const job of Array.isArray(legacy?.jobs) ? legacy.jobs : []) {
|
|
71
|
+
if (job?.jobId) entries.set(String(job.jobId), { job, order: "" });
|
|
72
|
+
}
|
|
73
|
+
for (const name of readDirectory(renderJobRecordsDir(stateDir))) {
|
|
74
|
+
if (!/^[a-f0-9]{64}$/u.test(name)) continue;
|
|
75
|
+
const dir = path.join(renderJobRecordsDir(stateDir), name);
|
|
76
|
+
const record = readJson(path.join(dir, "record.json"));
|
|
77
|
+
if (!record?.job?.jobId || digest(record.job.jobId) !== name) continue;
|
|
78
|
+
const entry = readStoredJob(dir);
|
|
79
|
+
if (entry) entries.set(String(entry.job.jobId), entry);
|
|
80
|
+
}
|
|
81
|
+
return [...entries.values()];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isRecent(job, now) {
|
|
85
|
+
return Boolean(job?.jobId) && now - (Number(job.createdAtMs) || 0) < ENTRY_TTL_MS;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pruneExpiredRecords(stateDir, now) {
|
|
89
|
+
for (const name of readDirectory(renderJobRecordsDir(stateDir))) {
|
|
90
|
+
if (!/^[a-f0-9]{64}$/u.test(name)) continue;
|
|
91
|
+
const dir = path.join(renderJobRecordsDir(stateDir), name);
|
|
92
|
+
const record = readJson(path.join(dir, "record.json"));
|
|
93
|
+
if (record?.job?.jobId && !isRecent(record.job, now)) {
|
|
94
|
+
rmSync(dir, { force: true, recursive: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
38
97
|
}
|
|
39
98
|
|
|
40
99
|
export function recordRenderJob(stateDir, { jobId, projectTitle = "", statusUrl = "", outputUrl = "" }, now = Date.now()) {
|
|
41
|
-
const
|
|
42
|
-
const jobs = pruneEntries(readEntries(filePath), now);
|
|
43
|
-
jobs.push({
|
|
100
|
+
const job = {
|
|
44
101
|
createdAt: new Date(now).toISOString(),
|
|
45
102
|
createdAtMs: now,
|
|
46
103
|
jobId: String(jobId || ""),
|
|
47
104
|
outputUrl: String(outputUrl || ""),
|
|
48
105
|
projectTitle: String(projectTitle || ""),
|
|
49
106
|
statusUrl: String(statusUrl || ""),
|
|
107
|
+
};
|
|
108
|
+
if (!job.jobId) throw new Error("jobId가 필요합니다.");
|
|
109
|
+
pruneExpiredRecords(stateDir, now);
|
|
110
|
+
writeJsonAtomic(path.join(jobDirectory(stateDir, job.jobId), "record.json"), {
|
|
111
|
+
job,
|
|
112
|
+
order: process.hrtime.bigint().toString().padStart(24, "0"),
|
|
50
113
|
});
|
|
51
|
-
|
|
52
|
-
return jobs[jobs.length - 1];
|
|
114
|
+
return job;
|
|
53
115
|
}
|
|
54
116
|
|
|
55
117
|
export function listRenderJobs(stateDir, now = Date.now()) {
|
|
56
|
-
return
|
|
118
|
+
return readAllJobs(stateDir)
|
|
119
|
+
.filter(({ job }) => isRecent(job, now))
|
|
120
|
+
.sort((left, right) => Number(left.job.createdAtMs) - Number(right.job.createdAtMs)
|
|
121
|
+
|| left.order.localeCompare(right.order))
|
|
122
|
+
.slice(-MAX_ENTRIES)
|
|
123
|
+
.reverse()
|
|
124
|
+
.map(({ job }) => job);
|
|
57
125
|
}
|
|
58
126
|
|
|
59
127
|
export function updateRenderJob(stateDir, jobId, patch = {}, now = Date.now()) {
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
128
|
+
const id = String(jobId || "");
|
|
129
|
+
const legacy = readJson(renderJobStorePath(stateDir));
|
|
130
|
+
const fallback = (Array.isArray(legacy?.jobs) ? legacy.jobs : []).find((job) => job?.jobId === id);
|
|
131
|
+
const dir = jobDirectory(stateDir, id);
|
|
132
|
+
const entry = readStoredJob(dir, fallback);
|
|
133
|
+
if (!entry || !isRecent(entry.job, now)) return null;
|
|
134
|
+
// Migrate only this legacy job; leave the original shared file untouched.
|
|
135
|
+
if (!readJson(path.join(dir, "record.json"))?.job?.jobId) {
|
|
136
|
+
writeJsonAtomic(path.join(dir, "record.json"), { job: entry.job, order: entry.order });
|
|
137
|
+
}
|
|
138
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
139
|
+
if (IDENTITY_FIELDS.has(field)) continue;
|
|
140
|
+
writeJsonAtomic(path.join(dir, `${digest(field)}.field.json`), { field, value });
|
|
141
|
+
}
|
|
142
|
+
return readStoredJob(dir)?.job || null;
|
|
67
143
|
}
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
|
-
import test
|
|
6
|
+
import test from "node:test";
|
|
6
7
|
|
|
7
8
|
import {
|
|
8
9
|
listRenderJobs,
|
|
9
10
|
recordRenderJob,
|
|
11
|
+
renderJobRecordsDir,
|
|
10
12
|
renderJobStorePath,
|
|
11
13
|
updateRenderJob,
|
|
12
14
|
} from "./render-job-store.mjs";
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
function fixture(t) {
|
|
17
|
+
const stateDir = mkdtempSync(join(tmpdir(), "aio-mcp-jobstore-"));
|
|
18
|
+
t.after(() => rmSync(stateDir, { force: true, recursive: true }));
|
|
19
|
+
return stateDir;
|
|
20
|
+
}
|
|
16
21
|
|
|
17
|
-
test("기록 → 목록(최신순) → 갱신 roundtrip", () => {
|
|
22
|
+
test("기록 → 목록(최신순) → 갱신 roundtrip", (t) => {
|
|
23
|
+
const stateDir = fixture(t);
|
|
18
24
|
recordRenderJob(stateDir, { jobId: "job-1", projectTitle: "첫번째" });
|
|
19
25
|
recordRenderJob(stateDir, { jobId: "job-2", projectTitle: "두번째", statusUrl: "/api/moviepy/render-jobs/job-2" });
|
|
20
26
|
|
|
@@ -29,18 +35,115 @@ test("기록 → 목록(최신순) → 갱신 roundtrip", () => {
|
|
|
29
35
|
assert.equal(updateRenderJob(stateDir, "missing", { status: "x" }), null);
|
|
30
36
|
});
|
|
31
37
|
|
|
32
|
-
test("24시간 지난 항목은 정리된다", () => {
|
|
38
|
+
test("24시간 지난 항목은 정리된다", (t) => {
|
|
39
|
+
const stateDir = fixture(t);
|
|
33
40
|
const now = Date.now();
|
|
34
41
|
recordRenderJob(stateDir, { jobId: "old-job" }, now - 25 * 60 * 60 * 1000);
|
|
35
42
|
const jobs = listRenderJobs(stateDir, now);
|
|
36
43
|
assert.equal(jobs.some((job) => job.jobId === "old-job"), false);
|
|
44
|
+
recordRenderJob(stateDir, { jobId: "new-job" }, now);
|
|
45
|
+
assert.equal(readdirSync(renderJobRecordsDir(stateDir)).length, 1);
|
|
37
46
|
});
|
|
38
47
|
|
|
39
|
-
test("손상된
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
rmSync(filePath);
|
|
48
|
+
test("손상된 legacy 파일이나 잡 레코드는 다른 잡을 지우지 않는다", (t) => {
|
|
49
|
+
const stateDir = fixture(t);
|
|
50
|
+
writeFileSync(renderJobStorePath(stateDir), "invalid json");
|
|
43
51
|
assert.deepEqual(listRenderJobs(stateDir), []);
|
|
44
|
-
recordRenderJob(stateDir, { jobId: "
|
|
45
|
-
|
|
52
|
+
recordRenderJob(stateDir, { jobId: "broken" });
|
|
53
|
+
const firstDir = readdirSync(renderJobRecordsDir(stateDir))[0];
|
|
54
|
+
writeFileSync(join(renderJobRecordsDir(stateDir), firstDir, "record.json"), "invalid json");
|
|
55
|
+
recordRenderJob(stateDir, { jobId: "healthy" });
|
|
56
|
+
assert.deepEqual(listRenderJobs(stateDir).map((job) => job.jobId), ["healthy"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("legacy 목록을 보존하면서 새 저장소에 갱신하고 중복 없이 병합", (t) => {
|
|
60
|
+
const stateDir = fixture(t);
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const legacy = JSON.stringify({ jobs: [
|
|
63
|
+
{ jobId: "legacy-1", createdAtMs: now - 2, projectTitle: "old title" },
|
|
64
|
+
{ jobId: "legacy-2", createdAtMs: now - 1 },
|
|
65
|
+
] });
|
|
66
|
+
writeFileSync(renderJobStorePath(stateDir), legacy);
|
|
67
|
+
recordRenderJob(stateDir, { jobId: "new" }, now);
|
|
68
|
+
updateRenderJob(stateDir, "legacy-1", { status: "downloaded", filePath: "/tmp/kept.mp4" }, now);
|
|
69
|
+
const jobs = listRenderJobs(stateDir, now);
|
|
70
|
+
assert.equal(jobs.length, 3);
|
|
71
|
+
assert.equal(jobs.find((job) => job.jobId === "legacy-1").filePath, "/tmp/kept.mp4");
|
|
72
|
+
assert.equal(jobs.find((job) => job.jobId === "legacy-1").projectTitle, "old title");
|
|
73
|
+
assert.equal(readFileSync(renderJobStorePath(stateDir), "utf8"), legacy);
|
|
74
|
+
rmSync(renderJobStorePath(stateDir));
|
|
75
|
+
assert.equal(listRenderJobs(stateDir, now).find((job) => job.jobId === "legacy-1").status, "downloaded");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("최신 50개 상한과 jobId 경로 격리를 유지", (t) => {
|
|
79
|
+
const stateDir = fixture(t);
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
for (let i = 0; i < 55; i++) recordRenderJob(stateDir, { jobId: `../job-${i}` }, now + i);
|
|
82
|
+
const jobs = listRenderJobs(stateDir, now + 55);
|
|
83
|
+
assert.equal(jobs.length, 50);
|
|
84
|
+
assert.equal(jobs[0].jobId, "../job-54");
|
|
85
|
+
assert.equal(jobs.at(-1).jobId, "../job-5");
|
|
86
|
+
assert.ok(readdirSync(renderJobRecordsDir(stateDir)).every((name) => /^[a-f0-9]{64}$/u.test(name)));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Start independent Node processes at the same barrier, not sequential promises
|
|
90
|
+
// in one event loop (the old synchronous read-modify-write passed those tests).
|
|
91
|
+
async function concurrentWorkers(t, stateDir, operation, count = 8) {
|
|
92
|
+
const moduleUrl = new URL("./render-job-store.mjs", import.meta.url).href;
|
|
93
|
+
const workers = Array.from({ length: count }, (_, index) => {
|
|
94
|
+
const source = `
|
|
95
|
+
import { recordRenderJob, updateRenderJob } from ${JSON.stringify(moduleUrl)};
|
|
96
|
+
const stateDir = ${JSON.stringify(stateDir)};
|
|
97
|
+
const worker = ${index};
|
|
98
|
+
process.stdin.once('data', () => {
|
|
99
|
+
${operation}
|
|
100
|
+
process.stdin.destroy();
|
|
101
|
+
});
|
|
102
|
+
process.stdout.write('ready');
|
|
103
|
+
`;
|
|
104
|
+
const child = spawn(process.execPath, ["--input-type=module", "--eval", source], { stdio: ["pipe", "pipe", "pipe"] });
|
|
105
|
+
t.after(() => { if (child.exitCode === null) child.kill(); });
|
|
106
|
+
let stderr = "";
|
|
107
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
108
|
+
const ready = new Promise((resolve, reject) => {
|
|
109
|
+
child.stdout.once("data", resolve);
|
|
110
|
+
child.once("error", reject);
|
|
111
|
+
child.once("exit", (code) => { if (code !== 0) reject(new Error(stderr || `worker exit ${code}`)); });
|
|
112
|
+
});
|
|
113
|
+
const done = new Promise((resolve, reject) => {
|
|
114
|
+
child.once("error", reject);
|
|
115
|
+
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(stderr || `worker exit ${code}`)));
|
|
116
|
+
});
|
|
117
|
+
return { child, ready, done };
|
|
118
|
+
});
|
|
119
|
+
await Promise.all(workers.map(({ ready }) => ready));
|
|
120
|
+
for (const { child } of workers) child.stdin.end("go");
|
|
121
|
+
await Promise.all(workers.map(({ done }) => done));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test("8개 프로세스가 동시에 기록·갱신해도 모든 잡이 보존된다", async (t) => {
|
|
125
|
+
const stateDir = fixture(t);
|
|
126
|
+
await concurrentWorkers(t, stateDir, `
|
|
127
|
+
for (let i = 0; i < 4; i++) {
|
|
128
|
+
const jobId = 'worker-' + worker + '-job-' + i;
|
|
129
|
+
recordRenderJob(stateDir, { jobId });
|
|
130
|
+
updateRenderJob(stateDir, jobId, { status: 'succeeded' });
|
|
131
|
+
}
|
|
132
|
+
`);
|
|
133
|
+
const jobs = listRenderJobs(stateDir);
|
|
134
|
+
assert.equal(jobs.length, 32);
|
|
135
|
+
assert.equal(new Set(jobs.map((job) => job.jobId)).size, 32);
|
|
136
|
+
assert.ok(jobs.every((job) => job.status === "succeeded"));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("같은 잡의 서로 다른 필드에 대한 프로세스 동시 패치를 보존", async (t) => {
|
|
140
|
+
const stateDir = fixture(t);
|
|
141
|
+
recordRenderJob(stateDir, { jobId: "shared" });
|
|
142
|
+
await concurrentWorkers(t, stateDir, `
|
|
143
|
+
updateRenderJob(stateDir, 'shared', { ['field-' + worker]: 'value-' + worker });
|
|
144
|
+
`);
|
|
145
|
+
const [job] = listRenderJobs(stateDir);
|
|
146
|
+
for (let i = 0; i < 8; i++) assert.equal(job[`field-${i}`], `value-${i}`);
|
|
147
|
+
updateRenderJob(stateDir, "shared", { status: "downloaded", filePath: "/tmp/shared.mp4", jobId: "other" });
|
|
148
|
+
assert.equal(listRenderJobs(stateDir)[0].jobId, "shared");
|
|
46
149
|
});
|