@yoonion/mimi-seed-mcp 0.18.0 → 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,4 +1,6 @@
1
- import { SignJWT, importPKCS8 } from 'jose';
1
+ // jose 는 값 import 하지 않는다 import 만으로 ~0.6초가 나가고, 이 파일은
2
+ // helpers.ts 를 통해 대부분의 register 에 딸려 들어가 MCP 기동 시간을 그대로 밀어올린다.
3
+ // 실제로 필요한 곳은 generateToken() 하나뿐이라 그 안에서 동적 import 한다.
2
4
  import fs from 'node:fs';
3
5
  import path from 'node:path';
4
6
  import os from 'node:os';
@@ -41,6 +43,7 @@ function normalizePrivateKey(raw) {
41
43
  return [header, ...chunks, footer, ''].join('\n');
42
44
  }
43
45
  export async function generateToken(creds) {
46
+ const { SignJWT, importPKCS8 } = await import('jose');
44
47
  const normalizedKey = normalizePrivateKey(creds.privateKey);
45
48
  let key;
46
49
  try {
@@ -1,5 +1,4 @@
1
- import { JWT } from 'google-auth-library';
2
- import type { OAuth2Client } from 'google-auth-library';
1
+ import type { JWT, OAuth2Client } from 'google-auth-library';
3
2
  export interface ServiceAccountKey {
4
3
  type?: string;
5
4
  client_email?: string;
@@ -1,4 +1,4 @@
1
- import { JWT } from 'google-auth-library';
1
+ import { newJWT } from '../lib/google-auth-lite.js';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
@@ -55,7 +55,7 @@ export function deleteBigQueryServiceAccountJson() {
55
55
  return true;
56
56
  }
57
57
  function makeJwt(sa) {
58
- return new JWT({ email: sa.client_email, key: sa.private_key, scopes: BQ_SCOPES });
58
+ return newJWT({ email: sa.client_email, key: sa.private_key, scopes: BQ_SCOPES });
59
59
  }
60
60
  /**
61
61
  * BigQuery 인증 클라이언트 해석. 우선순위:
@@ -3,7 +3,7 @@ import readline from 'node:readline';
3
3
  import fs from 'node:fs';
4
4
  import { saveBigQueryServiceAccountJson, getBigQueryServiceAccountKey, } from './bigquery-auth.js';
5
5
  import * as bigquery from '../bigquery/tools.js';
6
- import { JWT } from 'google-auth-library';
6
+ import { newJWT } from '../lib/google-auth-lite.js';
7
7
  import { resolveLang } from '../lib/lang.js';
8
8
  // ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
9
9
  const ko = {
@@ -111,7 +111,7 @@ async function main() {
111
111
  if (projectId) {
112
112
  console.log(M.probing(projectId));
113
113
  try {
114
- const jwt = new JWT({
114
+ const jwt = newJWT({
115
115
  email: parsed.client_email,
116
116
  key: parsed.private_key,
117
117
  scopes: [
@@ -1,4 +1,4 @@
1
- import { JWT } from 'google-auth-library';
1
+ import type { JWT } from 'google-auth-library';
2
2
  /**
3
3
  * 패키지별 → 레거시(default) 순으로 SA JSON을 로드.
4
4
  * 여러 앱이 다른 GCP 프로젝트의 SA를 쓰는 환경 지원.
@@ -1,4 +1,4 @@
1
- import { JWT } from 'google-auth-library';
1
+ import { newJWT } from '../lib/google-auth-lite.js';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
@@ -106,7 +106,7 @@ export function getServiceAccountClient(packageName) {
106
106
  return null;
107
107
  try {
108
108
  const parsed = JSON.parse(json);
109
- return new JWT({
109
+ return newJWT({
110
110
  email: parsed.client_email,
111
111
  key: parsed.private_key,
112
112
  // androidpublisher(edits/리스팅 등) + Developer Reporting(vitals 통계). 통계 도구가
@@ -0,0 +1,3 @@
1
+ import type { JWT, JWTOptions } from 'google-auth-library';
2
+ /** `new JWT(opts)` 와 동일. 첫 호출에서만 google-auth-library 를 로드한다. */
3
+ export declare function newJWT(opts: JWTOptions): JWT;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * google-auth-library 지연 로더 — googleapis-lite 와 같은 목적, 같은 기법.
3
+ *
4
+ * `import { JWT } from 'google-auth-library'` 는 그 자체로 ~0.6초다. 문제는 이게
5
+ * helpers.ts → 12개 register 로 전파돼, 도구를 하나도 호출하지 않아도 MCP 기동 때
6
+ * 무조건 지불된다는 점이다. 콜드 캐시에서는 이런 항목들이 합쳐져 기동이 25초까지 튀고,
7
+ * Claude Code 의 기본 5초 MCP 연결 타임아웃을 넘겨 서버가 통째로 등록되지 않는다.
8
+ *
9
+ * google-auth-library 는 `type: module` 도 `exports` 맵도 없는 순수 CJS 라
10
+ * `createRequire` 로 **동기** 로드가 된다. 덕분에 `getServiceAccountClient()` 처럼
11
+ * 이미 동기인 함수를 async 로 바꾸지 않고도 지연 로딩이 가능하다.
12
+ *
13
+ * 규칙:
14
+ * - `JWT` 를 **값으로** 쓰려면 이 파일의 `newJWT()` 를 쓴다.
15
+ * - 타입만 필요하면 `import type { JWT } from 'google-auth-library'` — 타입 import 는
16
+ * 런타임에 지워지므로 기동 비용이 0 이고 그대로 써도 된다.
17
+ * - 다른 파일에서 `import { JWT } from 'google-auth-library'` (값 import) 는 금지.
18
+ */
19
+ import { createRequire } from 'node:module';
20
+ const require = createRequire(import.meta.url);
21
+ let cached;
22
+ function lib() {
23
+ cached ??= require('google-auth-library');
24
+ return cached;
25
+ }
26
+ /** `new JWT(opts)` 와 동일. 첫 호출에서만 google-auth-library 를 로드한다. */
27
+ export function newJWT(opts) {
28
+ return new (lib().JWT)(opts);
29
+ }
@@ -1,43 +1,21 @@
1
+ export type { youtube_v3 } from 'googleapis/build/src/apis/youtube/index.js';
1
2
  /**
2
- * googleapis 서브패스 로더 MCP 서버 기동 시간 방어벽.
3
- *
4
- * `import { google } from 'googleapis'` 는 import 시점에 400여 개 API 클라이언트를
5
- * 전부 로드해 그것만으로 ~19초를 쓴다. 그 결과 MCP 서버 기동(21~36초)이 Claude Code 의
6
- * MCP 연결 타임아웃(30초)을 상습 초과해, 세션에서 mimi-seed 도구가 아예 등록되지 않는
7
- * 사고가 났다 (2026-07-24). 실제 사용하는 API 만 서브패스로 로드하면 ~1초대다.
8
- *
9
- * - 새 Google API 가 필요하면 여기에 import 한 줄 + google 객체에 한 줄 추가한다.
10
- * - 다른 파일에서 `from 'googleapis'` 값 import 는 금지 — 반드시 이 모듈을 거친다.
11
- * (googleapis 는 exports map 이 없어 서브패스 import 가 공식적으로 가능하다.)
12
- * - `auth` 는 AuthPlus 인스턴스라 `google.auth.OAuth2` 등 기존 사용처가 그대로 동작한다.
3
+ * 기존 `google.<api>(...)` 호출부와 100% 동일하게 동작하는 지연 로딩 네임스페이스.
4
+ * 각 프로퍼티는 getter 라서, 실제로 그 API 를 쓰는 도구가 호출될 때까지 아무것도 로드하지 않는다.
13
5
  */
14
- import { admob } from 'googleapis/build/src/apis/admob/index.js';
15
- import { analyticsadmin } from 'googleapis/build/src/apis/analyticsadmin/index.js';
16
- import { analyticsdata } from 'googleapis/build/src/apis/analyticsdata/index.js';
17
- import { androidpublisher } from 'googleapis/build/src/apis/androidpublisher/index.js';
18
- import { bigquery } from 'googleapis/build/src/apis/bigquery/index.js';
19
- import { billingbudgets } from 'googleapis/build/src/apis/billingbudgets/index.js';
20
- import { cloudbilling } from 'googleapis/build/src/apis/cloudbilling/index.js';
21
- import { cloudresourcemanager } from 'googleapis/build/src/apis/cloudresourcemanager/index.js';
22
- import { firebase } from 'googleapis/build/src/apis/firebase/index.js';
23
- import { iam } from 'googleapis/build/src/apis/iam/index.js';
24
- import { searchconsole } from 'googleapis/build/src/apis/searchconsole/index.js';
25
- import { serviceusage } from 'googleapis/build/src/apis/serviceusage/index.js';
26
- import { youtube } from 'googleapis/build/src/apis/youtube/index.js';
27
- export type { youtube_v3 } from 'googleapis/build/src/apis/youtube/index.js';
28
6
  export declare const google: {
29
- auth: import("googleapis-common/build/src/authplus.js").AuthPlus;
30
- admob: typeof admob;
31
- analyticsadmin: typeof analyticsadmin;
32
- analyticsdata: typeof analyticsdata;
33
- androidpublisher: typeof androidpublisher;
34
- bigquery: typeof bigquery;
35
- billingbudgets: typeof billingbudgets;
36
- cloudbilling: typeof cloudbilling;
37
- cloudresourcemanager: typeof cloudresourcemanager;
38
- firebase: typeof firebase;
39
- iam: typeof iam;
40
- searchconsole: typeof searchconsole;
41
- serviceusage: typeof serviceusage;
42
- youtube: typeof youtube;
7
+ readonly auth: typeof import("googleapis/build/src/apis/firebase/index.js").auth;
8
+ readonly admob: typeof import("googleapis/build/src/apis/admob/index.js").admob;
9
+ readonly analyticsadmin: typeof import("googleapis/build/src/apis/analyticsadmin/index.js").analyticsadmin;
10
+ readonly analyticsdata: typeof import("googleapis/build/src/apis/analyticsdata/index.js").analyticsdata;
11
+ readonly androidpublisher: typeof import("googleapis/build/src/apis/androidpublisher/index.js").androidpublisher;
12
+ readonly bigquery: typeof import("googleapis/build/src/apis/bigquery/index.js").bigquery;
13
+ readonly billingbudgets: typeof import("googleapis/build/src/apis/billingbudgets/index.js").billingbudgets;
14
+ readonly cloudbilling: typeof import("googleapis/build/src/apis/cloudbilling/index.js").cloudbilling;
15
+ readonly cloudresourcemanager: typeof import("googleapis/build/src/apis/cloudresourcemanager/index.js").cloudresourcemanager;
16
+ readonly firebase: typeof import("googleapis/build/src/apis/firebase/index.js").firebase;
17
+ readonly iam: typeof import("googleapis/build/src/apis/iam/index.js").iam;
18
+ readonly searchconsole: typeof import("googleapis/build/src/apis/searchconsole/index.js").searchconsole;
19
+ readonly serviceusage: typeof import("googleapis/build/src/apis/serviceusage/index.js").serviceusage;
20
+ readonly youtube: typeof import("googleapis/build/src/apis/youtube/index.js").youtube;
43
21
  };
@@ -3,40 +3,89 @@
3
3
  *
4
4
  * `import { google } from 'googleapis'` 는 import 시점에 400여 개 API 클라이언트를
5
5
  * 전부 로드해 그것만으로 ~19초를 쓴다. 그 결과 MCP 서버 기동(21~36초)이 Claude Code 의
6
- * MCP 연결 타임아웃(30초)을 상습 초과해, 세션에서 mimi-seed 도구가 아예 등록되지 않는
7
- * 사고가 났다 (2026-07-24). 실제 사용하는 API 만 서브패스로 로드하면 ~1초대다.
6
+ * MCP 연결 타임아웃을 상습 초과해, 세션에서 mimi-seed 도구가 아예 등록되지 않는
7
+ * 사고가 났다 (2026-07-24). 실제 사용하는 API 만 서브패스로 로드하면 ~1.4초대다.
8
8
  *
9
- * - Google API 필요하면 여기에 import + google 객체에 한 줄 추가한다.
9
+ * 그런데 ~1.4초도 **기동 시점에** 전부 나갔다. 서브패스를 정적 import 하면
10
+ * 도구를 하나도 호출하지 않아도 googleapis 공통 런타임(google-auth-library 등)이
11
+ * 딸려 온다. 콜드 캐시(Windows 실시간 검사 등)에서는 이게 25초까지 튀어
12
+ * 기본 5초 연결 타임아웃을 다시 넘겼다 (2026-08-22).
13
+ *
14
+ * 그래서 지금은 **첫 사용 시점까지 미룬다**:
15
+ * - googleapis 는 `type: module` 도 `exports` 맵도 없는 순수 CJS 라
16
+ * `createRequire` 로 **동기** 로드가 가능하다. 그래서 `google.admob(...)` 같은
17
+ * 기존 호출부를 async 로 바꿀 필요가 전혀 없다 — 소비자 코드는 그대로다.
18
+ * - 타입은 `typeof import(...)` 로 얻는다. 타입 위치의 import 는 런타임에 완전히
19
+ * 지워지므로 기동 비용이 0 이다. 절대 값 import 로 바꾸지 말 것 — 그 순간 이 파일의
20
+ * 존재 이유가 사라진다.
21
+ *
22
+ * 규칙:
23
+ * - 새 Google API 가 필요하면 아래 `google` 객체에 getter 한 줄을 추가한다.
10
24
  * - 다른 파일에서 `from 'googleapis'` 값 import 는 금지 — 반드시 이 모듈을 거친다.
11
- * (googleapis 는 exports map 이 없어 서브패스 import 가 공식적으로 가능하다.)
12
- * - `auth` 는 AuthPlus 인스턴스라 `google.auth.OAuth2` 등 기존 사용처가 그대로 동작한다.
13
25
  */
14
- import { admob } from 'googleapis/build/src/apis/admob/index.js';
15
- import { analyticsadmin } from 'googleapis/build/src/apis/analyticsadmin/index.js';
16
- import { analyticsdata } from 'googleapis/build/src/apis/analyticsdata/index.js';
17
- import { androidpublisher } from 'googleapis/build/src/apis/androidpublisher/index.js';
18
- import { bigquery } from 'googleapis/build/src/apis/bigquery/index.js';
19
- import { billingbudgets } from 'googleapis/build/src/apis/billingbudgets/index.js';
20
- import { cloudbilling } from 'googleapis/build/src/apis/cloudbilling/index.js';
21
- import { cloudresourcemanager } from 'googleapis/build/src/apis/cloudresourcemanager/index.js';
22
- import { auth, firebase } from 'googleapis/build/src/apis/firebase/index.js';
23
- import { iam } from 'googleapis/build/src/apis/iam/index.js';
24
- import { searchconsole } from 'googleapis/build/src/apis/searchconsole/index.js';
25
- import { serviceusage } from 'googleapis/build/src/apis/serviceusage/index.js';
26
- import { youtube } from 'googleapis/build/src/apis/youtube/index.js';
26
+ import { createRequire } from 'node:module';
27
+ const require = createRequire(import.meta.url);
28
+ /** 서브패스 모듈 캐시 require 자체도 캐시하지만 Map 조회가 더 싸다. */
29
+ const cache = new Map();
30
+ /**
31
+ * googleapis 서브패스를 호출 시점에 동기 로드한다.
32
+ * 호출이 공통 런타임까지 함께 지불하고(~1.1초), 이후 다른 서브패스는 ~20ms 다.
33
+ */
34
+ function sub(name) {
35
+ let mod = cache.get(name);
36
+ if (mod === undefined) {
37
+ mod = require(`googleapis/build/src/apis/${name}/index.js`);
38
+ cache.set(name, mod);
39
+ }
40
+ return mod;
41
+ }
42
+ /**
43
+ * 기존 `google.<api>(...)` 호출부와 100% 동일하게 동작하는 지연 로딩 네임스페이스.
44
+ * 각 프로퍼티는 getter 라서, 실제로 그 API 를 쓰는 도구가 호출될 때까지 아무것도 로드하지 않는다.
45
+ */
27
46
  export const google = {
28
- auth,
29
- admob,
30
- analyticsadmin,
31
- analyticsdata,
32
- androidpublisher,
33
- bigquery,
34
- billingbudgets,
35
- cloudbilling,
36
- cloudresourcemanager,
37
- firebase,
38
- iam,
39
- searchconsole,
40
- serviceusage,
41
- youtube,
47
+ // auth 는 AuthPlus 인스턴스. 어느 서브패스든 같은 걸 내보내지만,
48
+ // 기존 동작과 동일하게 firebase 서브패스에서 가져온다.
49
+ get auth() {
50
+ return sub('firebase').auth;
51
+ },
52
+ get admob() {
53
+ return sub('admob').admob;
54
+ },
55
+ get analyticsadmin() {
56
+ return sub('analyticsadmin').analyticsadmin;
57
+ },
58
+ get analyticsdata() {
59
+ return sub('analyticsdata').analyticsdata;
60
+ },
61
+ get androidpublisher() {
62
+ return sub('androidpublisher').androidpublisher;
63
+ },
64
+ get bigquery() {
65
+ return sub('bigquery').bigquery;
66
+ },
67
+ get billingbudgets() {
68
+ return sub('billingbudgets').billingbudgets;
69
+ },
70
+ get cloudbilling() {
71
+ return sub('cloudbilling').cloudbilling;
72
+ },
73
+ get cloudresourcemanager() {
74
+ return sub('cloudresourcemanager').cloudresourcemanager;
75
+ },
76
+ get firebase() {
77
+ return sub('firebase').firebase;
78
+ },
79
+ get iam() {
80
+ return sub('iam').iam;
81
+ },
82
+ get searchconsole() {
83
+ return sub('searchconsole').searchconsole;
84
+ },
85
+ get serviceusage() {
86
+ return sub('serviceusage').serviceusage;
87
+ },
88
+ get youtube() {
89
+ return sub('youtube').youtube;
90
+ },
42
91
  };
@@ -11,7 +11,7 @@ import fs from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import zlib from 'node:zlib';
14
- import { JWT } from 'google-auth-library';
14
+ import { newJWT } from '../lib/google-auth-lite.js';
15
15
  import { fetchWithTimeout, HTTP_TRANSFER_TIMEOUT_MS } from '../lib/http.js';
16
16
  import { requireServiceAccountJson } from '../helpers.js';
17
17
  const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'play-financials.json');
@@ -68,7 +68,7 @@ function normalizeBucket(raw) {
68
68
  */
69
69
  function storageClient(packageName) {
70
70
  const parsed = JSON.parse(requireServiceAccountJson(packageName));
71
- return new JWT({
71
+ return newJWT({
72
72
  email: parsed.client_email,
73
73
  key: parsed.private_key,
74
74
  scopes: [STORAGE_SCOPE],
@@ -1,5 +1,4 @@
1
- import type { OAuth2Client } from 'google-auth-library';
2
- import { JWT } from 'google-auth-library';
1
+ import type { OAuth2Client, JWT } from 'google-auth-library';
3
2
  export type PlayImageType = 'featureGraphic' | 'icon' | 'phoneScreenshots' | 'promoGraphic' | 'sevenInchScreenshots' | 'tenInchScreenshots' | 'tvBanner' | 'tvScreenshots' | 'wearScreenshots';
4
3
  /**
5
4
  * Google Play Developer API (Android Publisher API v3) 래퍼
@@ -1,5 +1,5 @@
1
1
  import { google } from '../lib/googleapis-lite.js';
2
- import { JWT } from 'google-auth-library';
2
+ import { newJWT } from '../lib/google-auth-lite.js';
3
3
  import fs from 'node:fs';
4
4
  import { extractHttpStatus } from '../lib/google-errors.js';
5
5
  function mimeTypeFor(filePath) {
@@ -763,7 +763,7 @@ export async function verifyServiceAccountJson(serviceAccountJson, packageName)
763
763
  message: `Expected type="service_account", got "${parsed.type}" — make sure you downloaded a service account key, not an OAuth client.`,
764
764
  };
765
765
  }
766
- const jwt = new JWT({
766
+ const jwt = newJWT({
767
767
  email: parsed.client_email,
768
768
  key: parsed.private_key,
769
769
  scopes: ['https://www.googleapis.com/auth/androidpublisher'],
@@ -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.0",
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",