@yoonion/mimi-seed-mcp 0.11.0 → 0.12.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 +6 -1
- package/assets/agent-guide.md +21 -3
- package/dist/auth/cli.js +57 -4
- package/dist/auth/google-auth.d.ts +10 -1
- package/dist/auth/google-auth.js +20 -18
- package/dist/auth/scopes.d.ts +98 -0
- package/dist/auth/scopes.js +159 -0
- package/dist/checks/risks.js +12 -8
- package/dist/helpers.js +20 -8
- package/dist/index.js +3 -3
- package/dist/lib/package-root.d.ts +24 -0
- package/dist/lib/package-root.js +21 -0
- package/dist/playstore/tools.js +30 -8
- package/dist/registers/auth.js +37 -7
- package/dist/registers/firebase.js +7 -4
- package/dist/registers/iam.js +6 -5
- package/dist/registers/playstore.js +1 -1
- package/dist/registers/video.d.ts +2 -0
- package/dist/registers/video.js +165 -0
- package/dist/resources.d.ts +0 -5
- package/dist/resources.js +26 -122
- package/dist/server.js +2 -0
- package/dist/video/files.d.ts +3 -0
- package/dist/video/files.js +37 -0
- package/dist/video/project.d.ts +38 -0
- package/dist/video/project.js +255 -0
- package/dist/video/providers.d.ts +104 -0
- package/dist/video/providers.js +321 -0
- package/dist/video/render.d.ts +39 -0
- package/dist/video/render.js +320 -0
- package/dist/video/research.d.ts +86 -0
- package/dist/video/research.js +106 -0
- package/dist/video/schemas.d.ts +499 -0
- package/dist/video/schemas.js +101 -0
- package/dist/video/types.d.ts +79 -0
- package/dist/video/types.js +1 -0
- package/package.json +1 -1
- package/tool-manifest.json +309 -201
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, createWriteStream, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { writeJsonAtomic } from './files.js';
|
|
6
|
+
import { loadProject, registerAsset } from './project.js';
|
|
7
|
+
function requireEnv(name) {
|
|
8
|
+
const value = process.env[name]?.trim();
|
|
9
|
+
if (!value)
|
|
10
|
+
throw new Error(`❌ ${name} 환경변수가 필요합니다.`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
async function fetchJson(url, init) {
|
|
14
|
+
const response = await fetch(url, init);
|
|
15
|
+
const text = await response.text();
|
|
16
|
+
let body;
|
|
17
|
+
try {
|
|
18
|
+
body = JSON.parse(text);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
body = text;
|
|
22
|
+
}
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
const message = typeof body === 'object' && body !== null && 'error' in body
|
|
25
|
+
? JSON.stringify(body.error)
|
|
26
|
+
: String(body).slice(0, 500);
|
|
27
|
+
throw new Error(`외부 API 요청 실패 (${response.status}): ${message}`);
|
|
28
|
+
}
|
|
29
|
+
return body;
|
|
30
|
+
}
|
|
31
|
+
function sanitizeExternalText(value, maxLength) {
|
|
32
|
+
return (value ?? '').replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').slice(0, maxLength);
|
|
33
|
+
}
|
|
34
|
+
export async function researchYouTube(input) {
|
|
35
|
+
const project = loadProject(input.projectDir);
|
|
36
|
+
const key = requireEnv('YOUTUBE_API_KEY');
|
|
37
|
+
const queries = (input.queries?.length ? input.queries : project.scenes.map((scene) => scene.searchQuery))
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.slice(0, 5);
|
|
40
|
+
if (queries.length === 0)
|
|
41
|
+
throw new Error('검색어가 없습니다. queries를 전달하거나 프로젝트 장면에 searchQuery를 추가하세요.');
|
|
42
|
+
const references = [];
|
|
43
|
+
for (const query of queries) {
|
|
44
|
+
const params = new URLSearchParams({
|
|
45
|
+
key,
|
|
46
|
+
part: 'snippet',
|
|
47
|
+
type: 'video',
|
|
48
|
+
q: query,
|
|
49
|
+
maxResults: String(Math.min(Math.max(input.maxResultsPerQuery ?? 5, 1), 10)),
|
|
50
|
+
order: input.order ?? 'relevance',
|
|
51
|
+
videoDuration: 'short',
|
|
52
|
+
safeSearch: 'moderate',
|
|
53
|
+
});
|
|
54
|
+
if (input.regionCode)
|
|
55
|
+
params.set('regionCode', input.regionCode);
|
|
56
|
+
if (input.publishedAfter)
|
|
57
|
+
params.set('publishedAfter', input.publishedAfter);
|
|
58
|
+
if (input.creativeCommonsOnly)
|
|
59
|
+
params.set('videoLicense', 'creativeCommon');
|
|
60
|
+
const search = await fetchJson(`https://www.googleapis.com/youtube/v3/search?${params}`);
|
|
61
|
+
const ids = (search.items ?? []).map((item) => item.id?.videoId).filter((id) => !!id);
|
|
62
|
+
if (ids.length === 0)
|
|
63
|
+
continue;
|
|
64
|
+
const detailParams = new URLSearchParams({
|
|
65
|
+
key,
|
|
66
|
+
part: 'contentDetails,statistics,status',
|
|
67
|
+
id: ids.join(','),
|
|
68
|
+
});
|
|
69
|
+
const details = await fetchJson(`https://www.googleapis.com/youtube/v3/videos?${detailParams}`);
|
|
70
|
+
const detailById = new Map((details.items ?? []).map((item) => [item.id, item]));
|
|
71
|
+
for (const item of search.items ?? []) {
|
|
72
|
+
const videoId = item.id?.videoId;
|
|
73
|
+
if (!videoId)
|
|
74
|
+
continue;
|
|
75
|
+
const detail = detailById.get(videoId);
|
|
76
|
+
references.push({
|
|
77
|
+
videoId,
|
|
78
|
+
title: sanitizeExternalText(item.snippet?.title, 300),
|
|
79
|
+
description: sanitizeExternalText(item.snippet?.description, 1_000),
|
|
80
|
+
channelTitle: sanitizeExternalText(item.snippet?.channelTitle, 200),
|
|
81
|
+
publishedAt: item.snippet?.publishedAt ?? '',
|
|
82
|
+
thumbnailUrl: item.snippet?.thumbnails?.high?.url ?? item.snippet?.thumbnails?.medium?.url,
|
|
83
|
+
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
84
|
+
duration: detail?.contentDetails?.duration,
|
|
85
|
+
viewCount: detail?.statistics?.viewCount,
|
|
86
|
+
likeCount: detail?.statistics?.likeCount,
|
|
87
|
+
license: detail?.status?.license,
|
|
88
|
+
sourceType: 'reference-only',
|
|
89
|
+
allowedForRendering: false,
|
|
90
|
+
untrustedExternalText: true,
|
|
91
|
+
query,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const outputPath = path.join(path.resolve(input.projectDir), 'research', 'youtube.json');
|
|
96
|
+
mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
97
|
+
writeJsonAtomic(outputPath, { generatedAt: new Date().toISOString(), references });
|
|
98
|
+
return references;
|
|
99
|
+
}
|
|
100
|
+
function bestPexelsFile(files = []) {
|
|
101
|
+
const mp4s = files.filter((file) => file.link && file.file_type === 'video/mp4');
|
|
102
|
+
return mp4s.sort((a, b) => ((b.width ?? 0) * (b.height ?? 0)) - ((a.width ?? 0) * (a.height ?? 0)))[0];
|
|
103
|
+
}
|
|
104
|
+
export async function searchStock(input) {
|
|
105
|
+
const project = loadProject(input.projectDir);
|
|
106
|
+
const key = requireEnv('PEXELS_API_KEY');
|
|
107
|
+
const queries = (input.queries?.length ? input.queries : project.scenes.map((scene) => scene.searchQuery))
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.slice(0, 10);
|
|
110
|
+
if (queries.length === 0)
|
|
111
|
+
throw new Error('검색어가 없습니다.');
|
|
112
|
+
const results = [];
|
|
113
|
+
for (const query of queries) {
|
|
114
|
+
const params = new URLSearchParams({
|
|
115
|
+
query,
|
|
116
|
+
per_page: String(Math.min(Math.max(input.perQuery ?? 5, 1), 15)),
|
|
117
|
+
});
|
|
118
|
+
if (input.orientation)
|
|
119
|
+
params.set('orientation', input.orientation);
|
|
120
|
+
const data = await fetchJson(`https://api.pexels.com/v1/videos/search?${params}`, {
|
|
121
|
+
headers: { Authorization: key },
|
|
122
|
+
});
|
|
123
|
+
for (const video of data.videos ?? []) {
|
|
124
|
+
const selected = bestPexelsFile(video.video_files);
|
|
125
|
+
results.push({
|
|
126
|
+
id: video.id,
|
|
127
|
+
width: video.width,
|
|
128
|
+
height: video.height,
|
|
129
|
+
duration: video.duration,
|
|
130
|
+
pageUrl: video.url,
|
|
131
|
+
author: sanitizeExternalText(video.user?.name, 200),
|
|
132
|
+
authorUrl: video.user?.url,
|
|
133
|
+
previewImage: video.image,
|
|
134
|
+
downloadUrl: selected?.link,
|
|
135
|
+
downloadWidth: selected?.width,
|
|
136
|
+
downloadHeight: selected?.height,
|
|
137
|
+
license: 'Pexels License',
|
|
138
|
+
attribution: `Video by ${video.user?.name ?? 'Pexels creator'} on Pexels`,
|
|
139
|
+
query,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const outputPath = path.join(path.resolve(input.projectDir), 'research', 'pexels.json');
|
|
144
|
+
mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
145
|
+
writeJsonAtomic(outputPath, { generatedAt: new Date().toISOString(), results });
|
|
146
|
+
return results;
|
|
147
|
+
}
|
|
148
|
+
const PEXELS_MEDIA_HOSTS = new Set([
|
|
149
|
+
'videos.pexels.com',
|
|
150
|
+
'images.pexels.com',
|
|
151
|
+
'static-videos.pexels.com',
|
|
152
|
+
]);
|
|
153
|
+
function validatePexelsUrl(value) {
|
|
154
|
+
const url = new URL(value);
|
|
155
|
+
if (url.protocol !== 'https:' || !PEXELS_MEDIA_HOSTS.has(url.hostname)) {
|
|
156
|
+
throw new Error(`허용되지 않은 Pexels 미디어 URL입니다: ${url.hostname}`);
|
|
157
|
+
}
|
|
158
|
+
return url;
|
|
159
|
+
}
|
|
160
|
+
async function fetchPexelsMedia(value) {
|
|
161
|
+
let current = validatePexelsUrl(value);
|
|
162
|
+
for (let redirects = 0; redirects <= 3; redirects += 1) {
|
|
163
|
+
const response = await fetch(current, { redirect: 'manual' });
|
|
164
|
+
if (![301, 302, 303, 307, 308].includes(response.status))
|
|
165
|
+
return response;
|
|
166
|
+
const location = response.headers.get('location');
|
|
167
|
+
if (!location)
|
|
168
|
+
throw new Error('Pexels 리디렉션 응답에 Location 헤더가 없습니다.');
|
|
169
|
+
await response.body?.cancel();
|
|
170
|
+
current = validatePexelsUrl(new URL(location, current).toString());
|
|
171
|
+
}
|
|
172
|
+
throw new Error('Pexels 자산 리디렉션이 3회를 초과했습니다.');
|
|
173
|
+
}
|
|
174
|
+
async function downloadLimited(response, destination, maxBytes) {
|
|
175
|
+
if (!response.body)
|
|
176
|
+
throw new Error('다운로드 응답에 본문이 없습니다.');
|
|
177
|
+
const tempPath = `${destination}.${randomUUID()}.tmp`;
|
|
178
|
+
const writer = createWriteStream(tempPath, { flags: 'wx' });
|
|
179
|
+
const reader = response.body.getReader();
|
|
180
|
+
let total = 0;
|
|
181
|
+
try {
|
|
182
|
+
while (true) {
|
|
183
|
+
const { done, value } = await reader.read();
|
|
184
|
+
if (done)
|
|
185
|
+
break;
|
|
186
|
+
total += value.byteLength;
|
|
187
|
+
if (total > maxBytes) {
|
|
188
|
+
await reader.cancel();
|
|
189
|
+
throw new Error(`자산이 ${Math.round(maxBytes / 1024 / 1024)}MB 제한을 초과합니다.`);
|
|
190
|
+
}
|
|
191
|
+
if (!writer.write(Buffer.from(value)))
|
|
192
|
+
await once(writer, 'drain');
|
|
193
|
+
}
|
|
194
|
+
writer.end();
|
|
195
|
+
await once(writer, 'finish');
|
|
196
|
+
renameSync(tempPath, destination);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
writer.destroy();
|
|
200
|
+
try {
|
|
201
|
+
unlinkSync(tempPath);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// The partial file may already be gone.
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
reader.releaseLock();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function hasMp4Signature(filePath) {
|
|
213
|
+
const fd = openSync(filePath, 'r');
|
|
214
|
+
const header = Buffer.alloc(12);
|
|
215
|
+
try {
|
|
216
|
+
const read = readSync(fd, header, 0, header.length, 0);
|
|
217
|
+
return read >= 12 && header.subarray(4, 8).toString('ascii') === 'ftyp';
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
closeSync(fd);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
export async function downloadStockAssets(projectDir, selections) {
|
|
224
|
+
loadProject(projectDir);
|
|
225
|
+
if (selections.length === 0 || selections.length > 5)
|
|
226
|
+
throw new Error('한 번에 1~5개 자산을 선택하세요.');
|
|
227
|
+
const assets = [];
|
|
228
|
+
for (const selection of selections) {
|
|
229
|
+
const url = validatePexelsUrl(selection.downloadUrl);
|
|
230
|
+
const response = await fetchPexelsMedia(url.toString());
|
|
231
|
+
if (!response.ok)
|
|
232
|
+
throw new Error(`Pexels 자산 다운로드 실패 (${response.status}): ${selection.id}`);
|
|
233
|
+
const contentType = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase();
|
|
234
|
+
if (contentType !== 'video/mp4' && contentType !== 'application/octet-stream') {
|
|
235
|
+
throw new Error(`Pexels 자산이 MP4 응답이 아닙니다: ${contentType ?? 'unknown'}`);
|
|
236
|
+
}
|
|
237
|
+
const length = Number(response.headers.get('content-length') ?? 0);
|
|
238
|
+
if (length > 100 * 1024 * 1024)
|
|
239
|
+
throw new Error(`자산이 100MB 제한을 초과합니다: ${selection.id}`);
|
|
240
|
+
const id = `pexels-${selection.id}-${randomUUID().slice(0, 8)}`;
|
|
241
|
+
const destination = path.join(path.resolve(projectDir), 'assets', 'stock', `${id}.mp4`);
|
|
242
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
243
|
+
await downloadLimited(response, destination, 100 * 1024 * 1024);
|
|
244
|
+
if (!hasMp4Signature(destination)) {
|
|
245
|
+
unlinkSync(destination);
|
|
246
|
+
throw new Error(`Pexels 자산이 유효한 MP4 시그니처를 갖지 않습니다: ${selection.id}`);
|
|
247
|
+
}
|
|
248
|
+
assets.push(registerAsset(projectDir, {
|
|
249
|
+
id,
|
|
250
|
+
kind: 'video',
|
|
251
|
+
sourceType: 'stock',
|
|
252
|
+
path: destination,
|
|
253
|
+
sourceUrl: selection.pageUrl,
|
|
254
|
+
license: 'Pexels License',
|
|
255
|
+
author: selection.author,
|
|
256
|
+
attribution: selection.attribution ?? `Video by ${selection.author ?? 'Pexels creator'} on Pexels`,
|
|
257
|
+
allowedForRendering: true,
|
|
258
|
+
provider: 'pexels',
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
return assets;
|
|
262
|
+
}
|
|
263
|
+
export async function generateImage(input) {
|
|
264
|
+
const project = loadProject(input.projectDir);
|
|
265
|
+
const key = requireEnv('OPENAI_API_KEY');
|
|
266
|
+
const size = input.size ?? (project.settings.aspectRatio === '9:16'
|
|
267
|
+
? '1024x1536'
|
|
268
|
+
: project.settings.aspectRatio === '16:9' ? '1536x1024' : '1024x1024');
|
|
269
|
+
const data = await fetchJson('https://api.openai.com/v1/images/generations', {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: {
|
|
272
|
+
Authorization: `Bearer ${key}`,
|
|
273
|
+
'Content-Type': 'application/json',
|
|
274
|
+
},
|
|
275
|
+
body: JSON.stringify({
|
|
276
|
+
model: input.model ?? 'gpt-image-2',
|
|
277
|
+
prompt: input.prompt,
|
|
278
|
+
n: 1,
|
|
279
|
+
size,
|
|
280
|
+
quality: input.quality ?? 'medium',
|
|
281
|
+
output_format: 'png',
|
|
282
|
+
}),
|
|
283
|
+
});
|
|
284
|
+
const base64 = data.data?.[0]?.b64_json;
|
|
285
|
+
if (!base64)
|
|
286
|
+
throw new Error('이미지 생성 응답에 이미지 데이터가 없습니다.');
|
|
287
|
+
const bytes = Buffer.from(base64, 'base64');
|
|
288
|
+
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
289
|
+
if (bytes.length === 0 || bytes.length > 50 * 1024 * 1024 || !bytes.subarray(0, 8).equals(pngSignature)) {
|
|
290
|
+
throw new Error('이미지 생성 응답이 유효한 50MB 이하 PNG가 아닙니다.');
|
|
291
|
+
}
|
|
292
|
+
const id = `openai-${randomUUID()}`;
|
|
293
|
+
const safeName = (input.name ?? id).replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || id;
|
|
294
|
+
const destination = path.join(path.resolve(input.projectDir), 'assets', 'generated', `${safeName}-${id.slice(-8)}.png`);
|
|
295
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
296
|
+
const tempPath = `${destination}.${randomUUID()}.tmp`;
|
|
297
|
+
try {
|
|
298
|
+
writeFileSync(tempPath, bytes, { flag: 'wx' });
|
|
299
|
+
renameSync(tempPath, destination);
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
try {
|
|
303
|
+
unlinkSync(tempPath);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// The temp file may not have been created.
|
|
307
|
+
}
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
return registerAsset(input.projectDir, {
|
|
311
|
+
id,
|
|
312
|
+
kind: 'image',
|
|
313
|
+
sourceType: 'generated',
|
|
314
|
+
path: destination,
|
|
315
|
+
license: 'OpenAI generated output; use subject to OpenAI terms and applicable law',
|
|
316
|
+
allowedForRendering: true,
|
|
317
|
+
provider: input.model ?? 'gpt-image-2',
|
|
318
|
+
prompt: input.prompt,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
export const __testing = { bestPexelsFile, validatePexelsUrl, hasMp4Signature, sanitizeExternalText };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { VideoRenderJob, VideoTimeline } from './types.js';
|
|
2
|
+
declare function processAlive(pid: number | undefined): boolean;
|
|
3
|
+
declare function readTail(filePath: string, maxBytes?: number): string;
|
|
4
|
+
declare function sanitizeSubtitleText(value: string): string;
|
|
5
|
+
declare function srtTime(seconds: number): string;
|
|
6
|
+
export declare function formatSrt(timeline: VideoTimeline): string;
|
|
7
|
+
export interface FfmpegPlan {
|
|
8
|
+
args: string[];
|
|
9
|
+
outputPath: string;
|
|
10
|
+
subtitlePath?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function buildFfmpegPlan(projectDir: string, outputPath: string, jobId: string): FfmpegPlan;
|
|
13
|
+
export interface StartRenderInput {
|
|
14
|
+
projectDir: string;
|
|
15
|
+
outputFileName?: string;
|
|
16
|
+
ffmpegPath?: string;
|
|
17
|
+
overwriteOutput?: boolean;
|
|
18
|
+
}
|
|
19
|
+
declare function acquireOutputLock(lockPath: string, projectDir: string, jobId: string): void;
|
|
20
|
+
export declare function startRender(input: StartRenderInput): Promise<VideoRenderJob>;
|
|
21
|
+
export declare function getRenderJob(projectDir: string, jobId: string): VideoRenderJob & {
|
|
22
|
+
logTail?: string;
|
|
23
|
+
};
|
|
24
|
+
declare function ffprobeFor(ffmpegPath?: string): string;
|
|
25
|
+
export declare function validateVideo(filePath: string, ffmpegPath?: string): Promise<{
|
|
26
|
+
valid: boolean;
|
|
27
|
+
format: unknown;
|
|
28
|
+
streams: unknown[];
|
|
29
|
+
issues: string[];
|
|
30
|
+
}>;
|
|
31
|
+
export declare const __testing: {
|
|
32
|
+
srtTime: typeof srtTime;
|
|
33
|
+
ffprobeFor: typeof ffprobeFor;
|
|
34
|
+
sanitizeSubtitleText: typeof sanitizeSubtitleText;
|
|
35
|
+
processAlive: typeof processAlive;
|
|
36
|
+
acquireOutputLock: typeof acquireOutputLock;
|
|
37
|
+
readTail: typeof readTail;
|
|
38
|
+
};
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { spawn, execFile } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { closeSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { readJson, writeJsonAtomic } from './files.js';
|
|
7
|
+
import { loadAssets, loadProject, loadTimeline } from './project.js';
|
|
8
|
+
import { parseFile, renderJobSchema } from './schemas.js';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
function jobPath(projectDir, jobId) {
|
|
11
|
+
return path.join(path.resolve(projectDir), '.jobs', `${jobId}.json`);
|
|
12
|
+
}
|
|
13
|
+
function updateJob(projectDir, jobId, patch) {
|
|
14
|
+
const filePath = jobPath(projectDir, jobId);
|
|
15
|
+
const current = parseFile(renderJobSchema, readJson(filePath), filePath);
|
|
16
|
+
const next = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
|
17
|
+
writeJsonAtomic(filePath, next);
|
|
18
|
+
return next;
|
|
19
|
+
}
|
|
20
|
+
function processAlive(pid) {
|
|
21
|
+
if (!pid)
|
|
22
|
+
return false;
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return error.code === 'EPERM';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function readTail(filePath, maxBytes = 64 * 1024) {
|
|
32
|
+
const size = statSync(filePath).size;
|
|
33
|
+
const length = Math.min(size, maxBytes);
|
|
34
|
+
const buffer = Buffer.alloc(length);
|
|
35
|
+
const fd = openSync(filePath, 'r');
|
|
36
|
+
try {
|
|
37
|
+
readSync(fd, buffer, 0, length, Math.max(0, size - length));
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
closeSync(fd);
|
|
41
|
+
}
|
|
42
|
+
return buffer.toString('utf8');
|
|
43
|
+
}
|
|
44
|
+
function sanitizeSubtitleText(value) {
|
|
45
|
+
return value
|
|
46
|
+
.replace(/\r/g, '')
|
|
47
|
+
.replace(/\n{2,}/g, '\n')
|
|
48
|
+
.replace(/-->/g, '→')
|
|
49
|
+
.replace(/</g, '<')
|
|
50
|
+
.replace(/>/g, '>')
|
|
51
|
+
.replace(/{/g, '{')
|
|
52
|
+
.replace(/}/g, '}')
|
|
53
|
+
.trim();
|
|
54
|
+
}
|
|
55
|
+
function srtTime(seconds) {
|
|
56
|
+
const ms = Math.max(0, Math.round(seconds * 1000));
|
|
57
|
+
const hours = Math.floor(ms / 3_600_000);
|
|
58
|
+
const minutes = Math.floor((ms % 3_600_000) / 60_000);
|
|
59
|
+
const secs = Math.floor((ms % 60_000) / 1000);
|
|
60
|
+
const millis = ms % 1000;
|
|
61
|
+
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')},${String(millis).padStart(3, '0')}`;
|
|
62
|
+
}
|
|
63
|
+
export function formatSrt(timeline) {
|
|
64
|
+
let cursor = 0;
|
|
65
|
+
const blocks = [];
|
|
66
|
+
for (const scene of timeline.scenes) {
|
|
67
|
+
const text = sanitizeSubtitleText(scene.onScreenText ?? '');
|
|
68
|
+
const start = cursor;
|
|
69
|
+
cursor += scene.durationSec;
|
|
70
|
+
if (!text)
|
|
71
|
+
continue;
|
|
72
|
+
blocks.push(`${blocks.length + 1}\n${srtTime(start)} --> ${srtTime(cursor)}\n${text}\n`);
|
|
73
|
+
}
|
|
74
|
+
return blocks.join('\n');
|
|
75
|
+
}
|
|
76
|
+
function isImage(asset) {
|
|
77
|
+
return asset.kind === 'image';
|
|
78
|
+
}
|
|
79
|
+
export function buildFfmpegPlan(projectDir, outputPath, jobId) {
|
|
80
|
+
const dir = path.resolve(projectDir);
|
|
81
|
+
const project = loadProject(dir);
|
|
82
|
+
const timeline = loadTimeline(dir);
|
|
83
|
+
const manifest = loadAssets(dir);
|
|
84
|
+
const byId = new Map(manifest.assets.map((asset) => [asset.id, asset]));
|
|
85
|
+
const assets = timeline.scenes.map((scene) => {
|
|
86
|
+
const asset = byId.get(scene.assetId);
|
|
87
|
+
if (!asset || !asset.allowedForRendering || !existsSync(asset.path)) {
|
|
88
|
+
throw new Error(`렌더링할 수 없는 장면 자산입니다: ${scene.assetId}`);
|
|
89
|
+
}
|
|
90
|
+
return asset;
|
|
91
|
+
});
|
|
92
|
+
const args = ['-nostdin', '-y'];
|
|
93
|
+
timeline.scenes.forEach((scene, index) => {
|
|
94
|
+
const asset = assets[index];
|
|
95
|
+
if (isImage(asset))
|
|
96
|
+
args.push('-loop', '1');
|
|
97
|
+
else
|
|
98
|
+
args.push('-stream_loop', '-1');
|
|
99
|
+
args.push('-t', String(scene.durationSec), '-i', asset.path);
|
|
100
|
+
});
|
|
101
|
+
let audioIndex;
|
|
102
|
+
if (timeline.audioAssetId) {
|
|
103
|
+
const audio = byId.get(timeline.audioAssetId);
|
|
104
|
+
if (!audio || audio.kind !== 'audio' || !audio.allowedForRendering || !existsSync(audio.path)) {
|
|
105
|
+
throw new Error(`렌더링할 수 없는 오디오 자산입니다: ${timeline.audioAssetId}`);
|
|
106
|
+
}
|
|
107
|
+
audioIndex = assets.length;
|
|
108
|
+
args.push('-stream_loop', '-1', '-i', audio.path);
|
|
109
|
+
}
|
|
110
|
+
const filters = timeline.scenes.map((scene, index) => (`[${index}:v]scale=${project.settings.width}:${project.settings.height}:force_original_aspect_ratio=increase,` +
|
|
111
|
+
`crop=${project.settings.width}:${project.settings.height},fps=${project.settings.fps},` +
|
|
112
|
+
`trim=duration=${scene.durationSec},setpts=PTS-STARTPTS,format=yuv420p[v${index}]`));
|
|
113
|
+
filters.push(`${timeline.scenes.map((_, index) => `[v${index}]`).join('')}concat=n=${timeline.scenes.length}:v=1:a=0[base]`);
|
|
114
|
+
const srt = formatSrt(timeline);
|
|
115
|
+
let videoLabel = '[base]';
|
|
116
|
+
let subtitlePath;
|
|
117
|
+
if (srt) {
|
|
118
|
+
const relativeSubtitlePath = `render/captions-${jobId}.srt`;
|
|
119
|
+
subtitlePath = path.join(dir, relativeSubtitlePath);
|
|
120
|
+
writeFileSync(subtitlePath, srt, 'utf8');
|
|
121
|
+
filters.push(`[base]subtitles=${relativeSubtitlePath}:force_style='FontName=Arial,FontSize=18,PrimaryColour=&H00FFFFFF,OutlineColour=&H80000000,BorderStyle=3,Outline=1,Shadow=0,Alignment=2,MarginV=48'[vout]`);
|
|
122
|
+
videoLabel = '[vout]';
|
|
123
|
+
}
|
|
124
|
+
args.push('-filter_complex', filters.join(';'), '-map', videoLabel);
|
|
125
|
+
if (audioIndex !== undefined) {
|
|
126
|
+
args.push('-map', `${audioIndex}:a:0`, '-c:a', 'aac', '-b:a', '192k', '-shortest');
|
|
127
|
+
}
|
|
128
|
+
args.push('-t', String(timeline.totalDurationSec), '-c:v', 'libx264', '-preset', 'medium', '-crf', '20', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', outputPath);
|
|
129
|
+
return { args, outputPath, subtitlePath };
|
|
130
|
+
}
|
|
131
|
+
async function verifyExecutable(command) {
|
|
132
|
+
try {
|
|
133
|
+
await execFileAsync(command, ['-version'], { timeout: 10_000, windowsHide: true });
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
throw new Error(`FFmpeg를 실행할 수 없습니다: ${command}\nFFmpeg를 설치하거나 ffmpegPath를 지정하세요.`, { cause: error });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function acquireOutputLock(lockPath, projectDir, jobId) {
|
|
140
|
+
const tryCreate = () => {
|
|
141
|
+
const fd = openSync(lockPath, 'wx');
|
|
142
|
+
try {
|
|
143
|
+
writeFileSync(fd, jobId, 'utf8');
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
closeSync(fd);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
tryCreate();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error.code !== 'EEXIST')
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
let active = false;
|
|
158
|
+
try {
|
|
159
|
+
const existingId = readFileSync(lockPath, 'utf8').trim();
|
|
160
|
+
const existingPath = jobPath(projectDir, existingId);
|
|
161
|
+
if (existsSync(existingPath)) {
|
|
162
|
+
const existing = parseFile(renderJobSchema, readJson(existingPath), existingPath);
|
|
163
|
+
const queuedRecently = existing.status === 'queued' && Date.now() - Date.parse(existing.updatedAt) < 60_000;
|
|
164
|
+
active = queuedRecently || (existing.status === 'running' && processAlive(existing.pid));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
active = false;
|
|
169
|
+
}
|
|
170
|
+
if (active)
|
|
171
|
+
throw new Error(`같은 출력 파일의 렌더 작업이 이미 실행 중입니다: ${lockPath}`);
|
|
172
|
+
unlinkSync(lockPath);
|
|
173
|
+
tryCreate();
|
|
174
|
+
}
|
|
175
|
+
export async function startRender(input) {
|
|
176
|
+
const projectDir = path.resolve(input.projectDir);
|
|
177
|
+
loadProject(projectDir);
|
|
178
|
+
loadTimeline(projectDir);
|
|
179
|
+
const ffmpeg = input.ffmpegPath ?? process.env.MIMI_SEED_FFMPEG_PATH ?? 'ffmpeg';
|
|
180
|
+
await verifyExecutable(ffmpeg);
|
|
181
|
+
const safeOutputName = (input.outputFileName ?? 'output.mp4')
|
|
182
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
183
|
+
.replace(/^-+|-+$/g, '') || 'output.mp4';
|
|
184
|
+
const finalName = safeOutputName.toLowerCase().endsWith('.mp4') ? safeOutputName : `${safeOutputName}.mp4`;
|
|
185
|
+
const outputPath = path.join(projectDir, 'render', finalName);
|
|
186
|
+
if (existsSync(outputPath) && !input.overwriteOutput) {
|
|
187
|
+
throw new Error(`출력 파일이 이미 존재합니다: ${outputPath}\noverwriteOutput=true로 명시해야 덮어씁니다.`);
|
|
188
|
+
}
|
|
189
|
+
const id = randomUUID();
|
|
190
|
+
const logPath = path.join(projectDir, '.jobs', `${id}.log`);
|
|
191
|
+
const lockPath = `${outputPath}.render.lock`;
|
|
192
|
+
mkdirSync(path.dirname(logPath), { recursive: true });
|
|
193
|
+
mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
194
|
+
const plan = buildFfmpegPlan(projectDir, outputPath, id);
|
|
195
|
+
const now = new Date().toISOString();
|
|
196
|
+
const job = {
|
|
197
|
+
id,
|
|
198
|
+
status: 'queued',
|
|
199
|
+
createdAt: now,
|
|
200
|
+
updatedAt: now,
|
|
201
|
+
projectDir,
|
|
202
|
+
outputPath,
|
|
203
|
+
logPath,
|
|
204
|
+
};
|
|
205
|
+
writeJsonAtomic(jobPath(projectDir, id), job);
|
|
206
|
+
try {
|
|
207
|
+
acquireOutputLock(lockPath, projectDir, id);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
updateJob(projectDir, id, { status: 'failed', error: error.message });
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
const logStream = createWriteStream(logPath, { flags: 'a' });
|
|
214
|
+
let child;
|
|
215
|
+
try {
|
|
216
|
+
child = spawn(ffmpeg, plan.args, {
|
|
217
|
+
cwd: projectDir,
|
|
218
|
+
windowsHide: true,
|
|
219
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
logStream.end();
|
|
224
|
+
unlinkSync(lockPath);
|
|
225
|
+
updateJob(projectDir, id, { status: 'failed', error: error.message });
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
child.stderr?.pipe(logStream);
|
|
229
|
+
updateJob(projectDir, id, { status: 'running', pid: child.pid });
|
|
230
|
+
let settled = false;
|
|
231
|
+
const finalize = (patch) => {
|
|
232
|
+
if (settled)
|
|
233
|
+
return;
|
|
234
|
+
settled = true;
|
|
235
|
+
logStream.end();
|
|
236
|
+
try {
|
|
237
|
+
if (existsSync(lockPath))
|
|
238
|
+
unlinkSync(lockPath);
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
updateJob(projectDir, id, patch);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
child.on('error', (error) => {
|
|
245
|
+
finalize({ status: 'failed', error: error.message });
|
|
246
|
+
});
|
|
247
|
+
child.on('close', (code) => {
|
|
248
|
+
finalize({
|
|
249
|
+
status: code === 0 ? 'completed' : 'failed',
|
|
250
|
+
exitCode: code,
|
|
251
|
+
error: code === 0 ? undefined : `FFmpeg가 종료 코드 ${code}로 실패했습니다.`,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
return JSON.parse(readFileSync(jobPath(projectDir, id), 'utf8'));
|
|
255
|
+
}
|
|
256
|
+
export function getRenderJob(projectDir, jobId) {
|
|
257
|
+
const filePath = jobPath(projectDir, jobId);
|
|
258
|
+
if (!existsSync(filePath))
|
|
259
|
+
throw new Error(`렌더 작업을 찾을 수 없습니다: ${jobId}`);
|
|
260
|
+
let job = parseFile(renderJobSchema, readJson(filePath), filePath);
|
|
261
|
+
const queuedStale = job.status === 'queued' && Date.now() - Date.parse(job.updatedAt) >= 60_000;
|
|
262
|
+
const runningDead = job.status === 'running' &&
|
|
263
|
+
Date.now() - Date.parse(job.updatedAt) >= 5_000 &&
|
|
264
|
+
!processAlive(job.pid);
|
|
265
|
+
if (queuedStale || runningDead) {
|
|
266
|
+
job = updateJob(projectDir, jobId, {
|
|
267
|
+
status: 'failed',
|
|
268
|
+
error: queuedStale
|
|
269
|
+
? '렌더 작업이 시작 대기 상태에서 중단되었습니다. 다시 렌더하세요.'
|
|
270
|
+
: '렌더 프로세스가 더 이상 실행 중이지 않습니다. 호스트 또는 MCP가 중단되었을 수 있습니다.',
|
|
271
|
+
});
|
|
272
|
+
const lockPath = `${job.outputPath}.render.lock`;
|
|
273
|
+
try {
|
|
274
|
+
if (existsSync(lockPath) && readFileSync(lockPath, 'utf8').trim() === jobId)
|
|
275
|
+
unlinkSync(lockPath);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// A concurrent render may have already replaced or removed the stale lock.
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
let logTail;
|
|
282
|
+
if (job.status === 'failed' && existsSync(job.logPath)) {
|
|
283
|
+
logTail = readTail(job.logPath).split(/\r?\n/).slice(-30).join('\n');
|
|
284
|
+
}
|
|
285
|
+
return { ...job, logTail };
|
|
286
|
+
}
|
|
287
|
+
function ffprobeFor(ffmpegPath) {
|
|
288
|
+
const configured = process.env.MIMI_SEED_FFPROBE_PATH;
|
|
289
|
+
if (configured)
|
|
290
|
+
return configured;
|
|
291
|
+
if (ffmpegPath && path.isAbsolute(ffmpegPath)) {
|
|
292
|
+
const ext = path.extname(ffmpegPath);
|
|
293
|
+
return path.join(path.dirname(ffmpegPath), `ffprobe${ext}`);
|
|
294
|
+
}
|
|
295
|
+
return 'ffprobe';
|
|
296
|
+
}
|
|
297
|
+
export async function validateVideo(filePath, ffmpegPath) {
|
|
298
|
+
if (!path.isAbsolute(filePath) || !existsSync(filePath)) {
|
|
299
|
+
throw new Error('filePath는 존재하는 절대경로여야 합니다.');
|
|
300
|
+
}
|
|
301
|
+
const ffprobe = ffprobeFor(ffmpegPath);
|
|
302
|
+
const { stdout } = await execFileAsync(ffprobe, [
|
|
303
|
+
'-v', 'error',
|
|
304
|
+
'-show_entries', 'format=duration,size,bit_rate,format_name:stream=index,codec_name,codec_type,width,height,r_frame_rate,pix_fmt',
|
|
305
|
+
'-of', 'json',
|
|
306
|
+
filePath,
|
|
307
|
+
], { timeout: 30_000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 });
|
|
308
|
+
const data = JSON.parse(stdout);
|
|
309
|
+
const streams = data.streams ?? [];
|
|
310
|
+
const video = streams.find((stream) => stream.codec_type === 'video');
|
|
311
|
+
const issues = [];
|
|
312
|
+
if (!video)
|
|
313
|
+
issues.push('비디오 스트림이 없습니다.');
|
|
314
|
+
if (video && video.codec_name !== 'h264')
|
|
315
|
+
issues.push(`권장 코덱 H.264가 아닙니다: ${String(video.codec_name)}`);
|
|
316
|
+
if (video && video.pix_fmt !== 'yuv420p')
|
|
317
|
+
issues.push(`권장 픽셀 포맷 yuv420p가 아닙니다: ${String(video.pix_fmt)}`);
|
|
318
|
+
return { valid: issues.length === 0, format: data.format ?? {}, streams, issues };
|
|
319
|
+
}
|
|
320
|
+
export const __testing = { srtTime, ffprobeFor, sanitizeSubtitleText, processAlive, acquireOutputLock, readTail };
|