@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 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 | 14 | `youtube_upload_video` / `youtube_get_video_status` / `youtube_update_video_privacy` / `video_plan_from_story` / `video_render` |
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)
@@ -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. `video_plan_from_story` — create the project and scene plan.
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. User-owned media goes through
228
- `video_add_local_asset` with its ownership/license basis.
229
- 6. `video_build_timeline` provenance-gated scene assignment.
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
@@ -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
- }, async ({ projectDir, scenes, audioAssetId }) => text(buildTimeline(projectDir, scenes, audioAssetId)));
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
- '이미지/영상 크롭, 장면 연결, 화면 문구 번인, 선택적 배경음, H.264/yuv420p 출력을 지원합니다.',
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 };
@@ -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;
@@ -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) && input.overwrite) {
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;
@@ -1,9 +1,6 @@
1
- import type { VideoRenderJob, VideoTimeline } from './types.js';
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;
@@ -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 srt = formatSrt(timeline);
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 (srt) {
118
- const relativeSubtitlePath = `render/captions-${jobId}.srt`;
87
+ if (ass) {
88
+ const relativeSubtitlePath = `render/captions-${jobId}.ass`;
119
89
  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]`);
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 = { srtTime, ffprobeFor, sanitizeSubtitleText, processAlive, acquireOutputLock, readTail };
291
+ export const __testing = { ffprobeFor, processAlive, acquireOutputLock, readTail };
@@ -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;
@@ -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) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Mimi Seed MCP server \u2014 Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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": 230,
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",