@yoonion/mimi-seed-mcp 0.18.1 → 0.19.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 +1 -1
- package/assets/agent-guide.md +7 -6
- package/dist/registers/video.js +38 -5
- package/dist/video/captions.d.ts +34 -0
- package/dist/video/captions.js +98 -0
- package/dist/video/project.d.ts +19 -2
- package/dist/video/project.js +45 -2
- package/dist/video/render.d.ts +1 -6
- package/dist/video/render.js +9 -38
- package/dist/video/schemas.d.ts +87 -0
- package/dist/video/schemas.js +11 -0
- package/dist/video/types.d.ts +10 -0
- package/package.json +1 -1
- package/tool-manifest.json +2 -1
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
99
99
|
| Android 서명 | 3 | `android_signing_setup` / `android_generate_keystore` / `jenkins_upload_playstore_sa` |
|
|
100
100
|
| 인증 | 4 | `mimi_seed_status` / `mimi_seed_auth_start` / `mimi_seed_auth_status` / `mimi_seed_remote_sync_credentials` |
|
|
101
101
|
| AI (Claude) | 2 | `generate_release_notes_from_commits` / `generate_review_reply` |
|
|
102
|
-
| 영상 제작·YouTube |
|
|
102
|
+
| 영상 제작·YouTube | 15 | `youtube_upload_video` / `youtube_get_video_status` / `youtube_update_video_privacy` / `video_plan_from_story` / `video_render` |
|
|
103
103
|
|
|
104
104
|
> 인앱 결제(IAP·구독) 도구는 위 Play Store·App Store 카운트에 포함됩니다 — `appstore_create_inapp_purchase` · `appstore_update_product_review_note` · `appstore_upload_product_review_screenshot` 등.
|
|
105
105
|
> 전체 카탈로그(항상 최신): [`docs/domain/tool-catalog.md`](../../docs/domain/tool-catalog.md)
|
package/assets/agent-guide.md
CHANGED
|
@@ -80,7 +80,7 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
|
|
|
80
80
|
| CI (GitHub/GitLab) | `select:ci_save_config,ci_list_workflows,ci_trigger_build,ci_get_build_status,ci_list_recent_builds,ci_cancel_build` |
|
|
81
81
|
| Android signing / keystore | `select:android_signing_setup,android_generate_keystore,jenkins_upload_keystore,jenkins_upload_playstore_sa` |
|
|
82
82
|
| Service account end-to-end | `select:iam_list_service_accounts,iam_create_service_account,iam_list_keys,iam_create_key,iam_add_iam_policy_binding,setup_playstore_connection,playstore_register_service_account,playstore_verify_service_account,playstore_list_service_accounts,playstore_delete_service_account` |
|
|
83
|
-
| Story → researched video | `select:video_plan_from_story,video_research_youtube,video_search_stock_assets,video_synthesize_research,video_download_stock_assets,video_generate_image,video_add_local_asset,video_build_timeline,video_render,video_job_status,video_validate` |
|
|
83
|
+
| Story → researched video | `select:video_save_plan,video_plan_from_story,video_research_youtube,video_search_stock_assets,video_synthesize_research,video_download_stock_assets,video_generate_image,video_add_local_asset,video_build_timeline,video_render,video_job_status,video_validate` |
|
|
84
84
|
| YouTube upload / publish | `select:youtube_upload_video,youtube_get_video_status,youtube_update_video_privacy,mimi_seed_auth_start` |
|
|
85
85
|
|
|
86
86
|
---
|
|
@@ -191,7 +191,7 @@ per-domain inventory is [`docs/domain/tool-catalog.md`](domain/tool-catalog.md).
|
|
|
191
191
|
| **TikTok Business** | `tiktok_business_auth_status` · `tiktok_business_plan_video_post` · `tiktok_business_publish_video` · `tiktok_business_get_publish_status` |
|
|
192
192
|
| **Checks** | `playstore_check_submission_risks` · `appstore_check_submission_risks` · `screenshot_validate` · `release_status` |
|
|
193
193
|
| **AI / Auth** | `generate_release_notes_from_commits` · `generate_review_reply` · `mimi_seed_status` · `mimi_seed_auth_start` · `mimi_seed_auth_status` · `mimi_seed_remote_sync_credentials` |
|
|
194
|
-
| **Video production** | `youtube_upload_video` · `youtube_get_video_status` · `youtube_update_video_privacy` · `video_plan_from_story` · `video_research_youtube` · `video_search_stock_assets` · `video_synthesize_research` · `video_generate_image` · `video_build_timeline` · `video_render` · `video_job_status` · `video_validate` |
|
|
194
|
+
| **Video production** | `youtube_upload_video` · `youtube_get_video_status` · `youtube_update_video_privacy` · `video_save_plan` · `video_plan_from_story` · `video_research_youtube` · `video_search_stock_assets` · `video_synthesize_research` · `video_generate_image` · `video_build_timeline` · `video_render` · `video_job_status` · `video_validate` |
|
|
195
195
|
|
|
196
196
|
---
|
|
197
197
|
|
|
@@ -218,15 +218,16 @@ For visual production, use the bundled `video-create-publish` skill. It requires
|
|
|
218
218
|
aspect-ratio-specific human-safe crops, video-native motion, an original-resolution frame review, and a saved
|
|
219
219
|
contact sheet; codec validation alone is not a quality pass.
|
|
220
220
|
|
|
221
|
-
1. `
|
|
221
|
+
1. `video_save_plan` — save an agent-authored storyboard (no API key; free-path default). `video_plan_from_story` is the metered alternative when `ANTHROPIC_API_KEY` is configured.
|
|
222
222
|
2. `video_research_youtube` — collect reference-only metadata; never treat a result as a render asset.
|
|
223
223
|
3. `video_search_stock_assets` — find licensed Pexels candidates.
|
|
224
224
|
4. `video_synthesize_research` — combine metadata with any direct human/agent observations. Treat its output as
|
|
225
225
|
metadata-bounded guidance, not proof that the source videos were watched.
|
|
226
226
|
5. Preview then confirm `video_download_stock_assets`; use `video_generate_image(confirm=false)` before any paid
|
|
227
|
-
generation and call it again with `confirm=true` only after approval.
|
|
228
|
-
`
|
|
229
|
-
|
|
227
|
+
generation and call it again with `confirm=true` only after approval. On the free path, generate scene images
|
|
228
|
+
with the local `codex` CLI (ChatGPT subscription) instead and register them — like all user-owned media —
|
|
229
|
+
through `video_add_local_asset` with the ownership/license basis.
|
|
230
|
+
6. `video_build_timeline` — provenance-gated scene assignment. Captions burn in the shorts style (bold outline, `**keyword**` highlight); tune with `captionStyle`.
|
|
230
231
|
7. Preview then confirm `video_render`; poll `video_job_status`, then run `video_validate` on the completed MP4.
|
|
231
232
|
|
|
232
233
|
> **Mimi Seed does not compile app binaries.** It manages metadata, store releases, and
|
package/dist/registers/video.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { addLocalAsset, buildTimeline, loadProject, loadTimeline, planStory, } from '../video/project.js';
|
|
2
|
+
import { addLocalAsset, buildTimeline, loadProject, loadTimeline, planStory, savePlan, } from '../video/project.js';
|
|
3
3
|
import { downloadStockAssets, generateImage, researchYouTube, searchStock, } from '../video/providers.js';
|
|
4
4
|
import { getRenderJob, startRender, validateVideo } from '../video/render.js';
|
|
5
5
|
import { synthesizeResearch } from '../video/research.js';
|
|
@@ -67,6 +67,29 @@ export function registerVideoTools(server) {
|
|
|
67
67
|
style: z.string().max(10_000).optional().describe('시각 스타일과 톤'),
|
|
68
68
|
overwrite: z.boolean().default(false).describe('기존 메타데이터를 .history에 보관하고 새 프로젝트로 덮어쓸지 여부'),
|
|
69
69
|
}, async (input) => text(await planStory(input)));
|
|
70
|
+
server.tool('video_save_plan', [
|
|
71
|
+
'에이전트가 직접 작성한 장면별 스토리보드를 project.json으로 저장합니다. API 키가 필요 없는 video_plan_from_story의 무료 대안입니다.',
|
|
72
|
+
'스토리보드(장면 분할·내레이션·화면 문구·시각 프롬프트)는 호출 전에 에이전트가 직접 설계하세요.',
|
|
73
|
+
'장면 durationSec 합계는 targetDurationSec와 일치해야 합니다(허용 오차 0.1초).',
|
|
74
|
+
'기존 project.json은 overwrite=true 없이는 덮어쓰지 않습니다.',
|
|
75
|
+
].join(' '), {
|
|
76
|
+
projectDir: absolutePath,
|
|
77
|
+
title: z.string().min(1).max(500).describe('영상 제목'),
|
|
78
|
+
story: z.string().min(1).max(200_000).describe('영상으로 만들 story 원문'),
|
|
79
|
+
language: z.string().default('ko').describe('내레이션과 화면 문구 언어'),
|
|
80
|
+
aspectRatio: z.enum(['9:16', '16:9', '1:1']).default('9:16').describe('출력 화면비'),
|
|
81
|
+
targetDurationSec: z.number().min(5).max(300).default(30).describe('목표 영상 길이(초)'),
|
|
82
|
+
scenes: z.array(z.object({
|
|
83
|
+
id: z.string().min(1).max(100).optional(),
|
|
84
|
+
durationSec: z.number().positive().max(300),
|
|
85
|
+
narration: z.string().max(10_000).optional(),
|
|
86
|
+
onScreenText: z.string().max(2_000).optional().describe('화면 자막. **단어** 마크업으로 키워드 하이라이트'),
|
|
87
|
+
visualPrompt: z.string().max(10_000).optional().describe('이미지 생성용 프롬프트'),
|
|
88
|
+
searchQuery: z.string().max(1_000).optional().describe('스톡 영상 검색용 영문 검색어'),
|
|
89
|
+
})).min(1).max(12),
|
|
90
|
+
style: z.string().max(10_000).optional().describe('시각 스타일과 톤'),
|
|
91
|
+
overwrite: z.boolean().default(false).describe('기존 메타데이터를 .history에 보관하고 새 프로젝트로 덮어쓸지 여부'),
|
|
92
|
+
}, async (input) => text(savePlan(input)));
|
|
70
93
|
server.tool('video_research_youtube', [
|
|
71
94
|
'스토리보드 검색어나 지정 검색어로 YouTube 유사 영상을 조사합니다.',
|
|
72
95
|
'YOUTUBE_API_KEY가 필요하며 결과는 research/youtube.json에 저장됩니다.',
|
|
@@ -157,21 +180,31 @@ export function registerVideoTools(server) {
|
|
|
157
180
|
server.tool('video_build_timeline', [
|
|
158
181
|
'등록된 이미지·영상 자산을 장면 순서와 길이에 맞춰 timeline.json으로 조합합니다.',
|
|
159
182
|
'assets.json에서 allowedForRendering=true이고 reference-only가 아닌 자산만 허용합니다.',
|
|
160
|
-
'화면 문구는 최종 렌더에서
|
|
183
|
+
'화면 문구는 최종 렌더에서 쇼츠 스타일 자막(굵은 폰트·흰 글자·검정 외곽선)으로 번인되며, onScreenText 안의 **단어** 마크업은 하이라이트 컬러(기본 노랑)로 강조됩니다.',
|
|
184
|
+
'한 장면 자막은 3~5어절·최대 2줄로 짧게 쓰고, 하이라이트는 문장당 한 단어만 지정하세요.',
|
|
161
185
|
].join(' '), {
|
|
162
186
|
projectDir: absolutePath,
|
|
163
187
|
scenes: z.array(z.object({
|
|
164
188
|
id: z.string().min(1),
|
|
165
189
|
assetId: z.string().min(1),
|
|
166
190
|
durationSec: z.number().positive().max(300),
|
|
167
|
-
onScreenText: z.string().optional(),
|
|
191
|
+
onScreenText: z.string().optional().describe('번인 자막. **단어**로 키워드 1개 하이라이트, \\n으로 의미 단위 줄바꿈'),
|
|
168
192
|
narration: z.string().optional(),
|
|
169
193
|
})).min(1).max(30),
|
|
170
194
|
audioAssetId: z.string().optional(),
|
|
171
|
-
|
|
195
|
+
captionStyle: z.object({
|
|
196
|
+
preset: z.enum(['shorts', 'box']).optional().describe('shorts(기본): 외곽선 자막+하이라이트 / box: 반투명 박스 자막(튜토리얼용)'),
|
|
197
|
+
fontName: z.string().min(1).max(200).optional().describe('설치된 굵은 한글 고딕 폰트명(예: Pretendard, 에스코어드림). 생략 시 Malgun Gothic Bold'),
|
|
198
|
+
fontSizePx: z.number().int().min(24).max(400).optional().describe('생략 시 세로 영상은 높이의 4.2%(1080×1920 기준 81px)'),
|
|
199
|
+
textColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
200
|
+
outlineColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
201
|
+
highlightColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('**단어** 마크업 색. 기본 #FFD400'),
|
|
202
|
+
position: z.enum(['lower', 'lower-middle', 'center']).optional().describe('생략 시 9:16은 lower-middle(하단-중앙 1/3, 쇼츠 UI 안전영역)'),
|
|
203
|
+
}).optional().describe('자막 스타일. 생략하면 쇼츠 기본 스타일'),
|
|
204
|
+
}, async ({ projectDir, scenes, audioAssetId, captionStyle }) => text(buildTimeline(projectDir, scenes, audioAssetId, captionStyle)));
|
|
172
205
|
server.tool('video_render', [
|
|
173
206
|
'timeline.json과 등록 자산을 FFmpeg로 합성해 MP4 렌더 작업을 시작합니다.',
|
|
174
|
-
'이미지/영상 크롭, 장면 연결,
|
|
207
|
+
'이미지/영상 크롭, 장면 연결, 쇼츠 스타일 자막 번인(timeline.json의 captionStyle 적용), 선택적 배경음, H.264/yuv420p 출력을 지원합니다.',
|
|
175
208
|
'confirm=false는 계획만 반환합니다. CPU를 사용하는 로컬 쓰기 작업이므로 승인 후 confirm=true로 호출하세요.',
|
|
176
209
|
'즉시 jobId를 반환하므로 video_job_status로 완료 여부를 확인하세요.',
|
|
177
210
|
].join(' '), {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { VideoCaptionStyle, VideoTimeline } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* 쇼츠 자막 스타일 시스템 — SRT + force_style(Arial 18px) 하드코딩을 대체한다.
|
|
4
|
+
*
|
|
5
|
+
* 기본 preset 'shorts'는 2026 숏폼 표준(Bold Highlight 스타일)을 따른다:
|
|
6
|
+
* 굵은 고딕 + 흰 글자 + 검정 외곽선(불투명 박스 없음), 9:16에서는 하단-중앙 1/3 배치,
|
|
7
|
+
* `**단어**` 마크업으로 문장당 키워드 1개만 하이라이트 컬러.
|
|
8
|
+
* 'box' preset은 튜토리얼용 반투명 박스 자막(Subtitle Block 스타일)이다.
|
|
9
|
+
*/
|
|
10
|
+
export type CaptionPosition = 'lower' | 'lower-middle' | 'center';
|
|
11
|
+
export interface ResolvedCaptionStyle {
|
|
12
|
+
fontName: string;
|
|
13
|
+
fontSizePx: number;
|
|
14
|
+
textColor: string;
|
|
15
|
+
outlineColor: string;
|
|
16
|
+
highlightColor: string;
|
|
17
|
+
position: CaptionPosition;
|
|
18
|
+
boxed: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function resolveCaptionStyle(width: number, height: number, style?: VideoCaptionStyle): ResolvedCaptionStyle;
|
|
21
|
+
/** #RRGGBB → ASS &HAABBGGRR. alpha 00=불투명, FF=투명. */
|
|
22
|
+
export declare function assColor(hex: string, alpha?: string): string;
|
|
23
|
+
/** ASS 태그·마크업 주입을 막는다. `**`는 하이라이트 마크업으로 살려둔다. */
|
|
24
|
+
declare function sanitizeCaptionText(value: string): string;
|
|
25
|
+
declare function assTime(seconds: number): string;
|
|
26
|
+
/** `**단어**` → 하이라이트 컬러 런. 줄바꿈은 \N. */
|
|
27
|
+
declare function renderEventText(raw: string, style: ResolvedCaptionStyle): string;
|
|
28
|
+
export declare function formatAss(timeline: VideoTimeline, style: ResolvedCaptionStyle): string;
|
|
29
|
+
export declare const __testing: {
|
|
30
|
+
sanitizeCaptionText: typeof sanitizeCaptionText;
|
|
31
|
+
assTime: typeof assTime;
|
|
32
|
+
renderEventText: typeof renderEventText;
|
|
33
|
+
};
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export function resolveCaptionStyle(width, height, style) {
|
|
2
|
+
const preset = style?.preset ?? 'shorts';
|
|
3
|
+
const vertical = height > width;
|
|
4
|
+
return {
|
|
5
|
+
// Malgun Gothic은 어떤 Windows에도 있는 폴백. 스킬은 설치된 굵은 고딕(Pretendard 등)을 우선 지정한다.
|
|
6
|
+
fontName: style?.fontName ?? 'Malgun Gothic',
|
|
7
|
+
// 세로 쇼츠 기준 height의 4.2% ≈ 81px(1080×1920) — 숏폼 권장(55~75pt)과 한국어 예능 자막(72~96px) 사이.
|
|
8
|
+
fontSizePx: style?.fontSizePx ?? Math.round(height * (vertical ? 0.042 : 0.055)),
|
|
9
|
+
textColor: style?.textColor ?? '#FFFFFF',
|
|
10
|
+
outlineColor: style?.outlineColor ?? '#000000',
|
|
11
|
+
highlightColor: style?.highlightColor ?? '#FFD400',
|
|
12
|
+
position: style?.position ?? (preset === 'shorts' && vertical ? 'lower-middle' : 'lower'),
|
|
13
|
+
boxed: preset === 'box',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** #RRGGBB → ASS &HAABBGGRR. alpha 00=불투명, FF=투명. */
|
|
17
|
+
export function assColor(hex, alpha = '00') {
|
|
18
|
+
const match = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(hex);
|
|
19
|
+
if (!match)
|
|
20
|
+
throw new Error(`잘못된 색상 형식입니다(#RRGGBB): ${hex}`);
|
|
21
|
+
const [, r, g, b] = match;
|
|
22
|
+
return `&H${alpha}${b}${g}${r}`.toUpperCase();
|
|
23
|
+
}
|
|
24
|
+
/** ASS 태그·마크업 주입을 막는다. `**`는 하이라이트 마크업으로 살려둔다. */
|
|
25
|
+
function sanitizeCaptionText(value) {
|
|
26
|
+
return value
|
|
27
|
+
.replace(/\r/g, '')
|
|
28
|
+
.replace(/\n{2,}/g, '\n')
|
|
29
|
+
.replace(/\\/g, '\')
|
|
30
|
+
.replace(/</g, '<')
|
|
31
|
+
.replace(/>/g, '>')
|
|
32
|
+
.replace(/{/g, '{')
|
|
33
|
+
.replace(/}/g, '}')
|
|
34
|
+
.trim();
|
|
35
|
+
}
|
|
36
|
+
function assTime(seconds) {
|
|
37
|
+
const cs = Math.max(0, Math.round(seconds * 100));
|
|
38
|
+
const hours = Math.floor(cs / 360_000);
|
|
39
|
+
const minutes = Math.floor((cs % 360_000) / 6_000);
|
|
40
|
+
const secs = Math.floor((cs % 6_000) / 100);
|
|
41
|
+
const centis = cs % 100;
|
|
42
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
43
|
+
return `${hours}:${pad(minutes)}:${pad(secs)}.${pad(centis)}`;
|
|
44
|
+
}
|
|
45
|
+
/** `**단어**` → 하이라이트 컬러 런. 줄바꿈은 \N. */
|
|
46
|
+
function renderEventText(raw, style) {
|
|
47
|
+
const primary = assColor(style.textColor);
|
|
48
|
+
const highlight = assColor(style.highlightColor);
|
|
49
|
+
return sanitizeCaptionText(raw)
|
|
50
|
+
.replace(/\*\*([^*\n]+)\*\*/g, `{\\1c${highlight}&}$1{\\1c${primary}&}`)
|
|
51
|
+
.replace(/\n/g, '\\N');
|
|
52
|
+
}
|
|
53
|
+
export function formatAss(timeline, style) {
|
|
54
|
+
const { width, height } = timeline;
|
|
55
|
+
const vertical = height > width;
|
|
56
|
+
const alignment = style.position === 'center' ? 5 : 2;
|
|
57
|
+
const marginX = Math.round(width * 0.09);
|
|
58
|
+
const marginV = style.position === 'center'
|
|
59
|
+
? 0
|
|
60
|
+
: style.position === 'lower-middle'
|
|
61
|
+
? Math.round(height * 0.28)
|
|
62
|
+
: Math.round(height * (vertical ? 0.12 : 0.08));
|
|
63
|
+
// 한글은 받침 내부 공간이 좁아 외곽선을 폰트 크기의 5.5%로 제한한다. box는 outline 값이 패딩이 된다.
|
|
64
|
+
const outlinePx = style.boxed
|
|
65
|
+
? Math.round(style.fontSizePx * 0.25)
|
|
66
|
+
: Math.max(2, Math.round(style.fontSizePx * 0.055));
|
|
67
|
+
const outlineColour = style.boxed ? assColor(style.outlineColor, '50') : assColor(style.outlineColor);
|
|
68
|
+
const events = [];
|
|
69
|
+
let cursor = 0;
|
|
70
|
+
for (const scene of timeline.scenes) {
|
|
71
|
+
const start = cursor;
|
|
72
|
+
cursor += scene.durationSec;
|
|
73
|
+
const text = renderEventText(scene.onScreenText ?? '', style);
|
|
74
|
+
if (!text)
|
|
75
|
+
continue;
|
|
76
|
+
events.push(`Dialogue: 0,${assTime(start)},${assTime(cursor)},Caption,,0,0,0,{\\fad(120,80)}${text}`);
|
|
77
|
+
}
|
|
78
|
+
if (events.length === 0)
|
|
79
|
+
return '';
|
|
80
|
+
return [
|
|
81
|
+
'[Script Info]',
|
|
82
|
+
'ScriptType: v4.00+',
|
|
83
|
+
`PlayResX: ${width}`,
|
|
84
|
+
`PlayResY: ${height}`,
|
|
85
|
+
'WrapStyle: 0',
|
|
86
|
+
'ScaledBorderAndShadow: yes',
|
|
87
|
+
'',
|
|
88
|
+
'[V4+ Styles]',
|
|
89
|
+
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
|
|
90
|
+
`Style: Caption,${style.fontName},${style.fontSizePx},${assColor(style.textColor)},${assColor(style.textColor)},${outlineColour},&H80000000,-1,0,0,0,100,100,0,0,${style.boxed ? 3 : 1},${outlinePx},0,${alignment},${marginX},${marginX},${marginV},1`,
|
|
91
|
+
'',
|
|
92
|
+
'[Events]',
|
|
93
|
+
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Text',
|
|
94
|
+
...events,
|
|
95
|
+
'',
|
|
96
|
+
].join('\n');
|
|
97
|
+
}
|
|
98
|
+
export const __testing = { sanitizeCaptionText, assTime, renderEventText };
|
package/dist/video/project.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { VideoAspectRatio, VideoAsset, VideoAssetKind, VideoAssetManifest, VideoAssetSource, VideoProject, VideoScenePlan, VideoTimeline, VideoTimelineScene } from './types.js';
|
|
1
|
+
import type { VideoAspectRatio, VideoAsset, VideoAssetKind, VideoAssetManifest, VideoAssetSource, VideoCaptionStyle, VideoProject, VideoScenePlan, VideoTimeline, VideoTimelineScene } from './types.js';
|
|
2
2
|
declare function dimensions(aspectRatio: VideoAspectRatio): {
|
|
3
3
|
width: number;
|
|
4
4
|
height: number;
|
|
@@ -15,6 +15,23 @@ export interface PlanStoryInput {
|
|
|
15
15
|
overwrite?: boolean;
|
|
16
16
|
}
|
|
17
17
|
export declare function planStory(input: PlanStoryInput): Promise<VideoProject>;
|
|
18
|
+
export interface SavePlanInput {
|
|
19
|
+
projectDir: string;
|
|
20
|
+
title: string;
|
|
21
|
+
story: string;
|
|
22
|
+
language: string;
|
|
23
|
+
aspectRatio: VideoAspectRatio;
|
|
24
|
+
targetDurationSec: number;
|
|
25
|
+
scenes: Array<Partial<VideoScenePlan> & Pick<VideoScenePlan, 'durationSec'>>;
|
|
26
|
+
style?: string;
|
|
27
|
+
overwrite?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 에이전트(구독 세션)가 직접 작성한 스토리보드를 API 키 없이 project.json으로 저장한다.
|
|
31
|
+
* video_plan_from_story(ANTHROPIC_API_KEY 필요)의 무료 대안.
|
|
32
|
+
* 장면 길이 합계가 targetDurationSec와 일치해야 projectSchema 검증을 통과한다.
|
|
33
|
+
*/
|
|
34
|
+
export declare function savePlan(input: SavePlanInput): VideoProject;
|
|
18
35
|
export declare function loadProject(projectDir: string): VideoProject;
|
|
19
36
|
export declare function loadAssets(projectDir: string): VideoAssetManifest;
|
|
20
37
|
export declare function sha256File(filePath: string): string;
|
|
@@ -29,7 +46,7 @@ export interface AddLocalAssetInput {
|
|
|
29
46
|
attribution?: string;
|
|
30
47
|
}
|
|
31
48
|
export declare function addLocalAsset(input: AddLocalAssetInput): VideoAsset;
|
|
32
|
-
export declare function buildTimeline(projectDir: string, scenes: VideoTimelineScene[], audioAssetId?: string): VideoTimeline;
|
|
49
|
+
export declare function buildTimeline(projectDir: string, scenes: VideoTimelineScene[], audioAssetId?: string, captionStyle?: VideoCaptionStyle): VideoTimeline;
|
|
33
50
|
export declare function loadTimeline(projectDir: string): VideoTimeline;
|
|
34
51
|
export declare const __testing: {
|
|
35
52
|
dimensions: typeof dimensions;
|
package/dist/video/project.js
CHANGED
|
@@ -90,6 +90,11 @@ export async function planStory(input) {
|
|
|
90
90
|
},
|
|
91
91
|
scenes: normalizeScenes(parsed.scenes ?? [], input.targetDurationSec),
|
|
92
92
|
}, 'AI가 생성한 영상 프로젝트');
|
|
93
|
+
persistProject(projectDir, project, input.overwrite === true);
|
|
94
|
+
return project;
|
|
95
|
+
}
|
|
96
|
+
function persistProject(projectDir, project, overwrite) {
|
|
97
|
+
const projectPath = path.join(projectDir, PROJECT_FILE);
|
|
93
98
|
mkdirSync(projectDir, { recursive: true });
|
|
94
99
|
mkdirSync(path.join(projectDir, 'assets', 'stock'), { recursive: true });
|
|
95
100
|
mkdirSync(path.join(projectDir, 'assets', 'generated'), { recursive: true });
|
|
@@ -97,7 +102,7 @@ export async function planStory(input) {
|
|
|
97
102
|
mkdirSync(path.join(projectDir, 'research'), { recursive: true });
|
|
98
103
|
mkdirSync(path.join(projectDir, 'render'), { recursive: true });
|
|
99
104
|
mkdirSync(path.join(projectDir, '.jobs'), { recursive: true });
|
|
100
|
-
if (existsSync(projectPath) &&
|
|
105
|
+
if (existsSync(projectPath) && overwrite) {
|
|
101
106
|
const archiveDir = path.join(projectDir, '.history', `${Date.now()}-${project.projectId}`);
|
|
102
107
|
mkdirSync(archiveDir, { recursive: true });
|
|
103
108
|
for (const name of [PROJECT_FILE, ASSET_FILE, TIMELINE_FILE]) {
|
|
@@ -115,6 +120,43 @@ export async function planStory(input) {
|
|
|
115
120
|
projectId: project.projectId,
|
|
116
121
|
assets: [],
|
|
117
122
|
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 에이전트(구독 세션)가 직접 작성한 스토리보드를 API 키 없이 project.json으로 저장한다.
|
|
126
|
+
* video_plan_from_story(ANTHROPIC_API_KEY 필요)의 무료 대안.
|
|
127
|
+
* 장면 길이 합계가 targetDurationSec와 일치해야 projectSchema 검증을 통과한다.
|
|
128
|
+
*/
|
|
129
|
+
export function savePlan(input) {
|
|
130
|
+
const projectDir = requireAbsolutePath(input.projectDir, 'projectDir');
|
|
131
|
+
const projectPath = path.join(projectDir, PROJECT_FILE);
|
|
132
|
+
if (existsSync(projectPath) && !input.overwrite) {
|
|
133
|
+
throw new Error(`이미 영상 프로젝트가 있습니다: ${projectPath}\noverwrite=true로 명시해야 덮어씁니다.`);
|
|
134
|
+
}
|
|
135
|
+
const dims = dimensions(input.aspectRatio);
|
|
136
|
+
const project = parseFile(projectSchema, {
|
|
137
|
+
version: 1,
|
|
138
|
+
projectId: randomUUID(),
|
|
139
|
+
title: input.title,
|
|
140
|
+
story: input.story,
|
|
141
|
+
language: input.language,
|
|
142
|
+
createdAt: new Date().toISOString(),
|
|
143
|
+
settings: {
|
|
144
|
+
aspectRatio: input.aspectRatio,
|
|
145
|
+
...dims,
|
|
146
|
+
fps: 30,
|
|
147
|
+
targetDurationSec: input.targetDurationSec,
|
|
148
|
+
style: input.style,
|
|
149
|
+
},
|
|
150
|
+
scenes: input.scenes.map((scene, index) => ({
|
|
151
|
+
id: scene.id?.trim() || `scene-${index + 1}`,
|
|
152
|
+
durationSec: scene.durationSec,
|
|
153
|
+
narration: scene.narration?.trim() ?? '',
|
|
154
|
+
onScreenText: scene.onScreenText?.trim() ?? '',
|
|
155
|
+
visualPrompt: scene.visualPrompt?.trim() ?? '',
|
|
156
|
+
searchQuery: scene.searchQuery?.trim() || scene.visualPrompt?.trim() || '',
|
|
157
|
+
})),
|
|
158
|
+
}, '에이전트가 작성한 영상 프로젝트');
|
|
159
|
+
persistProject(projectDir, project, input.overwrite === true);
|
|
118
160
|
return project;
|
|
119
161
|
}
|
|
120
162
|
export function loadProject(projectDir) {
|
|
@@ -191,7 +233,7 @@ export function addLocalAsset(input) {
|
|
|
191
233
|
allowedForRendering: true,
|
|
192
234
|
});
|
|
193
235
|
}
|
|
194
|
-
export function buildTimeline(projectDir, scenes, audioAssetId) {
|
|
236
|
+
export function buildTimeline(projectDir, scenes, audioAssetId, captionStyle) {
|
|
195
237
|
const dir = requireAbsolutePath(projectDir, 'projectDir');
|
|
196
238
|
const project = loadProject(dir);
|
|
197
239
|
const manifest = loadAssets(dir);
|
|
@@ -231,6 +273,7 @@ export function buildTimeline(projectDir, scenes, audioAssetId) {
|
|
|
231
273
|
totalDurationSec,
|
|
232
274
|
scenes,
|
|
233
275
|
audioAssetId,
|
|
276
|
+
captionStyle,
|
|
234
277
|
}, '생성할 영상 타임라인');
|
|
235
278
|
writeJsonAtomic(path.join(dir, TIMELINE_FILE), timeline);
|
|
236
279
|
return timeline;
|
package/dist/video/render.d.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import type { VideoRenderJob
|
|
1
|
+
import type { VideoRenderJob } from './types.js';
|
|
2
2
|
declare function processAlive(pid: number | undefined): boolean;
|
|
3
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
4
|
export interface FfmpegPlan {
|
|
8
5
|
args: string[];
|
|
9
6
|
outputPath: string;
|
|
@@ -29,9 +26,7 @@ export declare function validateVideo(filePath: string, ffmpegPath?: string): Pr
|
|
|
29
26
|
issues: string[];
|
|
30
27
|
}>;
|
|
31
28
|
export declare const __testing: {
|
|
32
|
-
srtTime: typeof srtTime;
|
|
33
29
|
ffprobeFor: typeof ffprobeFor;
|
|
34
|
-
sanitizeSubtitleText: typeof sanitizeSubtitleText;
|
|
35
30
|
processAlive: typeof processAlive;
|
|
36
31
|
acquireOutputLock: typeof acquireOutputLock;
|
|
37
32
|
readTail: typeof readTail;
|
package/dist/video/render.js
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { closeSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { formatAss, resolveCaptionStyle } from './captions.js';
|
|
6
7
|
import { readJson, writeJsonAtomic } from './files.js';
|
|
7
8
|
import { loadAssets, loadProject, loadTimeline } from './project.js';
|
|
8
9
|
import { parseFile, renderJobSchema } from './schemas.js';
|
|
@@ -41,38 +42,6 @@ function readTail(filePath, maxBytes = 64 * 1024) {
|
|
|
41
42
|
}
|
|
42
43
|
return buffer.toString('utf8');
|
|
43
44
|
}
|
|
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
45
|
function isImage(asset) {
|
|
77
46
|
return asset.kind === 'image';
|
|
78
47
|
}
|
|
@@ -111,14 +80,16 @@ export function buildFfmpegPlan(projectDir, outputPath, jobId) {
|
|
|
111
80
|
`crop=${project.settings.width}:${project.settings.height},fps=${project.settings.fps},` +
|
|
112
81
|
`trim=duration=${scene.durationSec},setpts=PTS-STARTPTS,format=yuv420p[v${index}]`));
|
|
113
82
|
filters.push(`${timeline.scenes.map((_, index) => `[v${index}]`).join('')}concat=n=${timeline.scenes.length}:v=1:a=0[base]`);
|
|
114
|
-
const
|
|
83
|
+
const captionStyle = resolveCaptionStyle(project.settings.width, project.settings.height, timeline.captionStyle);
|
|
84
|
+
const ass = formatAss(timeline, captionStyle);
|
|
115
85
|
let videoLabel = '[base]';
|
|
116
86
|
let subtitlePath;
|
|
117
|
-
if (
|
|
118
|
-
const relativeSubtitlePath = `render/captions-${jobId}.
|
|
87
|
+
if (ass) {
|
|
88
|
+
const relativeSubtitlePath = `render/captions-${jobId}.ass`;
|
|
119
89
|
subtitlePath = path.join(dir, relativeSubtitlePath);
|
|
120
|
-
writeFileSync(subtitlePath,
|
|
121
|
-
|
|
90
|
+
writeFileSync(subtitlePath, ass, 'utf8');
|
|
91
|
+
// 스타일(폰트·크기·외곽선·하이라이트)은 ASS 파일에 내장 — force_style을 쓰지 않는다.
|
|
92
|
+
filters.push(`[base]subtitles=${relativeSubtitlePath}[vout]`);
|
|
122
93
|
videoLabel = '[vout]';
|
|
123
94
|
}
|
|
124
95
|
args.push('-filter_complex', filters.join(';'), '-map', videoLabel);
|
|
@@ -317,4 +288,4 @@ export async function validateVideo(filePath, ffmpegPath) {
|
|
|
317
288
|
issues.push(`권장 픽셀 포맷 yuv420p가 아닙니다: ${String(video.pix_fmt)}`);
|
|
318
289
|
return { valid: issues.length === 0, format: data.format ?? {}, streams, issues };
|
|
319
290
|
}
|
|
320
|
-
export const __testing = {
|
|
291
|
+
export const __testing = { ffprobeFor, processAlive, acquireOutputLock, readTail };
|
package/dist/video/schemas.d.ts
CHANGED
|
@@ -350,6 +350,32 @@ export declare const assetManifestSchema: z.ZodObject<{
|
|
|
350
350
|
attribution?: string | undefined;
|
|
351
351
|
}[];
|
|
352
352
|
}>;
|
|
353
|
+
export declare const hexColorSchema: z.ZodString;
|
|
354
|
+
export declare const captionStyleSchema: z.ZodObject<{
|
|
355
|
+
preset: z.ZodOptional<z.ZodEnum<["shorts", "box"]>>;
|
|
356
|
+
fontName: z.ZodOptional<z.ZodString>;
|
|
357
|
+
fontSizePx: z.ZodOptional<z.ZodNumber>;
|
|
358
|
+
textColor: z.ZodOptional<z.ZodString>;
|
|
359
|
+
outlineColor: z.ZodOptional<z.ZodString>;
|
|
360
|
+
highlightColor: z.ZodOptional<z.ZodString>;
|
|
361
|
+
position: z.ZodOptional<z.ZodEnum<["lower", "lower-middle", "center"]>>;
|
|
362
|
+
}, "strip", z.ZodTypeAny, {
|
|
363
|
+
preset?: "shorts" | "box" | undefined;
|
|
364
|
+
fontName?: string | undefined;
|
|
365
|
+
fontSizePx?: number | undefined;
|
|
366
|
+
textColor?: string | undefined;
|
|
367
|
+
outlineColor?: string | undefined;
|
|
368
|
+
highlightColor?: string | undefined;
|
|
369
|
+
position?: "lower" | "lower-middle" | "center" | undefined;
|
|
370
|
+
}, {
|
|
371
|
+
preset?: "shorts" | "box" | undefined;
|
|
372
|
+
fontName?: string | undefined;
|
|
373
|
+
fontSizePx?: number | undefined;
|
|
374
|
+
textColor?: string | undefined;
|
|
375
|
+
outlineColor?: string | undefined;
|
|
376
|
+
highlightColor?: string | undefined;
|
|
377
|
+
position?: "lower" | "lower-middle" | "center" | undefined;
|
|
378
|
+
}>;
|
|
353
379
|
export declare const timelineSceneSchema: z.ZodObject<{
|
|
354
380
|
id: z.ZodString;
|
|
355
381
|
assetId: z.ZodString;
|
|
@@ -397,6 +423,31 @@ export declare const timelineSchema: z.ZodEffects<z.ZodObject<{
|
|
|
397
423
|
onScreenText?: string | undefined;
|
|
398
424
|
}>, "many">;
|
|
399
425
|
audioAssetId: z.ZodOptional<z.ZodString>;
|
|
426
|
+
captionStyle: z.ZodOptional<z.ZodObject<{
|
|
427
|
+
preset: z.ZodOptional<z.ZodEnum<["shorts", "box"]>>;
|
|
428
|
+
fontName: z.ZodOptional<z.ZodString>;
|
|
429
|
+
fontSizePx: z.ZodOptional<z.ZodNumber>;
|
|
430
|
+
textColor: z.ZodOptional<z.ZodString>;
|
|
431
|
+
outlineColor: z.ZodOptional<z.ZodString>;
|
|
432
|
+
highlightColor: z.ZodOptional<z.ZodString>;
|
|
433
|
+
position: z.ZodOptional<z.ZodEnum<["lower", "lower-middle", "center"]>>;
|
|
434
|
+
}, "strip", z.ZodTypeAny, {
|
|
435
|
+
preset?: "shorts" | "box" | undefined;
|
|
436
|
+
fontName?: string | undefined;
|
|
437
|
+
fontSizePx?: number | undefined;
|
|
438
|
+
textColor?: string | undefined;
|
|
439
|
+
outlineColor?: string | undefined;
|
|
440
|
+
highlightColor?: string | undefined;
|
|
441
|
+
position?: "lower" | "lower-middle" | "center" | undefined;
|
|
442
|
+
}, {
|
|
443
|
+
preset?: "shorts" | "box" | undefined;
|
|
444
|
+
fontName?: string | undefined;
|
|
445
|
+
fontSizePx?: number | undefined;
|
|
446
|
+
textColor?: string | undefined;
|
|
447
|
+
outlineColor?: string | undefined;
|
|
448
|
+
highlightColor?: string | undefined;
|
|
449
|
+
position?: "lower" | "lower-middle" | "center" | undefined;
|
|
450
|
+
}>>;
|
|
400
451
|
}, "strip", z.ZodTypeAny, {
|
|
401
452
|
projectId: string;
|
|
402
453
|
version: 1;
|
|
@@ -413,6 +464,15 @@ export declare const timelineSchema: z.ZodEffects<z.ZodObject<{
|
|
|
413
464
|
}[];
|
|
414
465
|
totalDurationSec: number;
|
|
415
466
|
audioAssetId?: string | undefined;
|
|
467
|
+
captionStyle?: {
|
|
468
|
+
preset?: "shorts" | "box" | undefined;
|
|
469
|
+
fontName?: string | undefined;
|
|
470
|
+
fontSizePx?: number | undefined;
|
|
471
|
+
textColor?: string | undefined;
|
|
472
|
+
outlineColor?: string | undefined;
|
|
473
|
+
highlightColor?: string | undefined;
|
|
474
|
+
position?: "lower" | "lower-middle" | "center" | undefined;
|
|
475
|
+
} | undefined;
|
|
416
476
|
}, {
|
|
417
477
|
projectId: string;
|
|
418
478
|
version: 1;
|
|
@@ -429,6 +489,15 @@ export declare const timelineSchema: z.ZodEffects<z.ZodObject<{
|
|
|
429
489
|
}[];
|
|
430
490
|
totalDurationSec: number;
|
|
431
491
|
audioAssetId?: string | undefined;
|
|
492
|
+
captionStyle?: {
|
|
493
|
+
preset?: "shorts" | "box" | undefined;
|
|
494
|
+
fontName?: string | undefined;
|
|
495
|
+
fontSizePx?: number | undefined;
|
|
496
|
+
textColor?: string | undefined;
|
|
497
|
+
outlineColor?: string | undefined;
|
|
498
|
+
highlightColor?: string | undefined;
|
|
499
|
+
position?: "lower" | "lower-middle" | "center" | undefined;
|
|
500
|
+
} | undefined;
|
|
432
501
|
}>, {
|
|
433
502
|
projectId: string;
|
|
434
503
|
version: 1;
|
|
@@ -445,6 +514,15 @@ export declare const timelineSchema: z.ZodEffects<z.ZodObject<{
|
|
|
445
514
|
}[];
|
|
446
515
|
totalDurationSec: number;
|
|
447
516
|
audioAssetId?: string | undefined;
|
|
517
|
+
captionStyle?: {
|
|
518
|
+
preset?: "shorts" | "box" | undefined;
|
|
519
|
+
fontName?: string | undefined;
|
|
520
|
+
fontSizePx?: number | undefined;
|
|
521
|
+
textColor?: string | undefined;
|
|
522
|
+
outlineColor?: string | undefined;
|
|
523
|
+
highlightColor?: string | undefined;
|
|
524
|
+
position?: "lower" | "lower-middle" | "center" | undefined;
|
|
525
|
+
} | undefined;
|
|
448
526
|
}, {
|
|
449
527
|
projectId: string;
|
|
450
528
|
version: 1;
|
|
@@ -461,6 +539,15 @@ export declare const timelineSchema: z.ZodEffects<z.ZodObject<{
|
|
|
461
539
|
}[];
|
|
462
540
|
totalDurationSec: number;
|
|
463
541
|
audioAssetId?: string | undefined;
|
|
542
|
+
captionStyle?: {
|
|
543
|
+
preset?: "shorts" | "box" | undefined;
|
|
544
|
+
fontName?: string | undefined;
|
|
545
|
+
fontSizePx?: number | undefined;
|
|
546
|
+
textColor?: string | undefined;
|
|
547
|
+
outlineColor?: string | undefined;
|
|
548
|
+
highlightColor?: string | undefined;
|
|
549
|
+
position?: "lower" | "lower-middle" | "center" | undefined;
|
|
550
|
+
} | undefined;
|
|
464
551
|
}>;
|
|
465
552
|
export declare const renderJobSchema: z.ZodObject<{
|
|
466
553
|
id: z.ZodString;
|
package/dist/video/schemas.js
CHANGED
|
@@ -56,6 +56,16 @@ export const assetManifestSchema = z.object({
|
|
|
56
56
|
projectId: z.string().uuid(),
|
|
57
57
|
assets: z.array(assetSchema).max(10_000),
|
|
58
58
|
});
|
|
59
|
+
export const hexColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, '#RRGGBB 형식이어야 합니다.');
|
|
60
|
+
export const captionStyleSchema = z.object({
|
|
61
|
+
preset: z.enum(['shorts', 'box']).optional(),
|
|
62
|
+
fontName: z.string().min(1).max(200).optional(),
|
|
63
|
+
fontSizePx: z.number().int().min(24).max(400).optional(),
|
|
64
|
+
textColor: hexColorSchema.optional(),
|
|
65
|
+
outlineColor: hexColorSchema.optional(),
|
|
66
|
+
highlightColor: hexColorSchema.optional(),
|
|
67
|
+
position: z.enum(['lower', 'lower-middle', 'center']).optional(),
|
|
68
|
+
});
|
|
59
69
|
export const timelineSceneSchema = z.object({
|
|
60
70
|
id: z.string().min(1).max(100),
|
|
61
71
|
assetId: z.string().min(1).max(200),
|
|
@@ -73,6 +83,7 @@ export const timelineSchema = z.object({
|
|
|
73
83
|
totalDurationSec: z.number().finite().positive().max(300),
|
|
74
84
|
scenes: z.array(timelineSceneSchema).min(1).max(30),
|
|
75
85
|
audioAssetId: z.string().min(1).max(200).optional(),
|
|
86
|
+
captionStyle: captionStyleSchema.optional(),
|
|
76
87
|
}).superRefine((timeline, ctx) => {
|
|
77
88
|
const calculated = timeline.scenes.reduce((sum, scene) => sum + scene.durationSec, 0);
|
|
78
89
|
if (Math.abs(calculated - timeline.totalDurationSec) > 0.01) {
|
package/dist/video/types.d.ts
CHANGED
|
@@ -53,6 +53,15 @@ export interface VideoTimelineScene {
|
|
|
53
53
|
onScreenText?: string;
|
|
54
54
|
narration?: string;
|
|
55
55
|
}
|
|
56
|
+
export interface VideoCaptionStyle {
|
|
57
|
+
preset?: 'shorts' | 'box';
|
|
58
|
+
fontName?: string;
|
|
59
|
+
fontSizePx?: number;
|
|
60
|
+
textColor?: string;
|
|
61
|
+
outlineColor?: string;
|
|
62
|
+
highlightColor?: string;
|
|
63
|
+
position?: 'lower' | 'lower-middle' | 'center';
|
|
64
|
+
}
|
|
56
65
|
export interface VideoTimeline {
|
|
57
66
|
version: 1;
|
|
58
67
|
projectId: string;
|
|
@@ -63,6 +72,7 @@ export interface VideoTimeline {
|
|
|
63
72
|
totalDurationSec: number;
|
|
64
73
|
scenes: VideoTimelineScene[];
|
|
65
74
|
audioAssetId?: string;
|
|
75
|
+
captionStyle?: VideoCaptionStyle;
|
|
66
76
|
}
|
|
67
77
|
export type VideoJobStatus = 'queued' | 'running' | 'completed' | 'failed';
|
|
68
78
|
export interface VideoRenderJob {
|
package/package.json
CHANGED
package/tool-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary)의 SSOT. src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
|
|
3
|
-
"total":
|
|
3
|
+
"total": 231,
|
|
4
4
|
"domains": {
|
|
5
5
|
"admob": {
|
|
6
6
|
"label": "AdMob",
|
|
@@ -367,6 +367,7 @@
|
|
|
367
367
|
"youtube_get_video_status",
|
|
368
368
|
"youtube_update_video_privacy",
|
|
369
369
|
"video_plan_from_story",
|
|
370
|
+
"video_save_plan",
|
|
370
371
|
"video_research_youtube",
|
|
371
372
|
"video_search_stock_assets",
|
|
372
373
|
"video_synthesize_research",
|