@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
package/dist/resources.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
1
|
import { ensureFreshAccessToken } from './auth/google-auth.js';
|
|
2
|
+
import { readPackageRootText, readToolManifest } from './lib/package-root.js';
|
|
3
3
|
// assets/agent-guide.md = docs/agent-guide.md 의 배포용 사본 (npm 배포본에는 docs/ 가 없다).
|
|
4
4
|
// 갱신은 `npm run plugin:sync`, 드리프트는 prompts-resources.test.ts 가 잡는다.
|
|
5
5
|
// 읽기 실패는 정상 설치에서 불가능하다(files 화이트리스트에 포함) — 그래서 폴백은 가이드
|
|
@@ -13,109 +13,6 @@ const AGENT_GUIDE_FALLBACK = [
|
|
|
13
13
|
'가이드 전문: https://github.com/jeonghwanko/mimi-seed-sdk/blob/main/docs/agent-guide.md',
|
|
14
14
|
'최소 안전수칙: 스토어 제출·승격·삭제·공개 게시는 사용자 명시 동의 없이 실행하지 않는다.',
|
|
15
15
|
].join('\n');
|
|
16
|
-
function readPackageAsset(relativePath) {
|
|
17
|
-
try {
|
|
18
|
-
// src/ 와 dist/ 모두 패키지 루트 바로 아래라 ../ 가 같은 곳을 가리킨다 (index.ts 의 package.json 읽기와 동일 패턴).
|
|
19
|
-
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
/** 도메인 id → 라벨·필요 자격증명·한줄 요약. 키 집합은 tool-manifest.json 의 domains 와
|
|
26
|
-
* 일치해야 한다 (prompts-resources.test.ts 가 강제) — 도메인을 추가하면 여기도 추가할 것. */
|
|
27
|
-
const DOMAIN_SUMMARY = {
|
|
28
|
-
playstore: {
|
|
29
|
-
label: 'Google Play',
|
|
30
|
-
credential: 'Google OAuth (CI/헤드리스는 Play 서비스 계정)',
|
|
31
|
-
summary: '리스팅·트랙 릴리스·이미지·리뷰 답변·통계·서비스 계정 등록',
|
|
32
|
-
},
|
|
33
|
-
appstore: {
|
|
34
|
-
label: 'App Store Connect',
|
|
35
|
-
credential: 'ASC API 키 (mimi-seed auth appstore)',
|
|
36
|
-
summary: '버전·빌드 attach·What\'s New·스크린샷·IAP 심사 메타데이터·심사 제출',
|
|
37
|
-
},
|
|
38
|
-
firebase: {
|
|
39
|
-
label: 'Firebase',
|
|
40
|
-
credential: 'Google OAuth',
|
|
41
|
-
summary: '프로젝트/앱 생성·설정 파일 다운로드·서비스 활성화',
|
|
42
|
-
},
|
|
43
|
-
admob: {
|
|
44
|
-
label: 'AdMob',
|
|
45
|
-
credential: 'Google OAuth',
|
|
46
|
-
summary: '앱·광고 단위 생성, 오늘 수익·기간 리포트',
|
|
47
|
-
},
|
|
48
|
-
iam: {
|
|
49
|
-
label: 'Google Cloud IAM',
|
|
50
|
-
credential: 'Google OAuth',
|
|
51
|
-
summary: '서비스 계정 생성·키 발급·IAM 정책 바인딩',
|
|
52
|
-
},
|
|
53
|
-
bigquery: {
|
|
54
|
-
label: 'BigQuery',
|
|
55
|
-
credential: 'Google OAuth (또는 BigQuery 서비스 계정)',
|
|
56
|
-
summary: '쿼리 실행·데이터셋/테이블/스키마 조회',
|
|
57
|
-
},
|
|
58
|
-
ga4: {
|
|
59
|
-
label: 'Google Analytics 4',
|
|
60
|
-
credential: 'Google OAuth',
|
|
61
|
-
summary: '계정/속성·데이터 스트림 관리, 리포트 실행',
|
|
62
|
-
},
|
|
63
|
-
gsc: {
|
|
64
|
-
label: 'Search Console',
|
|
65
|
-
credential: 'Google OAuth',
|
|
66
|
-
summary: 'URL 검사·검색 성과 분석·사이트맵 제출',
|
|
67
|
-
},
|
|
68
|
-
googleads: {
|
|
69
|
-
label: 'Google Ads',
|
|
70
|
-
credential: 'Google Ads 설정 (mimi-seed auth googleads, adwords 스코프)',
|
|
71
|
-
summary: '캠페인 목록·캠페인/UAC 리포트',
|
|
72
|
-
},
|
|
73
|
-
ci: {
|
|
74
|
-
label: 'CI (GitHub Actions / GitLab)',
|
|
75
|
-
credential: 'GitHub/GitLab 토큰 (mimi-seed auth ci)',
|
|
76
|
-
summary: '워크플로 조회·빌드 트리거/상태/취소 — Jenkins 빌드는 대상 아님',
|
|
77
|
-
},
|
|
78
|
-
jenkins: {
|
|
79
|
-
label: 'Jenkins',
|
|
80
|
-
credential: 'Jenkins URL + API 토큰 (mimi-seed auth jenkins)',
|
|
81
|
-
summary: '크리덴셜·keystore 업로드·잡 생성/수정 — 빌드 트리거 도구는 없음',
|
|
82
|
-
},
|
|
83
|
-
android: {
|
|
84
|
-
label: 'Android 서명',
|
|
85
|
-
credential: '없음 (로컬 파일 작업)',
|
|
86
|
-
summary: 'keystore 생성·서명 설정·Jenkins 로 Play SA 업로드',
|
|
87
|
-
},
|
|
88
|
-
facebook: {
|
|
89
|
-
label: 'Facebook',
|
|
90
|
-
credential: 'Facebook 페이지 토큰 (mimi-seed auth facebook)',
|
|
91
|
-
summary: '페이지 텍스트/사진/링크 포스팅',
|
|
92
|
-
},
|
|
93
|
-
instagram: {
|
|
94
|
-
label: 'Instagram',
|
|
95
|
-
credential: 'Instagram 토큰 (mimi-seed auth instagram)',
|
|
96
|
-
summary: '사진·캐러셀·릴스 포스팅',
|
|
97
|
-
},
|
|
98
|
-
threads: {
|
|
99
|
-
label: 'Threads',
|
|
100
|
-
credential: 'Threads 토큰 (mimi-seed auth threads)',
|
|
101
|
-
summary: '텍스트/이미지 포스팅·토큰 갱신',
|
|
102
|
-
},
|
|
103
|
-
checks: {
|
|
104
|
-
label: '출시 점검',
|
|
105
|
-
credential: '점검 대상 스토어의 자격증명',
|
|
106
|
-
summary: '제출 전 위험 점검·스크린샷 규격 검증·릴리스 상태',
|
|
107
|
-
},
|
|
108
|
-
ai: {
|
|
109
|
-
label: 'AI 생성',
|
|
110
|
-
credential: 'ANTHROPIC_API_KEY 환경변수',
|
|
111
|
-
summary: '커밋 기반 릴리스 노트·리뷰 답변 초안 생성',
|
|
112
|
-
},
|
|
113
|
-
auth: {
|
|
114
|
-
label: '연결/진단',
|
|
115
|
-
credential: '없음 (이것이 셋업 도구)',
|
|
116
|
-
summary: '전체 연결 상태 스캔(mimi_seed_status)·OAuth 시작·원격 크리덴셜 동기화',
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
16
|
export function registerResources(server) {
|
|
120
17
|
server.resource('auth-status', 'mimi-seed://auth/status', { description: 'Google OAuth 인증 상태 — fresh / refreshed / expired / unauthenticated', mimeType: 'application/json' }, async () => {
|
|
121
18
|
const r = await ensureFreshAccessToken();
|
|
@@ -137,35 +34,42 @@ export function registerResources(server) {
|
|
|
137
34
|
server.resource('agent-guide', 'mimi-seed://agent/guide', {
|
|
138
35
|
description: 'Mimi Seed 에이전트 운영 규약 전문 (docs/agent-guide.md) — deferred 도구 로딩·ToolSearch select: 배치·호출 순서·비가역 액션 안전수칙',
|
|
139
36
|
mimeType: 'text/markdown',
|
|
140
|
-
}, async () =>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
37
|
+
}, async () => {
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
text = readPackageRootText('assets/agent-guide.md');
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
text = AGENT_GUIDE_FALLBACK;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
contents: [{
|
|
47
|
+
uri: 'mimi-seed://agent/guide',
|
|
48
|
+
mimeType: 'text/markdown',
|
|
49
|
+
text,
|
|
50
|
+
}],
|
|
51
|
+
};
|
|
52
|
+
});
|
|
147
53
|
server.resource('tools-catalog', 'mimi-seed://tools/catalog', {
|
|
148
54
|
description: '150+ 도구 전체 카탈로그 — 도메인별 도구 목록·필요 자격증명·한줄 요약. "mimi-seed 로 뭘 할 수 있어?" 에는 이 리소스를 읽고 답하세요.',
|
|
149
55
|
mimeType: 'application/json',
|
|
150
56
|
}, async () => {
|
|
151
57
|
// LLM 이 읽는 페이로드라 compact 로 직렬화한다 (pretty 들여쓰기는 ~40% 바이트 낭비).
|
|
58
|
+
// 도메인 메타데이터(label·credential·summary)는 tool-manifest.json 이 SSOT —
|
|
59
|
+
// 여기서는 그대로 서빙만 한다.
|
|
152
60
|
let payload;
|
|
153
61
|
try {
|
|
154
|
-
const
|
|
155
|
-
const manifest = JSON.parse(raw);
|
|
156
|
-
if (typeof manifest?.total !== 'number' || typeof manifest?.domains !== 'object' || manifest.domains === null) {
|
|
157
|
-
throw new Error('tool-manifest.json 의 형태가 예상과 다릅니다');
|
|
158
|
-
}
|
|
62
|
+
const manifest = readToolManifest();
|
|
159
63
|
payload = JSON.stringify({
|
|
160
64
|
total: manifest.total,
|
|
161
65
|
deferredHint: 'Claude Code 에서는 도구 schema 가 lazy 로드됩니다 — 호출 전 ToolSearch(query="select:<tool,...>") 로 선로드하세요. 상세: mimi-seed://agent/guide',
|
|
162
|
-
domains: Object.entries(manifest.domains).map(([id,
|
|
66
|
+
domains: Object.entries(manifest.domains).map(([id, d]) => ({
|
|
163
67
|
id,
|
|
164
|
-
label:
|
|
165
|
-
credential:
|
|
166
|
-
summary:
|
|
167
|
-
toolCount: tools.length,
|
|
168
|
-
tools,
|
|
68
|
+
label: d.label,
|
|
69
|
+
credential: d.credential,
|
|
70
|
+
summary: d.summary,
|
|
71
|
+
toolCount: d.tools.length,
|
|
72
|
+
tools: d.tools,
|
|
169
73
|
})),
|
|
170
74
|
});
|
|
171
75
|
}
|
package/dist/server.js
CHANGED
|
@@ -17,6 +17,7 @@ import { registerGscTools } from './registers/gsc.js';
|
|
|
17
17
|
import { registerGa4Tools } from './registers/ga4.js';
|
|
18
18
|
import { registerJenkinsTools } from './registers/jenkins.js';
|
|
19
19
|
import { registerAndroidTools } from './registers/android.js';
|
|
20
|
+
import { registerVideoTools } from './registers/video.js';
|
|
20
21
|
import { registerPrompts } from './prompts.js';
|
|
21
22
|
import { registerResources } from './resources.js';
|
|
22
23
|
/**
|
|
@@ -50,6 +51,7 @@ export function buildServer(version) {
|
|
|
50
51
|
registerGa4Tools(server);
|
|
51
52
|
registerJenkinsTools(server);
|
|
52
53
|
registerAndroidTools(server);
|
|
54
|
+
registerVideoTools(server);
|
|
53
55
|
registerPrompts(server);
|
|
54
56
|
registerResources(server);
|
|
55
57
|
return server;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export function writeJsonAtomic(filePath, value) {
|
|
5
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
6
|
+
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
7
|
+
try {
|
|
8
|
+
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
9
|
+
renameSync(tempPath, filePath);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
try {
|
|
13
|
+
unlinkSync(tempPath);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// The temp file may not have been created or may already have been renamed.
|
|
17
|
+
}
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function readJson(filePath, maxBytes = 20 * 1024 * 1024) {
|
|
22
|
+
try {
|
|
23
|
+
const size = statSync(filePath).size;
|
|
24
|
+
if (size > maxBytes) {
|
|
25
|
+
throw new Error(`JSON 파일이 ${Math.round(maxBytes / 1024 / 1024)}MB 제한을 초과합니다 (${size} bytes).`);
|
|
26
|
+
}
|
|
27
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
throw new Error(`JSON 파일을 읽거나 파싱할 수 없습니다: ${filePath}`, { cause: error });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function requireAbsolutePath(value, label) {
|
|
34
|
+
if (!path.isAbsolute(value))
|
|
35
|
+
throw new Error(`${label}은 절대경로여야 합니다.`);
|
|
36
|
+
return path.resolve(value);
|
|
37
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { VideoAspectRatio, VideoAsset, VideoAssetKind, VideoAssetManifest, VideoAssetSource, VideoProject, VideoScenePlan, VideoTimeline, VideoTimelineScene } from './types.js';
|
|
2
|
+
declare function dimensions(aspectRatio: VideoAspectRatio): {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
};
|
|
6
|
+
declare function normalizeScenes(raw: Partial<VideoScenePlan>[], targetDurationSec: number): VideoScenePlan[];
|
|
7
|
+
export interface PlanStoryInput {
|
|
8
|
+
projectDir: string;
|
|
9
|
+
title: string;
|
|
10
|
+
story: string;
|
|
11
|
+
language: string;
|
|
12
|
+
aspectRatio: VideoAspectRatio;
|
|
13
|
+
targetDurationSec: number;
|
|
14
|
+
style?: string;
|
|
15
|
+
overwrite?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function planStory(input: PlanStoryInput): Promise<VideoProject>;
|
|
18
|
+
export declare function loadProject(projectDir: string): VideoProject;
|
|
19
|
+
export declare function loadAssets(projectDir: string): VideoAssetManifest;
|
|
20
|
+
export declare function sha256File(filePath: string): string;
|
|
21
|
+
export declare function registerAsset(projectDir: string, asset: Omit<VideoAsset, 'createdAt' | 'sha256'>): VideoAsset;
|
|
22
|
+
export interface AddLocalAssetInput {
|
|
23
|
+
projectDir: string;
|
|
24
|
+
filePath: string;
|
|
25
|
+
kind: VideoAssetKind;
|
|
26
|
+
sourceType: Extract<VideoAssetSource, 'user-owned' | 'licensed'>;
|
|
27
|
+
license: string;
|
|
28
|
+
author?: string;
|
|
29
|
+
attribution?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function addLocalAsset(input: AddLocalAssetInput): VideoAsset;
|
|
32
|
+
export declare function buildTimeline(projectDir: string, scenes: VideoTimelineScene[], audioAssetId?: string): VideoTimeline;
|
|
33
|
+
export declare function loadTimeline(projectDir: string): VideoTimeline;
|
|
34
|
+
export declare const __testing: {
|
|
35
|
+
dimensions: typeof dimensions;
|
|
36
|
+
normalizeScenes: typeof normalizeScenes;
|
|
37
|
+
};
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readSync, statSync, unlinkSync, } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseJsonResponse, requireApiKey } from '../ai/client.js';
|
|
5
|
+
import { readJson, requireAbsolutePath, writeJsonAtomic } from './files.js';
|
|
6
|
+
import { assetManifestSchema, assetSchema, parseFile, projectSchema, timelineSchema } from './schemas.js';
|
|
7
|
+
const PROJECT_FILE = 'project.json';
|
|
8
|
+
const ASSET_FILE = 'assets.json';
|
|
9
|
+
const TIMELINE_FILE = 'timeline.json';
|
|
10
|
+
function dimensions(aspectRatio) {
|
|
11
|
+
if (aspectRatio === '16:9')
|
|
12
|
+
return { width: 1920, height: 1080 };
|
|
13
|
+
if (aspectRatio === '1:1')
|
|
14
|
+
return { width: 1080, height: 1080 };
|
|
15
|
+
return { width: 1080, height: 1920 };
|
|
16
|
+
}
|
|
17
|
+
function normalizeScenes(raw, targetDurationSec) {
|
|
18
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
19
|
+
throw new Error('AI가 유효한 장면 목록을 만들지 못했습니다. 다시 시도하세요.');
|
|
20
|
+
}
|
|
21
|
+
const maxScenes = Math.min(12, Math.max(1, Math.floor(targetDurationSec)));
|
|
22
|
+
const scenes = raw.slice(0, maxScenes).map((scene, index) => ({
|
|
23
|
+
id: scene.id?.trim() || `scene-${index + 1}`,
|
|
24
|
+
durationSec: Number.isFinite(scene.durationSec) && Number(scene.durationSec) > 0 ? Number(scene.durationSec) : 1,
|
|
25
|
+
narration: scene.narration?.trim() || '',
|
|
26
|
+
onScreenText: scene.onScreenText?.trim() || '',
|
|
27
|
+
visualPrompt: scene.visualPrompt?.trim() || '',
|
|
28
|
+
searchQuery: scene.searchQuery?.trim() || scene.visualPrompt?.trim() || '',
|
|
29
|
+
}));
|
|
30
|
+
const weightTotal = scenes.reduce((sum, scene) => sum + scene.durationSec, 0);
|
|
31
|
+
const distributable = targetDurationSec - scenes.length;
|
|
32
|
+
const normalized = scenes.map((scene) => ({
|
|
33
|
+
...scene,
|
|
34
|
+
durationSec: Math.round((1 + distributable * (scene.durationSec / weightTotal)) * 10) / 10,
|
|
35
|
+
}));
|
|
36
|
+
const roundedTotal = normalized.reduce((sum, scene) => sum + scene.durationSec, 0);
|
|
37
|
+
const adjustmentIndex = normalized.reduce((best, scene, index) => scene.durationSec > normalized[best].durationSec ? index : best, 0);
|
|
38
|
+
normalized[adjustmentIndex].durationSec = Math.round((normalized[adjustmentIndex].durationSec + targetDurationSec - roundedTotal) * 10) / 10;
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
export async function planStory(input) {
|
|
42
|
+
const projectDir = requireAbsolutePath(input.projectDir, 'projectDir');
|
|
43
|
+
const projectPath = path.join(projectDir, PROJECT_FILE);
|
|
44
|
+
if (existsSync(projectPath) && !input.overwrite) {
|
|
45
|
+
throw new Error(`이미 영상 프로젝트가 있습니다: ${projectPath}\noverwrite=true로 명시해야 덮어씁니다.`);
|
|
46
|
+
}
|
|
47
|
+
const client = requireApiKey();
|
|
48
|
+
const response = await client.messages.create({
|
|
49
|
+
model: 'claude-haiku-4-5-20251001',
|
|
50
|
+
max_tokens: 3000,
|
|
51
|
+
system: [
|
|
52
|
+
'당신은 숏폼 영상 스토리보드 디렉터입니다.',
|
|
53
|
+
'사용자의 이야기에서 핵심 메시지를 보존하고 장면별 시각 계획을 만드세요.',
|
|
54
|
+
'반드시 설명이나 마크다운 없이 유효한 JSON만 반환하세요.',
|
|
55
|
+
].join(' '),
|
|
56
|
+
messages: [{
|
|
57
|
+
role: 'user',
|
|
58
|
+
content: [
|
|
59
|
+
`제목: ${input.title}`,
|
|
60
|
+
`언어: ${input.language}`,
|
|
61
|
+
`목표 길이: ${input.targetDurationSec}초`,
|
|
62
|
+
`화면비: ${input.aspectRatio}`,
|
|
63
|
+
input.style ? `스타일: ${input.style}` : '',
|
|
64
|
+
'',
|
|
65
|
+
'이야기:',
|
|
66
|
+
input.story,
|
|
67
|
+
'',
|
|
68
|
+
'다음 JSON 형식으로 3~12개 장면을 만드세요:',
|
|
69
|
+
'{"scenes":[{"id":"scene-1","durationSec":5,"narration":"...","onScreenText":"...","visualPrompt":"...","searchQuery":"..."}]}',
|
|
70
|
+
'onScreenText는 짧게, visualPrompt는 이미지 생성용, searchQuery는 스톡 영상 검색용 영문 검색어로 작성하세요.',
|
|
71
|
+
].filter(Boolean).join('\n'),
|
|
72
|
+
}],
|
|
73
|
+
});
|
|
74
|
+
const text = response.content[0]?.type === 'text' ? response.content[0].text : '';
|
|
75
|
+
const parsed = parseJsonResponse(text);
|
|
76
|
+
const dims = dimensions(input.aspectRatio);
|
|
77
|
+
const project = parseFile(projectSchema, {
|
|
78
|
+
version: 1,
|
|
79
|
+
projectId: randomUUID(),
|
|
80
|
+
title: input.title,
|
|
81
|
+
story: input.story,
|
|
82
|
+
language: input.language,
|
|
83
|
+
createdAt: new Date().toISOString(),
|
|
84
|
+
settings: {
|
|
85
|
+
aspectRatio: input.aspectRatio,
|
|
86
|
+
...dims,
|
|
87
|
+
fps: 30,
|
|
88
|
+
targetDurationSec: input.targetDurationSec,
|
|
89
|
+
style: input.style,
|
|
90
|
+
},
|
|
91
|
+
scenes: normalizeScenes(parsed.scenes ?? [], input.targetDurationSec),
|
|
92
|
+
}, 'AI가 생성한 영상 프로젝트');
|
|
93
|
+
mkdirSync(projectDir, { recursive: true });
|
|
94
|
+
mkdirSync(path.join(projectDir, 'assets', 'stock'), { recursive: true });
|
|
95
|
+
mkdirSync(path.join(projectDir, 'assets', 'generated'), { recursive: true });
|
|
96
|
+
mkdirSync(path.join(projectDir, 'assets', 'local'), { recursive: true });
|
|
97
|
+
mkdirSync(path.join(projectDir, 'research'), { recursive: true });
|
|
98
|
+
mkdirSync(path.join(projectDir, 'render'), { recursive: true });
|
|
99
|
+
mkdirSync(path.join(projectDir, '.jobs'), { recursive: true });
|
|
100
|
+
if (existsSync(projectPath) && input.overwrite) {
|
|
101
|
+
const archiveDir = path.join(projectDir, '.history', `${Date.now()}-${project.projectId}`);
|
|
102
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
103
|
+
for (const name of [PROJECT_FILE, ASSET_FILE, TIMELINE_FILE]) {
|
|
104
|
+
const source = path.join(projectDir, name);
|
|
105
|
+
if (existsSync(source))
|
|
106
|
+
copyFileSync(source, path.join(archiveDir, name));
|
|
107
|
+
}
|
|
108
|
+
const oldTimeline = path.join(projectDir, TIMELINE_FILE);
|
|
109
|
+
if (existsSync(oldTimeline))
|
|
110
|
+
unlinkSync(oldTimeline);
|
|
111
|
+
}
|
|
112
|
+
writeJsonAtomic(projectPath, project);
|
|
113
|
+
writeJsonAtomic(path.join(projectDir, ASSET_FILE), {
|
|
114
|
+
version: 1,
|
|
115
|
+
projectId: project.projectId,
|
|
116
|
+
assets: [],
|
|
117
|
+
});
|
|
118
|
+
return project;
|
|
119
|
+
}
|
|
120
|
+
export function loadProject(projectDir) {
|
|
121
|
+
const dir = requireAbsolutePath(projectDir, 'projectDir');
|
|
122
|
+
const filePath = path.join(dir, PROJECT_FILE);
|
|
123
|
+
if (!existsSync(filePath))
|
|
124
|
+
throw new Error(`영상 프로젝트를 찾을 수 없습니다: ${filePath}`);
|
|
125
|
+
return parseFile(projectSchema, readJson(filePath), filePath);
|
|
126
|
+
}
|
|
127
|
+
export function loadAssets(projectDir) {
|
|
128
|
+
const dir = requireAbsolutePath(projectDir, 'projectDir');
|
|
129
|
+
const project = loadProject(dir);
|
|
130
|
+
const filePath = path.join(dir, ASSET_FILE);
|
|
131
|
+
if (!existsSync(filePath))
|
|
132
|
+
return { version: 1, projectId: project.projectId, assets: [] };
|
|
133
|
+
const manifest = parseFile(assetManifestSchema, readJson(filePath), filePath);
|
|
134
|
+
if (manifest.projectId !== project.projectId) {
|
|
135
|
+
throw new Error('assets.json이 현재 project.json과 다른 프로젝트에 속합니다. 프로젝트를 다시 계획하거나 자산 매니페스트를 복구하세요.');
|
|
136
|
+
}
|
|
137
|
+
return manifest;
|
|
138
|
+
}
|
|
139
|
+
export function sha256File(filePath) {
|
|
140
|
+
const hash = createHash('sha256');
|
|
141
|
+
const fd = openSync(filePath, 'r');
|
|
142
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
143
|
+
try {
|
|
144
|
+
let bytesRead = 0;
|
|
145
|
+
do {
|
|
146
|
+
bytesRead = readSync(fd, buffer, 0, buffer.length, null);
|
|
147
|
+
if (bytesRead > 0)
|
|
148
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
149
|
+
} while (bytesRead > 0);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
closeSync(fd);
|
|
153
|
+
}
|
|
154
|
+
return hash.digest('hex');
|
|
155
|
+
}
|
|
156
|
+
export function registerAsset(projectDir, asset) {
|
|
157
|
+
const dir = requireAbsolutePath(projectDir, 'projectDir');
|
|
158
|
+
loadProject(dir);
|
|
159
|
+
if (!path.isAbsolute(asset.path) || !existsSync(asset.path) || !statSync(asset.path).isFile()) {
|
|
160
|
+
throw new Error(`자산 파일은 존재하는 절대경로여야 합니다: ${asset.path}`);
|
|
161
|
+
}
|
|
162
|
+
const entry = parseFile(assetSchema, {
|
|
163
|
+
...asset,
|
|
164
|
+
path: path.resolve(asset.path),
|
|
165
|
+
sha256: sha256File(asset.path),
|
|
166
|
+
createdAt: new Date().toISOString(),
|
|
167
|
+
}, '등록할 영상 자산');
|
|
168
|
+
const manifest = loadAssets(dir);
|
|
169
|
+
manifest.assets = [...manifest.assets.filter((item) => item.id !== entry.id), entry];
|
|
170
|
+
writeJsonAtomic(path.join(dir, ASSET_FILE), manifest);
|
|
171
|
+
return entry;
|
|
172
|
+
}
|
|
173
|
+
export function addLocalAsset(input) {
|
|
174
|
+
const projectDir = requireAbsolutePath(input.projectDir, 'projectDir');
|
|
175
|
+
if (!path.isAbsolute(input.filePath) || !existsSync(input.filePath)) {
|
|
176
|
+
throw new Error('filePath는 존재하는 절대경로여야 합니다.');
|
|
177
|
+
}
|
|
178
|
+
const id = `local-${randomUUID()}`;
|
|
179
|
+
const ext = path.extname(input.filePath).toLowerCase();
|
|
180
|
+
const destination = path.join(projectDir, 'assets', 'local', `${id}${ext}`);
|
|
181
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
182
|
+
copyFileSync(input.filePath, destination);
|
|
183
|
+
return registerAsset(projectDir, {
|
|
184
|
+
id,
|
|
185
|
+
kind: input.kind,
|
|
186
|
+
sourceType: input.sourceType,
|
|
187
|
+
path: destination,
|
|
188
|
+
license: input.license,
|
|
189
|
+
author: input.author,
|
|
190
|
+
attribution: input.attribution,
|
|
191
|
+
allowedForRendering: true,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
export function buildTimeline(projectDir, scenes, audioAssetId) {
|
|
195
|
+
const dir = requireAbsolutePath(projectDir, 'projectDir');
|
|
196
|
+
const project = loadProject(dir);
|
|
197
|
+
const manifest = loadAssets(dir);
|
|
198
|
+
if (scenes.length === 0)
|
|
199
|
+
throw new Error('타임라인에는 장면이 한 개 이상 필요합니다.');
|
|
200
|
+
const byId = new Map(manifest.assets.map((asset) => [asset.id, asset]));
|
|
201
|
+
for (const scene of scenes) {
|
|
202
|
+
const asset = byId.get(scene.assetId);
|
|
203
|
+
if (!asset)
|
|
204
|
+
throw new Error(`등록되지 않은 assetId입니다: ${scene.assetId}`);
|
|
205
|
+
if (!asset.allowedForRendering || asset.sourceType === 'reference-only') {
|
|
206
|
+
throw new Error(`렌더링이 허용되지 않은 자산입니다: ${scene.assetId}`);
|
|
207
|
+
}
|
|
208
|
+
if (asset.kind !== 'image' && asset.kind !== 'video') {
|
|
209
|
+
throw new Error(`장면에는 이미지 또는 영상 자산만 사용할 수 있습니다: ${scene.assetId}`);
|
|
210
|
+
}
|
|
211
|
+
if (!Number.isFinite(scene.durationSec) || scene.durationSec <= 0) {
|
|
212
|
+
throw new Error(`장면 길이는 0보다 커야 합니다: ${scene.id}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (audioAssetId) {
|
|
216
|
+
const audio = byId.get(audioAssetId);
|
|
217
|
+
if (!audio || audio.kind !== 'audio' || !audio.allowedForRendering) {
|
|
218
|
+
throw new Error(`사용할 수 없는 오디오 자산입니다: ${audioAssetId}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const totalDurationSec = scenes.reduce((sum, scene) => sum + scene.durationSec, 0);
|
|
222
|
+
if (totalDurationSec > 300)
|
|
223
|
+
throw new Error(`타임라인 총 길이는 300초를 넘을 수 없습니다: ${totalDurationSec}초`);
|
|
224
|
+
const timeline = parseFile(timelineSchema, {
|
|
225
|
+
version: 1,
|
|
226
|
+
projectId: project.projectId,
|
|
227
|
+
createdAt: new Date().toISOString(),
|
|
228
|
+
width: project.settings.width,
|
|
229
|
+
height: project.settings.height,
|
|
230
|
+
fps: project.settings.fps,
|
|
231
|
+
totalDurationSec,
|
|
232
|
+
scenes,
|
|
233
|
+
audioAssetId,
|
|
234
|
+
}, '생성할 영상 타임라인');
|
|
235
|
+
writeJsonAtomic(path.join(dir, TIMELINE_FILE), timeline);
|
|
236
|
+
return timeline;
|
|
237
|
+
}
|
|
238
|
+
export function loadTimeline(projectDir) {
|
|
239
|
+
const dir = requireAbsolutePath(projectDir, 'projectDir');
|
|
240
|
+
const project = loadProject(dir);
|
|
241
|
+
const filePath = path.join(dir, TIMELINE_FILE);
|
|
242
|
+
if (!existsSync(filePath))
|
|
243
|
+
throw new Error(`타임라인을 찾을 수 없습니다: ${filePath}`);
|
|
244
|
+
const timeline = parseFile(timelineSchema, readJson(filePath), filePath);
|
|
245
|
+
if (timeline.projectId !== project.projectId) {
|
|
246
|
+
throw new Error('timeline.json이 현재 project.json과 다른 프로젝트에 속합니다. 타임라인을 다시 생성하세요.');
|
|
247
|
+
}
|
|
248
|
+
if (timeline.width !== project.settings.width ||
|
|
249
|
+
timeline.height !== project.settings.height ||
|
|
250
|
+
timeline.fps !== project.settings.fps) {
|
|
251
|
+
throw new Error('timeline.json의 출력 설정이 현재 프로젝트 설정과 다릅니다. 타임라인을 다시 생성하세요.');
|
|
252
|
+
}
|
|
253
|
+
return timeline;
|
|
254
|
+
}
|
|
255
|
+
export const __testing = { dimensions, normalizeScenes };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { VideoAsset } from './types.js';
|
|
2
|
+
export interface YouTubeReference {
|
|
3
|
+
videoId: string;
|
|
4
|
+
title: string;
|
|
5
|
+
description: string;
|
|
6
|
+
channelTitle: string;
|
|
7
|
+
publishedAt: string;
|
|
8
|
+
thumbnailUrl?: string;
|
|
9
|
+
url: string;
|
|
10
|
+
duration?: string;
|
|
11
|
+
viewCount?: string;
|
|
12
|
+
likeCount?: string;
|
|
13
|
+
license?: string;
|
|
14
|
+
sourceType: 'reference-only';
|
|
15
|
+
allowedForRendering: false;
|
|
16
|
+
untrustedExternalText: true;
|
|
17
|
+
query: string;
|
|
18
|
+
}
|
|
19
|
+
declare function sanitizeExternalText(value: string | undefined, maxLength: number): string;
|
|
20
|
+
export interface ResearchYouTubeInput {
|
|
21
|
+
projectDir: string;
|
|
22
|
+
queries?: string[];
|
|
23
|
+
maxResultsPerQuery?: number;
|
|
24
|
+
regionCode?: string;
|
|
25
|
+
publishedAfter?: string;
|
|
26
|
+
creativeCommonsOnly?: boolean;
|
|
27
|
+
order?: 'relevance' | 'viewCount' | 'date';
|
|
28
|
+
}
|
|
29
|
+
export declare function researchYouTube(input: ResearchYouTubeInput): Promise<YouTubeReference[]>;
|
|
30
|
+
export interface PexelsVideoResult {
|
|
31
|
+
id: number;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
duration: number;
|
|
35
|
+
pageUrl: string;
|
|
36
|
+
author?: string;
|
|
37
|
+
authorUrl?: string;
|
|
38
|
+
previewImage?: string;
|
|
39
|
+
downloadUrl?: string;
|
|
40
|
+
downloadWidth?: number;
|
|
41
|
+
downloadHeight?: number;
|
|
42
|
+
license: 'Pexels License';
|
|
43
|
+
attribution: string;
|
|
44
|
+
query: string;
|
|
45
|
+
}
|
|
46
|
+
interface PexelsSearchResponse {
|
|
47
|
+
videos?: Array<{
|
|
48
|
+
id: number;
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
duration: number;
|
|
52
|
+
url: string;
|
|
53
|
+
image?: string;
|
|
54
|
+
user?: {
|
|
55
|
+
name?: string;
|
|
56
|
+
url?: string;
|
|
57
|
+
};
|
|
58
|
+
video_files?: Array<{
|
|
59
|
+
link?: string;
|
|
60
|
+
width?: number;
|
|
61
|
+
height?: number;
|
|
62
|
+
file_type?: string;
|
|
63
|
+
}>;
|
|
64
|
+
}>;
|
|
65
|
+
}
|
|
66
|
+
declare function bestPexelsFile(files?: NonNullable<PexelsSearchResponse['videos']>[number]['video_files']): {
|
|
67
|
+
link?: string;
|
|
68
|
+
width?: number;
|
|
69
|
+
height?: number;
|
|
70
|
+
file_type?: string;
|
|
71
|
+
};
|
|
72
|
+
export interface SearchStockInput {
|
|
73
|
+
projectDir: string;
|
|
74
|
+
queries?: string[];
|
|
75
|
+
orientation?: 'landscape' | 'portrait' | 'square';
|
|
76
|
+
perQuery?: number;
|
|
77
|
+
}
|
|
78
|
+
export declare function searchStock(input: SearchStockInput): Promise<PexelsVideoResult[]>;
|
|
79
|
+
declare function validatePexelsUrl(value: string): URL;
|
|
80
|
+
declare function hasMp4Signature(filePath: string): boolean;
|
|
81
|
+
export interface DownloadStockSelection {
|
|
82
|
+
id: number;
|
|
83
|
+
downloadUrl: string;
|
|
84
|
+
pageUrl: string;
|
|
85
|
+
author?: string;
|
|
86
|
+
attribution?: string;
|
|
87
|
+
}
|
|
88
|
+
export declare function downloadStockAssets(projectDir: string, selections: DownloadStockSelection[]): Promise<VideoAsset[]>;
|
|
89
|
+
export interface GenerateImageInput {
|
|
90
|
+
projectDir: string;
|
|
91
|
+
prompt: string;
|
|
92
|
+
name?: string;
|
|
93
|
+
model?: 'gpt-image-2' | 'gpt-image-1.5' | 'gpt-image-1' | 'gpt-image-1-mini';
|
|
94
|
+
quality?: 'low' | 'medium' | 'high' | 'auto';
|
|
95
|
+
size?: '1024x1024' | '1024x1536' | '1536x1024';
|
|
96
|
+
}
|
|
97
|
+
export declare function generateImage(input: GenerateImageInput): Promise<VideoAsset>;
|
|
98
|
+
export declare const __testing: {
|
|
99
|
+
bestPexelsFile: typeof bestPexelsFile;
|
|
100
|
+
validatePexelsUrl: typeof validatePexelsUrl;
|
|
101
|
+
hasMp4Signature: typeof hasMp4Signature;
|
|
102
|
+
sanitizeExternalText: typeof sanitizeExternalText;
|
|
103
|
+
};
|
|
104
|
+
export {};
|