aimakeall-mcp 0.9.0 → 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 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.9.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
- - 씬 영상 생성은 동기 호출로 최대 15분 걸립니다 — MCP 클라이언트의 툴 타임아웃(`MCP_TIMEOUT`)을 충분히 잡아주세요.
44
- - 렌더 잡은 `~/.aimakeall/render-jobs.json`에 기록되어 프록시 재시작 후 `list_render_jobs`로 복구할 수 있습니다.
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
+ });
@@ -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 { mp3DurationSec } from "./mp3-duration.mjs";
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
  "내 플랜과 기능별 렌더 쿼터 현황을 조회합니다. 생성 작업 전에 확인하세요.",
@@ -254,7 +301,7 @@ export function registerCloudTools(server, config, api) {
254
301
  tone: z.string().optional().describe("기본 '자동 추천'"),
255
302
  sceneCount: z.number().int().min(1).max(10).optional().describe("씬 수. 생략하면 channelFingerprint 의 목표 길이로 자동 산출(지문도 없으면 3)"),
256
303
  channelFingerprint: z.object({
257
- sampleSize: z.number().optional(),
304
+ sampleSize: z.number().describe("표본 수 — 서버는 이 값이 0보다 커야 지문을 규격으로 인정한다(없으면 조용히 무시됨)"),
258
305
  durationTargetSec: z.number().nullable().optional(),
259
306
  cutIntervalSec: z.number().nullable().optional(),
260
307
  cutsPerMin: z.number().nullable().optional(),
@@ -446,8 +493,9 @@ export function registerCloudTools(server, config, api) {
446
493
 
447
494
  server.tool(
448
495
  "generate_scene_image",
449
- "씬 이미지를 생성합니다 (기획 결과의 imagePrompt 사용). 반환된 imageUrl을 씬 영상 생성의 입력으로 쓰세요. 인물·제품 일관성은 이 참조 방식이 권장 경로입니다: 첫 씬(또는 캐릭터 시트/제품 사진)의 이미지를 referenceImageUrls·referenceImagePaths로 모든 씬에 앵커로 전달하세요. 결과에 이미지 블록이 동봉됩니다 — 반드시 직접 보고 QC(캐릭터 앵커·왜곡·지시 이행) 후 불합격 시 재생성하세요. 이미지를 볼 수 없으면 verify_scene_image 사용.",
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
- const payload = await api.request("/api/tracker/gemini/storyboard-scene-image", {
475
- body: {
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
- method: "POST",
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
- "씬 영상을 생성합니다 (동기 호출, 최대 ~30분 — MCP 클라이언트의 툴 타임아웃(MCP_TIMEOUT)을 그 이상으로 늘려두세요. 타임아웃되면 이미 과금된 결과를 회수할 수 없으니 주의). 반환된 videoUrl을 stitch_timeline의 sceneVideos에 넣으세요. 결과에 대표 프레임 3장이 동봉됩니다 — 반드시 직접 보고 QC 후 불합격 시 재생성하세요. 볼 수 없으면 verify_scene_video 사용.",
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
- const payload = await api.request("/api/tracker/gemini/storyboard-scene-video", {
574
- body: {
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
- method: "POST",
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
 
@@ -784,9 +776,9 @@ export function registerCloudTools(server, config, api) {
784
776
  subtitleLines: z.number().nullable().optional(),
785
777
  hasBgm: z.boolean().nullable().optional(),
786
778
  }).passthrough().optional().describe("measure_channel_spec 이 반환한 fingerprint — 자막 세로 위치·BGM 유무를 참고 채널에 맞춥니다"),
787
- transitionMode: z.enum(["fade", "none"]).optional().describe("기본 fade"),
779
+ transitionMode: z.enum(["fade", "none"]).optional().describe("기본 none(하드컷). fade 는 씬이 맞대기로 붙는 배치에서 컷마다 검은 딥을 만든다 — 명시 요청 시에만 쓸 것"),
788
780
  },
789
- wrapCloudHandler(config, async ({ sceneVideos, featureKey = "commerceVideo", projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines = [], emphasisKeywords = [], emphasisColor = "", emphasisSizeScale = 0, transitionMode = "fade", channelFingerprint = null }) => {
781
+ wrapCloudHandler(config, async ({ sceneVideos, featureKey = "commerceVideo", projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines = [], emphasisKeywords = [], emphasisColor = "", emphasisSizeScale = 0, transitionMode = "none", channelFingerprint = null }) => {
790
782
  // 오디오 확장자 검증 + 합산 크기 예산(서버 413 사전 차단).
791
783
  if (ttsAudioPath) assertAllowedInputFile(ttsAudioPath, new Set([".mp3", ".wav"]), { kind: "TTS 오디오" });
792
784
  if (bgmAudioPath) assertAllowedInputFile(bgmAudioPath, new Set([".mp3", ".wav"]), { kind: "BGM 오디오" });
@@ -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, { mimeType: "audio/mpeg" }) : "",
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, { mimeType: "audio/mpeg" }) : "",
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
- assertAllowedInputFile(videoPath, ALLOWED_VIDEO_EXTS, { kind: "영상 파일" });
899
- const stats = statSync(videoPath);
890
+ const stats = assertAllowedVideoUpload(videoPath);
900
891
  // 메타데이터가 쿼리스트링을 타므로 한국어 설명의 퍼센트 인코딩 팽창으로 헤더 상한(16KB)을
901
892
  // 넘지 않도록 보수적으로 제한한다.
902
893
  const safeTitle = title.slice(0, 100);
@@ -942,7 +933,7 @@ export function registerCloudTools(server, config, api) {
942
933
  storyText: z.string().optional().describe("핵심 줄거리·내용 브리프"),
943
934
  styleId: z.enum(["standard", "senior", "insight", "webnovel", "christian", "community-shorts", "viral-shorts"]).optional().describe("기본 standard. senior=시니어(쉽고 친절), insight=명언(통찰)"),
944
935
  model: z.enum(["gemini", "opus", "sonnet"]).optional().describe("기본 gemini(가성비). opus/sonnet=Claude"),
945
- targetLength: z.number().int().min(500).max(20000).optional().describe("목표 글자수, 기본 5000"),
936
+ targetLength: z.number().int().min(500).max(12000).optional().describe("목표 글자수, 기본 5000. 서버 출력 상한(8192토큰 ≈ 한국어 12,300자)을 넘으면 대본이 중간에 잘리므로 12000 이 상한이다"),
946
937
  targetLanguageLabel: z.string().optional().describe("출력 언어 라벨, 기본 '한국 (한국어)'"),
947
938
  contentFormat: z.enum(["longform", "shortform"]).optional().describe("기본 longform (standard/senior/insight는 항상 longform)"),
948
939
  webnovelSubtypeId: z.enum(["modern-webnovel", "regression-fantasy", "romance-fantasy", "martial-arts", "light-novel", "hunter-fantasy", "revenge-soda", "healing-daily", "relationship-drama", "literary-serial"]).optional().describe("styleId=webnovel일 때 장르"),
@@ -958,7 +949,7 @@ export function registerCloudTools(server, config, api) {
958
949
  endingStyle: z.string().nullable().optional(),
959
950
  emphasisCount: z.number().nullable().optional(),
960
951
  hasBgm: z.boolean().nullable().optional(),
961
- sampleSize: z.number().optional(),
952
+ sampleSize: z.number().describe("표본 수 — 서버는 이 값이 0보다 커야 지문을 규격으로 인정한다(없으면 조용히 무시됨)"),
962
953
  }).passthrough().optional().describe("measure_channel_spec 의 fingerprint — 실측 규격(길이·컷·말끝)을 대본에 강제합니다"),
963
954
  },
964
955
  wrapCloudHandler(config, async ({
@@ -1002,7 +993,9 @@ export function registerCloudTools(server, config, api) {
1002
993
  webnovelSubtypeId,
1003
994
  },
1004
995
  method: "POST",
1005
- timeoutMs: 240_000,
996
+ // 서버 최악 예산(리서치 40s + 생성 150s×2 = 340s)보다 크게 — 역전되면
997
+ // 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다.
998
+ timeoutMs: 420_000,
1006
999
  });
1007
1000
  const text = String(payload?.text || "").trim();
1008
1001
  if (!text) return textResult("대본 생성 결과가 비어 있습니다. 제목/브리프를 보강해 다시 시도하세요.", { isError: true });
@@ -1023,7 +1016,7 @@ export function registerCloudTools(server, config, api) {
1023
1016
  purpose: z.enum(["investment", "owner-occupancy", "move-up", "verification"]).optional().describe("기본 investment"),
1024
1017
  budget: z.string().optional().describe("예산/가격대"),
1025
1018
  notes: z.string().optional(),
1026
- targetLength: z.number().int().min(500).max(20000).optional().describe("목표 글자수, 기본 5000"),
1019
+ targetLength: z.number().int().min(500).max(12000).optional().describe("목표 글자수, 기본 5000. 서버 출력 상한(8192토큰 ≈ 한국어 12,300자)을 넘으면 대본이 중간에 잘리므로 12000 이 상한이다"),
1027
1020
  targetLanguageLabel: z.string().optional().describe("기본 '한국 (한국어)'"),
1028
1021
  },
1029
1022
  wrapCloudHandler(config, async ({ analysisType = "apartment-report", primaryTarget = "", secondaryTarget = "", region = "", title = "", storyText = "", purpose = "investment", budget = "", notes = "", targetLength = 5000, targetLanguageLabel = "한국 (한국어)" }) => {
@@ -1053,7 +1046,9 @@ export function registerCloudTools(server, config, api) {
1053
1046
  title,
1054
1047
  },
1055
1048
  method: "POST",
1056
- timeoutMs: 240_000,
1049
+ // 서버 최악 예산(리서치 40s + 생성 150s×2 = 340s)보다 크게 — 역전되면
1050
+ // 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다.
1051
+ timeoutMs: 420_000,
1057
1052
  });
1058
1053
  const text = String(payload?.text || "").trim();
1059
1054
  if (!text) return textResult("부동산 분석 대본 결과가 비어 있습니다.", { isError: true });
@@ -1074,7 +1069,7 @@ export function registerCloudTools(server, config, api) {
1074
1069
  purpose: z.enum(["startup", "investment", "site-selection", "verification"]).optional().describe("기본 startup"),
1075
1070
  budget: z.string().optional().describe("예산/운영 여력"),
1076
1071
  notes: z.string().optional(),
1077
- targetLength: z.number().int().min(500).max(20000).optional().describe("목표 글자수, 기본 5000"),
1072
+ targetLength: z.number().int().min(500).max(12000).optional().describe("목표 글자수, 기본 5000. 서버 출력 상한(8192토큰 ≈ 한국어 12,300자)을 넘으면 대본이 중간에 잘리므로 12000 이 상한이다"),
1078
1073
  targetLanguageLabel: z.string().optional().describe("기본 '한국 (한국어)'"),
1079
1074
  },
1080
1075
  wrapCloudHandler(config, async ({ analysisType = "commercial-district-report", primaryTarget = "", secondaryTarget = "", businessType = "", title = "", storyText = "", purpose = "startup", budget = "", notes = "", targetLength = 5000, targetLanguageLabel = "한국 (한국어)" }) => {
@@ -1104,7 +1099,9 @@ export function registerCloudTools(server, config, api) {
1104
1099
  title,
1105
1100
  },
1106
1101
  method: "POST",
1107
- timeoutMs: 240_000,
1102
+ // 서버 최악 예산(리서치 40s + 생성 150s×2 = 340s)보다 크게 — 역전되면
1103
+ // 클라는 실패로 끝나는데 서버는 계속 돌며 과금한다.
1104
+ timeoutMs: 420_000,
1108
1105
  });
1109
1106
  const text = String(payload?.text || "").trim();
1110
1107
  if (!text) return textResult("상권 분석 대본 결과가 비어 있습니다.", { isError: true });
@@ -1457,7 +1454,7 @@ export function registerCloudTools(server, config, api) {
1457
1454
 
1458
1455
  server.tool(
1459
1456
  "measure_channel_spec",
1460
- "벤치마크 채널의 영상 표본(유튜브 URL ≤5)을 실측해 채널 규격 지문(길이·컷간격·컷/분·라우드니스·무음비율·자막 위치·말끝 스타일)을 만듭니다. 컴패니언 앱이 다운로드·샷감지·오디오 추출을 수행하고 서버가 집계합니다. 반환된 fingerprint 를 plan_shorts_video 의 channelFingerprint 로 넘기면 대본이 그 규격을 강제합니다.",
1457
+ "벤치마크 채널의 영상 표본(유튜브 URL ≤5)을 실측해 채널 규격 지문(길이·컷간격·컷/분·라우드니스·무음비율·자막 위치·말끝 스타일)을 만듭니다. 말속도는 측정하지 않습니다. 컴패니언 앱이 다운로드·샷감지·오디오 추출을 수행하고 서버가 집계합니다. 반환된 fingerprint 를 plan_shorts_video 의 channelFingerprint 로 넘기면 대본이 그 규격을 강제합니다.",
1461
1458
  {
1462
1459
  videoUrls: z.array(z.string()).min(1).max(5).describe("표본 유튜브 URL 또는 11자 ID (조회수 상위 영상 권장)"),
1463
1460
  },
@@ -1526,6 +1523,9 @@ export function registerCloudTools(server, config, api) {
1526
1523
  notes.push(`표본 ${index + 1}: ${String(error?.message || error).slice(0, 100)}`);
1527
1524
  }
1528
1525
  }
1526
+ // 말속도(cpsTarget)는 타임코드 전사가 있어야 산출된다. 이 도구는 전사를 하지 않으므로
1527
+ // 그 축은 비워 둔다 — 재지 않은 것을 잰 척하지 않는다.
1528
+ notes.push("말속도(cpsTarget)는 측정하지 않습니다 — 타임코드 전사가 필요합니다.");
1529
1529
  if (!specs.length) {
1530
1530
  return textResult(`표본 실측에 모두 실패했습니다. 컴패니언 앱 실행 여부를 확인하세요.\n${notes.join("\n")}`, { isError: true });
1531
1531
  }
@@ -1539,7 +1539,7 @@ export function registerCloudTools(server, config, api) {
1539
1539
  }
1540
1540
  return jsonResult({
1541
1541
  fingerprint: payload.fingerprint,
1542
- next: "이 fingerprint 객체를 plan_shorts_video 의 channelFingerprint 로 그대로 넘기세요 — 대본이 실측 규격(길이·컷·말속도·말끝)을 따르게 됩니다.",
1542
+ next: "이 fingerprint 객체를 plan_shorts_video·stitch_timeline·write_script 의 channelFingerprint 로 그대로 넘기세요 — 길이·컷 리듬·말끝·자막 위치·BGM 이 이 규격을 따릅니다.",
1543
1543
  notes: notes.length ? notes : undefined,
1544
1544
  sampleCount: specs.length,
1545
1545
  });
@@ -1,4 +1,5 @@
1
- import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
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
- // 사용된 파일명이 이미 있으면 -2, -3 … 을 붙여 기존 출력을 덮어쓰지 않는다.
161
- function uniqueOutputPath(dir, fileName) {
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 (existsSync(candidate)) {
167
- candidate = path.join(dir, `${base}-${counter}${ext}`);
168
- counter += 1;
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
- mkdirSync(outputDir, { recursive: true });
197
- const finalPath = uniqueOutputPath(outputDir, fileName);
198
- const partPath = `${finalPath}.part`;
199
-
208
+ let finalPath = "";
209
+ let partPath = "";
200
210
  try {
201
- await pipeline(Readable.fromWeb(response.body), createWriteStream(partPath, { mode: 0o600 }));
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
- try { rmSync(partPath, { force: true }); } catch { /* ignore */ }
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
  }