aimakeall-mcp 0.14.4 → 0.14.5

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
@@ -137,9 +137,125 @@ ElevenLabs의 실제 자막은 `options.includeAlignment:true`로 오디오와
137
137
  `get_generation_job` 완료 응답은 보호된 오디오 링크와 검증된 `subtitleLines`,
138
138
  `timingSource:"provider-character-alignment"`를 반환합니다. 응답이 끊겨도 같은 ID를
139
139
  조회하며 다른 공급자나 기존 동기 TTS로 다시 합성하지 않습니다. 이 비동기 도구가
140
- 기존 `tts_narration_with_captions`의 로컬 파일·제작 핸들을 자동 갱신하는 것은 아닙니다.
140
+ 기존 제작 핸들을 갱신해야 한다면 아래 제작용 도구 흐름을 사용합니다.
141
+
142
+ ### 기존 음성 도구의 공유 실행 연결 (다음 릴리스용 변경, 아직 게시 전)
143
+
144
+ - `tts_narration`과 `tts_narration_with_captions`는 인증된
145
+ `GET /api/tracker/tts/mcp-execution-capability`로 실행 방식을 먼저 확인합니다.
146
+ 서버가 명시적으로 `binary`를 반환할 때만 기존 동기 합성을 사용합니다.
147
+ 구형 서버의 404, 통신 실패, 알 수 없는 계약을 동기 합성 허용으로 해석하지 않습니다.
148
+ 따라서 **대응 서버를 먼저 배포한 뒤 MCP를 게시·업데이트**해야 합니다.
149
+ - 공유 유료 합성은 제작 핸들의 `productionId`로 안정된 작업 ID를 만들며,
150
+ 핸들이 없으면 `requestId`(영문·숫자·`_`·`-`, 16~80자)가 필요합니다.
151
+ Typecast → ElevenLabs → Supertone → Edge → Supertonic2의 선택 우선순위와
152
+ 저장된 캐스팅을 유지합니다. 접수 뒤 공급자 변경·자동 유료 재합성은 없습니다.
153
+ - 접수 결과의 `generationJobId`로 `finish_speech_generation`을 호출합니다.
154
+ 완료 전이면 상태만 반환하고, 완료되면 보호된 오디오를 PC에 저장하고 **새로운**
155
+ `productionPath`를 반환합니다. 이를 `stitch_timeline`에 전달하세요.
156
+ 원래 제작 핸들의 제목·강조어·디자인·캐스팅은 보존하며 원본 파일을 덮어쓰지 않습니다.
157
+ ElevenLabs 자막은 공급자 문자 정렬, 나머지는 실제 오디오 길이에 따른 추정임을 표시합니다.
158
+ - 제출 전에 비공개 로컬 기록을 저장합니다. 응답 유실·MCP 재연결 후에는 같은 기록의
159
+ 작업을 조회·다운로드할 뿐 다시 POST하지 않습니다. 입력이 달라지면 자동으로 새 ID를
160
+ 만들지 않습니다. 결과 불명을 해결하려고 기록 삭제·PAT 교체·새 ID 합성을 하지 마세요.
161
+ 의도적으로 새 음성이 필요한 경우 기존 비용/상태를 확인한 뒤 별도 `requestId`를 지정합니다.
162
+ - 기록은 `stateDir/speech-production-v1`에 저장되며 PAT 원문은 저장하지 않습니다.
163
+ 다른 PAT/서버의 기록, 변조·불완전 파일은 거절합니다. 음성 파일의 TTL 정리와 달리
164
+ 제출 기록은 자동 삭제하지 않습니다(최대 4,096건/합계 64MiB, 개별 2MiB).
165
+ 한도에 도달하면 새 유료 접수를 차단하며,
166
+ 기존 ID의 조회·다운로드는 계속 가능합니다.
167
+ - 이 완료 도구는 위 두 제작 도구가 남긴 기록을 사용합니다. 독립 도구
168
+ `start_speech_generation`의 작업은 계속 `get_generation_job`으로 조회합니다.
169
+ 리메이크/리뷰 행별 합성은 아래의 별도 작업 흐름을 사용합니다.
170
+
171
+ ### 리메이크·리뷰 행별 공유 음성 (다음 릴리스용 변경, 아직 게시 전)
172
+
173
+ - `prepare_remake_timeline`, `prepare_review_timeline`은 인증된
174
+ `/api/tracker/mcp/narration/{remake|review}/execution-capability`를 먼저 확인합니다.
175
+ `binary`일 때만 기존 단일 서버 실행을 사용합니다. 구형 서버/통신 오류로 폴백하지
176
+ 않으므로 대응 서버를 먼저 배포해야 합니다.
177
+ - 공유 모드에서는 전체 행의 합성 비용에 동의한 뒤 시작하세요. 리메이크는 고정
178
+ `requestId`가 필요하고 리뷰는 제작 핸들의 `productionId`에서 작업 ID를 결정합니다.
179
+ Typecast·ElevenLabs·Supertone의 명시 보이스가 필요합니다. 공유 Edge/Supertonic2
180
+ 행별 실행은 아직 지원하지 않으며 유료·동기 대체 실행을 하지 않습니다.
181
+ - 반환된 `operationId`를 `continue_narrated_timeline`에 전달합니다. 이 도구는
182
+ **조회 전용이 아닙니다.** 기존 행을 확인한 후 아직 제출하지 않은 행을 호출당 최대
183
+ 1건 합성합니다. 접수한 행은 같은 작업 ID로 조회만 하며 실패·응답 유실 시
184
+ 다시 합성하지 않습니다. 완료 행의 비용 영수증도 함께 반환합니다.
185
+ - 리뷰는 각 음성의 실제 길이가 원본 구간을 넘으면 다음 행을 합성하기 전에 멈춥니다.
186
+ 최종 서버 조립에서도 소유권·입력·비용 확정·오디오 무결성·길이를 다시 확인합니다.
187
+ 원래 제목·자막 디자인·자동 소재 편집·캐스팅을 유지하고, 로컬 원본 경로는 서버에
188
+ 보내지 않습니다. 모두 완료되면 `payloadPath`를 `render_start`에 전달합니다.
189
+ - `stateDir/narration-timeline-v1`에 서버/PAT에 결속한 기록을 저장합니다.
190
+ 기록 삭제·PAT 교체·새 ID로 결과 불명을 우회하지 마세요. 기존 음성 조립은 새
191
+ TTS 비용을 발생시키지 않지만 서버의 렌더 이용 한도는 적용됩니다. 최종 미디어가
192
+ TTL로 정리되면 자동 재합성/재조립하지 않습니다. 보관 기록은 자동 삭제하지 않으며
193
+ 4,096개/64MiB 한도에 이르면 새 접수를 중단합니다.
194
+
141
195
  - 이미지·영상·LLM 폴백은 대응 서버가 지원하는 동일 기능/입력 경로에서만 동작합니다. SUNO, 음성 클로닝, 정밀 정렬 등 모든 독점 기능에 호환 대체가 있는 것은 아닙니다. 상태 조회만으로 미확인 유료 작업을 재생성하지 마세요.
142
196
 
197
+ ### 보이스 추천 공유 실행 (다음 릴리스용 변경, 아직 게시 전)
198
+
199
+ - `recommend_voice`는 대응 서버의 capability를 확인합니다. `legacy-sync`일 때만
200
+ 기존 단일 서버 경로를 쓰며, 공유 모드에서는 샘플 관측/추천을 하나의 영속 작업으로
201
+ 실행합니다. 대응 서버를 먼저 배포해야 하며 404·통신 실패를 동기 폴백으로 취급하지 않습니다.
202
+ - 동일한 주제·대본·프로필·샘플·요청 공급자 조합은 재연결 후 기존 작업만 조회합니다.
203
+ 새 유료 추천을 명시적으로 원할 때만 새 `requestId`(UUID)를 지정하세요. 불명 작업을
204
+ 우회하려고 ID·PAT·기록을 변경하지 마세요. 응답의 `backgroundJobId`와 비용 영수증을 보존합니다.
205
+ - Typecast → ElevenLabs → Supertone → Edge → Supertonic2 순서는 TTS 공급자 선택입니다.
206
+ 공유 **추천 LLM**은 Gemini 3 Flash입니다. 계정의 사용 가능한 키를 기준으로 호출 전에
207
+ EvoLink를 우선 선택하고, 해당 키가 없으면 KIE를 선택합니다. KIE-only 계정의 샘플
208
+ 스트리밍 분석도 지원합니다. 두 키 모두 없으면 무료 TTS 공급자의 샘플 없는 카탈로그
209
+ 추천만 가능합니다. 호출 후 공급자를 바꾸거나 샘플 분석을 조용히 생략하지 않습니다.
210
+ - 샘플은 앞 90초 이내만 관측하고 단계별 비용 기록 확인 후 다음 요청을 보냅니다.
211
+ 요청 실패/비용 확인 불명/JSON 오류에는 자동 유료 재시도를 하지 않습니다.
212
+ 보이스·자막 스타일·제작 프로필은 최신 `productionPath`로 다음 합성에 이어집니다.
213
+ - `stateDir/voice-recommendation-v1`은 별도 비공개 journal입니다(최대 4,096건,
214
+ 결과 파일당 64KiB). PAT 원문·카탈로그·샘플 오디오는 기록하지 않고 자동 삭제하지 않습니다.
215
+ 저장소/서버 작업이 정리되거나 손상되면 자동 재추천하지 않으므로 고객지원에 문의하세요.
216
+
217
+ ### 리뷰 기획 공유 실행 (다음 릴리스용 변경, 아직 게시 전)
218
+
219
+ - `plan_review_video`는 `/api/tracker/review/plan/execution-capability`의
220
+ 명시적 `queue-v2`에 따라 영속 작업을 제출합니다. `legacy-sync`일 때만
221
+ 기존 동기 기획을 사용합니다. 404·인증 실패·통신 오류로 유료 동기 기획을
222
+ 대신 실행하지 않으므로 **대응 서버를 먼저 배포한 뒤 MCP를 업데이트**합니다.
223
+ - 같은 `reviewSourcePath`·리뷰 설정·선택 장면으로 다시 호출하면 기존 작업만
224
+ 조회합니다. 응답의 `backgroundJobId`와 비용 영수증을 보존하세요.
225
+ 제출 결과가 불명확하거나 서버 기록이 만료되면 자동 재제출하지 않습니다.
226
+ 기록 삭제·PAT 교체·새 핸들로 불명 작업을 우회하지 말고 고객지원에 문의하세요.
227
+ - 전체 분석/원본 재검증 완료 조건, 전체 전사와 원본 구간 근거, 말투·자막의
228
+ 사실/해석 구분을 유지합니다. 원본 핸들이 변경되거나 없어지면 이미 받은 결과와
229
+ 섞지 않고 기존 작업 ID·확정 비용을 포함한 오류를 반환합니다. 선택한 TTS 공급자와
230
+ 보이스는 제작 핸들에 보존하며 보이스만 변경해도 기획 LLM을 다시 호출하지 않습니다.
231
+ - `stateDir/review-plan-v1`에는 비공개 제출·결과 기록을 저장합니다.
232
+ 결과에는 원본 근거/전사가 포함되므로 민감한 자료처럼 보관하세요. PAT 원문과
233
+ 로컬 원본 파일 경로는 저장하지 않습니다. 최대 32건/결과 파일당 20MiB이며
234
+ 자동 삭제하지 않습니다. 한도에 이르면 새 제출을 차단하고 기존 작업 조회는 유지합니다.
235
+
236
+ ### 쇼츠·커머스·뮤직비디오 기획 공유 실행 (다음 릴리스용 변경, 아직 게시 전)
237
+
238
+ - `plan_shorts_video`, `plan_commerce_video`, `plan_music_video`는 각각의 인증된
239
+ `/plan/execution-capability`에서 `queue-v2`를 확인하면 전용 `/plan/jobs`에
240
+ 제출합니다. `legacy-sync`만 기존 동기 실행을 허용하며, 404·인증·통신 오류는
241
+ 동기 폴백 허가가 아닙니다. 대응 서버 배포 후 MCP 새 버전을 게시해야 합니다.
242
+ - 동일한 기획 옵션으로 다시 호출하면 알려진 작업 ID만 조회합니다. 접수 응답이
243
+ 유실돼 ID가 불명확해도 자동 재제출하지 않습니다. 기록·PAT·입력을 바꾸어
244
+ 불명 작업을 우회하지 마세요. 반환된 `backgroundJobId`와 비용 영수증을 보존합니다.
245
+ - 커머스 기획은 파일을 열기 전에 기존 접수를 조회하고, 동일 경로의 상품/모델 사진
246
+ 바이트까지 제출 당시 입력과 대조합니다. 사진 변경·삭제 시 기존 결과를 새 입력과
247
+ 섞지 않으며 기존 작업 ID·비용을 포함한 오류를 반환합니다.
248
+ - 자막·제작 프로필, 캐릭터, 이미지 소스/모션 선택, 채널 규격, 상품 조사 근거,
249
+ 음악 구간을 유지합니다. 결과 핸들 경로는 새로 만들지만 복구된 기획의
250
+ `productionId`는 유지하므로 후속 영속 TTS를 새 제작으로 오인하지 않습니다.
251
+ 리뷰 기획 복구에도 같은 제작 ID 보존을 적용합니다.
252
+ - `stateDir/studio-plan-v1`은 비공개 기록입니다. 최대 32개/결과 파일당 10MiB이며
253
+ 자동 정리하지 않습니다. 입력 원문/사진/PAT/로컬 파일 경로는 기록하지 않고
254
+ 입력 digest와 기획 결과·확정 비용을 저장합니다. 결과에 민감한 내용이 있을 수
255
+ 있으므로 보호하세요. 한도에 이르면 새 제출만 차단하고 기존 작업 조회는 유지합니다.
256
+ - 이것은 시나리오 기획 연결이며 상품 웹 조사, 음악 생성/가사 정렬, 원본 영상 분석,
257
+ 교사 비동기 과금 등 다른 경로까지 모두 공유 실행된다는 뜻은 아닙니다.
258
+
143
259
  ## 장면 이미지 준비와 영상 표현 선택
144
260
 
145
261
  다음 설정은 0.12.0부터 지원합니다. 구형 클라이언트는 위의 자동 업데이트 전환 안내에 따라 한 번 전환하고 유휴 상태에서 다시 연결해야 새 입력과 도구 안내를 사용할 수 있습니다. 이미지 모션 렌더에는 해당 기능을 지원하는 최신 컴패니언 런타임도 필요합니다.
@@ -1,4 +1,7 @@
1
1
  import { createChannelMeasurementClient } from "./durable-channel-client.mjs";
2
+ import { createVoiceRecommendationClient } from "./voice-recommendation-client.mjs";
3
+ import { createStudioPlanningClient, studioPlanReceipt, withStudioPlanReceipt } from './studio-plan-client.mjs';
4
+ import { compactVoiceCatalog } from "./voice-catalog.mjs";
2
5
 
3
6
  import { createReadStream, readFileSync, statSync } from "node:fs";
4
7
  import path from "node:path";
@@ -26,6 +29,8 @@ import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
26
29
  import { createUsageEventId } from "./usage-event.mjs";
27
30
  import { fetchGenerationJob, GENERATION_JOB_PATH, submitGenerationJob, requestGenerationJobAction } from "./generation-jobs.mjs";
28
31
  import { durableSpeechToolSchema, submitDurableSpeech, durableSpeechResult } from "./durable-speech.mjs";
32
+ import { trySharedSpeechProduction, finishSpeechProduction } from "./speech-production.mjs";
33
+ import { trySharedNarrationTimeline, continueNarratedTimeline } from "./narration-timeline.mjs";
29
34
  import { loudnessDbfsFromWav, silenceRatioFromWav } from "./wav-dsp.mjs";
30
35
  import { trimWavToSeconds } from "./wav-trim.mjs";
31
36
  import { registerComposerTools } from "./composer-tools.mjs";
@@ -101,6 +106,10 @@ function jsonResult(value) {
101
106
  return textResult(JSON.stringify(value, null, 2));
102
107
  }
103
108
 
109
+ function speechProductionResult(value) {
110
+ return textResult(JSON.stringify(value, null, 2), { isError: !["pending", "running", "completed"].includes(value.status) });
111
+ }
112
+
104
113
  // JSON + 이미지 블록 결과 — 비전 지원 클라이언트의 에이전트가 산출물을 "직접 보고"
105
114
  // QC(1층 검증)할 수 있게 한다. 비전 미지원 클라이언트는 이미지 블록을 무시한다.
106
115
  function jsonWithImagesResult(value, images = []) {
@@ -160,6 +169,10 @@ export function wrapCloudHandler(config, handler) {
160
169
  try {
161
170
  return await handler(args, extra);
162
171
  } catch (error) {
172
+ if (error?.paidReplayAllowed === false) return textResult(JSON.stringify({
173
+ error: describeLocalError(error), code: error.code, ...studioPlanReceipt(error),
174
+ guidance: "기존 작업 ID와 비용을 확인하세요. 결과 불명 작업을 자동으로 재제출하지 마세요.",
175
+ }), { isError: true });
163
176
  if (error instanceof AimakeallApiError) {
164
177
  const retry = error.retryAfterSeconds ? ` (${error.retryAfterSeconds}초 후 재시도)` : "";
165
178
  if (error.partialProviderUsage || error.accountCostEvents.length || error.fallbackStatus) {
@@ -177,6 +190,32 @@ export function wrapCloudHandler(config, handler) {
177
190
  };
178
191
  }
179
192
 
193
+ async function runStudioPlan(config, api, operation, intent, buildBody, apply) {
194
+ const client = createStudioPlanningClient(config, api, operation, intent), recovered = await client.recover();
195
+ return withStudioPlanReceipt(recovered, async () => {
196
+ const body = await buildBody();
197
+ let payload = recovered;
198
+ if (payload) await client.assertInputMatches(body);
199
+ else {
200
+ const mode = await client.readCapability();
201
+ if (mode === 'legacy-sync') payload = await client.legacy(body);
202
+ else if (mode === 'queue-v2') payload = await client.run(body);
203
+ else throw Object.assign(Error('공유 시나리오 실행을 현재 사용할 수 없습니다.'), { code: 'STUDIO_PLAN_UNAVAILABLE', paidReplayAllowed: false });
204
+ }
205
+ return withStudioPlanReceipt(payload, () => apply(payload));
206
+ });
207
+ }
208
+
209
+ function studioPlanningContext(payload) {
210
+ return {
211
+ // Immutable local handles may have new paths, but a recovered plan is the
212
+ // same production. Downstream durable TTS must not get a fresh random ID.
213
+ ...(payload?.backgroundJobId ? { productionId: 'studio-plan:' + payload.backgroundJobId } : {}),
214
+ planningReceipt: studioPlanReceipt(payload),
215
+ charactersUsed: payload?.charactersUsed || [],
216
+ };
217
+ }
218
+
180
219
  // 씬 응답에서 모델 컨텍스트에 유용한 필드만 추린다 (base64/내부 필드 제외).
181
220
  function trimPlanScenes(scenes) {
182
221
  return (Array.isArray(scenes) ? scenes : []).map((scene) => ({
@@ -271,6 +310,13 @@ export function registerCloudTools(server, config, api) {
271
310
  }),
272
311
  );
273
312
 
313
+ server.tool(
314
+ "finish_speech_generation",
315
+ "tts_narration/tts_narration_with_captions가 공유 모드에서 반환한 기존 작업만 조회해 완료 오디오와 자막을 PC에 저장하고 productionPath를 반환합니다. 합성 POST·재과금 없음. MCP 재시작 뒤에도 같은 ID를 사용하세요. start_speech_generation 단독 작업에는 해당 로컬 제작 기록이 없어 사용할 수 없습니다.",
316
+ { generationJobId: z.string().regex(/^[a-zA-Z0-9_-]{16,80}$/u) },
317
+ wrapCloudHandler(config, async ({ generationJobId }) => speechProductionResult(await finishSpeechProduction(api, config, generationJobId))),
318
+ );
319
+
274
320
  server.tool(
275
321
  "resume_generation_job",
276
322
  "내 계정의 기존 생성 작업에서 접수된 공급자 작업 상태·결과를 복구합니다. 원래 유료 생성은 다시 제출하지 않습니다. get_generation_job의 workflow.canResume이 true일 때 사용하세요.",
@@ -347,7 +393,7 @@ export function registerCloudTools(server, config, api) {
347
393
 
348
394
  server.tool(
349
395
  "plan_shorts_video",
350
- "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조).",
396
+ "쇼츠 영상 기획(시나리오·씬별 이미지 프롬프트·내레이션)을 생성합니다. topic 또는 copy 중 하나는 필수. 결과 scenes의 imagePrompt는 generate_scene_image로, skill은 씬 영상 프롬프트의 videoStylePreset으로, emphasisKeywords는 stitch_timeline의 emphasisKeywords로 이어집니다(하단자막 단어 강조). 공유 모드에서는 제출 기록을 보존하고 같은 입력 재연결은 기존 작업만 조회합니다. 접수 불명 작업은 자동 재제출하지 않습니다.",
351
397
  {
352
398
  productionProfile: productionFields.productionProfile,
353
399
  ...visualProductionFields,
@@ -385,8 +431,9 @@ export function registerCloudTools(server, config, api) {
385
431
  return textResult("topic 또는 copy 중 하나는 입력해야 합니다.", { isError: true });
386
432
  }
387
433
  const visualSettings = resolveVisualSettings(null, { productionProfile, imageSourceMode, visualMotionMode });
388
- const payload = await api.request("/api/tracker/shorts-video/plan", {
389
- body: {
434
+ return runStudioPlan(config, api, 'shorts',
435
+ { ...visualSettings, productionProfile, durationTargetSec, categoryId, channelFingerprint, copy, sceneCount, characters, targetCustomer, tone, topic },
436
+ () => ({
390
437
  ...visualSettings,
391
438
  categoryId,
392
439
  productionProfile,
@@ -401,17 +448,16 @@ export function registerCloudTools(server, config, api) {
401
448
  targetCustomer,
402
449
  tone,
403
450
  topic,
404
- },
405
- method: "POST",
406
- timeoutMs: 240_000,
407
- });
451
+ }), payload => {
408
452
  const productionPath = saveProductionContext(config.stateDir, {
453
+ ...studioPlanningContext(payload),
409
454
  topic, visualSettings, autoCompose: productionProfile?.autoComposeEnabled ?? true,
410
455
  productionProfile: payload?.productionProfile, narrationScript: payload?.narrationScript,
411
456
  narrationSource: payload?.narrationSource, tagline: payload?.tagline, emphasisKeywords: payload?.emphasisKeywords || [],
412
457
  channelFingerprint, scenes: trimPlanScenes(payload?.scenes),
413
458
  });
414
459
  return jsonResult({
460
+ ...studioPlanReceipt(payload),
415
461
  productionPath,
416
462
  ...visualSettings,
417
463
  productionProfile: payload?.productionProfile,
@@ -427,6 +473,7 @@ export function registerCloudTools(server, config, api) {
427
473
  specWarnings: Array.isArray(payload?.specWarnings) && payload.specWarnings.length ? payload.specWarnings : undefined,
428
474
  tagline: payload?.tagline,
429
475
  });
476
+ });
430
477
  }),
431
478
  );
432
479
 
@@ -509,7 +556,7 @@ export function registerCloudTools(server, config, api) {
509
556
 
510
557
  server.tool(
511
558
  "plan_commerce_video",
512
- "제품 홍보 영상 기획을 생성합니다. 제품 사진 파일 경로가 최소 1장 필요합니다 (이 PC의 로컬 경로).",
559
+ "제품 홍보 영상 기획을 생성합니다. 제품 사진 파일 경로가 최소 1장 필요합니다 (이 PC의 로컬 경로). 공유 모드에서는 같은 입력으로 다시 호출해 기존 작업만 조회하며 사진 변경 시 이전 결과를 적용하지 않습니다. 접수 불명 작업은 자동 재제출하지 않습니다.",
513
560
  {
514
561
  ...visualProductionFields,
515
562
  productionProfile: productionFields.productionProfile,
@@ -532,13 +579,16 @@ export function registerCloudTools(server, config, api) {
532
579
  },
533
580
  wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" , characters = [], productionProfile, imageSourceMode, visualMotionMode }) => {
534
581
  const visualSettings = resolveVisualSettings(null, { productionProfile, imageSourceMode, visualMotionMode });
582
+ return runStudioPlan(config, api, 'commerce',
583
+ { ...visualSettings, productionProfile, productName, productImagePaths: productImagePaths.map(file => path.resolve(file)),
584
+ modelImagePath: modelImagePath ? path.resolve(modelImagePath) : '', description, targetCustomer, tone, categoryId, sceneCount, researchBrief, characters },
585
+ () => {
535
586
  // 확장자 검증(임의 파일 업로드 차단) + 합산 크기 예산(서버 413 사전 차단).
536
587
  for (const filePath of productImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "이미지" });
537
588
  if (modelImagePath) assertAllowedInputFile(modelImagePath, ALLOWED_IMAGE_EXTS, { kind: "모델 이미지" });
538
589
  assertEncodedBudget([...productImagePaths, modelImagePath], { context: "제품/모델 사진" });
539
590
 
540
- const payload = await api.request("/api/tracker/commerce-video/plan", {
541
- body: {
591
+ return {
542
592
  ...visualSettings, productionProfile,
543
593
  categoryId,
544
594
  description,
@@ -553,25 +603,27 @@ export function registerCloudTools(server, config, api) {
553
603
  skillOverride: null,
554
604
  targetCustomer,
555
605
  tone,
556
- },
557
- method: "POST",
558
- timeoutMs: 300_000,
559
- });
606
+ };
607
+ }, payload => {
560
608
  const productionPath = saveProductionContext(config.stateDir, {
609
+ ...studioPlanningContext(payload),
561
610
  topic: productName, visualSettings, autoCompose: productionProfile?.autoComposeEnabled ?? true,
562
611
  productionProfile: payload?.productionProfile || productionProfile,
563
612
  tagline: payload?.tagline, narrationScript: payload?.narrationScript,
564
613
  scenes: trimPlanScenes(payload?.scenes), featureKey: "commerceVideo",
565
614
  });
566
615
  return jsonResult({
616
+ ...studioPlanReceipt(payload),
567
617
  productionPath, ...visualSettings, productionProfile: payload?.productionProfile || productionProfile,
568
618
  analysis: trimAnalysis(payload?.analysis),
619
+ charactersUsed: payload?.charactersUsed,
569
620
  ctaText: payload?.ctaText,
570
621
  ok: payload?.ok,
571
622
  scenes: trimPlanScenes(payload?.scenes),
572
623
  skill: payload?.skill,
573
624
  tagline: payload?.tagline,
574
625
  });
626
+ });
575
627
  }),
576
628
  );
577
629
 
@@ -757,9 +809,10 @@ export function registerCloudTools(server, config, api) {
757
809
 
758
810
  server.tool(
759
811
  "tts_narration",
760
- "내레이션 TTS를 합성해 이 PC에 저장합니다. 자동 순서: Typecast → ElevenLabs → Supertone → Edge → Supertonic2. 확정된 일시 거절에만 폴백하며, 명시 보이스는 fallbackVoices가 있어야 교체합니다. 접수 후 결과 불명·정책·인증 오류는 재합성하지 않습니다. 먼저 recommend_voice로 톤에 맞는 보이스를 고르세요.",
812
+ "내레이션 TTS. 자동 순서 Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 공유 모드에서는 작업 ID를 반환하며 finish_speech_generation으로 PC 파일·제작 핸들을 완성합니다. 제작 핸들이 없으면 requestId 필수. 공유 모드의 공급자 폴백은 지원하지 않습니다. 단일 서버에서만 확정된 일시 거절에 허용된 폴백을 사용하며 결과 불명 시 재합성하지 않습니다.",
761
813
  {
762
814
  ...productionFields,
815
+ requestId: z.string().regex(/^[a-zA-Z0-9_-]{16,80}$/u).optional().describe("공유 모드에서 동일 요청에 유지할 ID. 제작 핸들이 있으면 자동 결정. 의도적인 새 합성만 새 ID를 지정하세요."),
763
816
  text: z.string().optional().describe("생략 시 productionPath의 기획 대본"),
764
817
  ...ttsFallbackFields,
765
818
  provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 명시 선택/저장된 보이스는 유지"),
@@ -768,11 +821,15 @@ export function registerCloudTools(server, config, api) {
768
821
  stability: z.number().min(0).max(1).optional(), style: z.number().min(0).max(1).optional(),
769
822
  modelId: z.string().optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
770
823
  },
771
- wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, stability, style, modelId, similarityBoost, speakerBoost, fallbackVoices, allowProviderFallback }) => {
824
+ wrapCloudHandler(config, async ({ requestId, text, provider, voice = "", speed, productionPath, productionProfile, stability, style, modelId, similarityBoost, speakerBoost, fallbackVoices, allowProviderFallback }) => {
772
825
  const context = readProductionContext(config.stateDir, productionPath);
826
+ const sharedSpeech = await trySharedSpeechProduction(api, config, { context, args: {
827
+ requestId, text, provider, voice, speed, productionPath, productionProfile, stability, style, modelId, similarityBoost, speakerBoost, fallbackVoices, allowProviderFallback,
828
+ } });
829
+ if (sharedSpeech?.result) return speechProductionResult(sharedSpeech.result);
773
830
  productionProfile = context?.productionProfile || productionProfile;
774
831
  text ??= context?.narrationScript;
775
- const selection = await resolveTtsSelection(api, config, { provider, context });
832
+ const selection = sharedSpeech?.legacySelection || await resolveTtsSelection(api, config, { provider, context });
776
833
  provider = selection.provider;
777
834
  if (!voice && context?.voice?.provider && context.voice.provider !== provider) return textResult("보이스 공급자가 다릅니다. 선택한 provider용 voice를 다시 선정하세요.", { isError: true });
778
835
  voice ||= context?.voice?.voiceId || "";
@@ -1279,7 +1336,7 @@ export function registerCloudTools(server, config, api) {
1279
1336
  // ── 뮤직비디오 기획 ──────────────────────────────────────────────────────────
1280
1337
  server.tool(
1281
1338
  "plan_music_video",
1282
- "뮤직비디오 씬 플랜을 생성합니다. suno_music_download로 받은 곡의 가사·길이(durationSec)를 입력하세요. 결과 scenes의 imagePrompt는 generate_scene_image로 이어집니다.",
1339
+ "뮤직비디오 씬 플랜을 생성합니다. suno_music_download로 받은 곡의 가사·길이(durationSec)를 입력하세요. 결과 scenes의 imagePrompt는 generate_scene_image로 이어집니다. 공유 모드에서는 같은 입력 재연결은 기존 작업만 조회하며 비용 영수증과 음악 구간을 보존합니다. 접수 불명 작업은 자동 재제출하지 않습니다.",
1283
1340
  {
1284
1341
  ...visualProductionFields,
1285
1342
  productionProfile: productionFields.productionProfile,
@@ -1304,8 +1361,9 @@ export function registerCloudTools(server, config, api) {
1304
1361
  return textResult("lyrics를 입력하거나 instrumental=true로 지정하세요.", { isError: true });
1305
1362
  }
1306
1363
  const visualSettings = resolveVisualSettings(null, { productionProfile, imageSourceMode, visualMotionMode });
1307
- const payload = await api.request("/api/tracker/music-video/plan", {
1308
- body: {
1364
+ return runStudioPlan(config, api, 'music',
1365
+ { ...visualSettings, productionProfile, durationSec, lyrics, conceptBrief, instrumental, sceneSeconds, splitMode, videoStylePreset, aspectRatio, characters },
1366
+ () => ({
1309
1367
  ...visualSettings, productionProfile,
1310
1368
  alignedLines: [],
1311
1369
  aspectRatio,
@@ -1319,19 +1377,19 @@ export function registerCloudTools(server, config, api) {
1319
1377
  songMeta: {},
1320
1378
  splitMode,
1321
1379
  videoStylePreset,
1322
- },
1323
- method: "POST",
1324
- timeoutMs: 240_000,
1325
- });
1326
- // usage/원장 이벤트 등 컨텍스트 낭비 필드는 제거하고 기획 본문만 돌려준다.
1327
- const { accountCostEvents, providerUsage, usage, ...plan } = payload && typeof payload === "object" ? payload : {};
1380
+ }), payload => {
1381
+ // Keep settled public receipts for recovery; omit only raw provider detail.
1382
+ const { providerUsage, ...plan } = payload && typeof payload === "object" ? payload : {};
1328
1383
  const scenes = trimPlanScenes(plan.scenes);
1329
1384
  const productionPath = saveProductionContext(config.stateDir, {
1385
+ ...studioPlanningContext(payload),
1330
1386
  topic: conceptBrief, visualSettings, autoCompose: productionProfile?.autoComposeEnabled ?? true,
1331
1387
  productionProfile: plan.productionProfile || productionProfile,
1388
+ musicPlan: { structure: plan.structure, sceneInputs: plan.sceneInputs, splitSource: plan.splitSource, inferredVibe: plan.inferredVibe },
1332
1389
  scenes, featureKey: "musicVideo",
1333
1390
  });
1334
1391
  return jsonResult({ ...plan, scenes, ...visualSettings, productionPath });
1392
+ });
1335
1393
  }),
1336
1394
  );
1337
1395
 
@@ -1423,10 +1481,18 @@ export function registerCloudTools(server, config, api) {
1423
1481
  );
1424
1482
 
1425
1483
  // ── 짜집기 렌더 준비 — 컷 세트 → TTS + 타임라인 페이로드 ────────────────────
1484
+ server.tool("continue_narrated_timeline",
1485
+ "공유 리메이크·리뷰 나레이션을 이어갑니다. 기존 행은 조회만 하고, 완료 확인 후 아직 제출하지 않은 행을 호출당 최대 1건 유료 합성합니다. 전체 행 비용에 동의한 경우에만 사용하세요. 실패·결과 불명 행은 자동 재합성하지 않습니다. 모두 완료되면 기존 음성으로 타임라인을 조립합니다.",
1486
+ { operationId: z.string().regex(/^[A-Za-z0-9_-]{16,80}$/u) },
1487
+ wrapCloudHandler(config, async ({ operationId }) => {
1488
+ const value = await continueNarratedTimeline(api, config, operationId);
1489
+ return { ...jsonResult(value), ...(!["pending", "completed"].includes(value.status) ? { isError: true } : {}) };
1490
+ }));
1426
1491
  server.tool(
1427
1492
  "prepare_remake_timeline",
1428
1493
  "짜집기(리메이크) 렌더 준비 — analyze_edit_points 의 sceneCatalog 에서 고른 컷들로 구성한 컷 세트를 서버에 보내 TTS 합성 + 렌더 타임라인을 만듭니다. 반환된 payloadPath 를 render_start 에 넘기면 이 PC 에서 원본 다운로드 + 렌더가 수행됩니다. [N]/[SN] 행의 audioContent 는 유료 TTS 로 합성되므로 실행 전 사용자 확인을 받으세요.",
1429
1494
  {
1495
+ requestId: z.string().regex(/^[A-Za-z0-9_-]{16,80}$/u).optional().describe("공유 실행에서는 필수인 고정 요청 ID. 중단 후 같은 operationId로 continue_narrated_timeline을 호출하세요. 전체 행 TTS 비용에 동의한 뒤 시작하세요."),
1430
1496
  title: z.string().optional().describe("영상 상단 타이틀 텍스트 (없으면 생략)"),
1431
1497
  sourceVideos: z.array(z.object({
1432
1498
  id: z.string().describe("YouTube video id (11자)"),
@@ -1453,12 +1519,17 @@ export function registerCloudTools(server, config, api) {
1453
1519
  aspectRatio: z.enum(["9:16", "16:9", "1:1"]).optional().describe("기본 9:16"),
1454
1520
  subtitleCropMode: z.enum(["off", "always", "auto"]).optional().describe("원본 박힌 자막 하단 크롭 — auto 는 burnedSubtitle.present 소스만"),
1455
1521
  },
1456
- wrapCloudHandler(config, async ({ title = "", sourceVideos, rows, provider, voiceId = "", aspectRatio = "9:16", subtitleCropMode = "off" }) => {
1522
+ wrapCloudHandler(config, async ({ requestId, title = "", sourceVideos, rows, provider, voiceId = "", aspectRatio = "9:16", subtitleCropMode = "off" }) => {
1457
1523
  const sourceIds = new Set(sourceVideos.map((source) => source.id));
1458
1524
  const badRef = rows.flatMap((row) => row.clipRefs).find((ref) => !sourceIds.has(ref.videoId));
1459
1525
  if (badRef) {
1460
1526
  return textResult(`clipRefs 의 videoId "${badRef.videoId}" 가 sourceVideos 에 없습니다.`, { isError: true });
1461
1527
  }
1528
+ const version = { id: "mcp-remake", title, sourceVideos, rows: rows.map(row => ({ ...row,
1529
+ clipRefs: row.clipRefs.map(ref => ({ ...ref, durationSec: Math.max(0.3, (ref.endMs - ref.startMs) / 1000) })) })) };
1530
+ const shared = await trySharedNarrationTimeline(api, config, { kind: "remake", requestId, provider, voiceId,
1531
+ body: { version, aspectRatio, subtitleCropMode } });
1532
+ if (shared) return { ...jsonResult(shared), ...(!["pending", "completed"].includes(shared.status) ? { isError: true } : {}) };
1462
1533
  const hasNarration = rows.some((row) => ["N", "SN"].includes(row.mode) && row.audioContent?.trim());
1463
1534
  const selection = hasNarration ? await resolveTtsSelection(api, config, { provider }) : { provider: "typecast", executionTarget: "server" };
1464
1535
  provider = selection.provider;
@@ -1473,18 +1544,6 @@ export function registerCloudTools(server, config, api) {
1473
1544
  }
1474
1545
  preSynthesizedNarrations = { narrations, attempted: Object.keys(narrations).length, succeeded: Object.keys(narrations).length, lastError: "" };
1475
1546
  }
1476
- const version = {
1477
- id: "mcp-remake",
1478
- title,
1479
- sourceVideos,
1480
- rows: rows.map((row) => ({
1481
- ...row,
1482
- clipRefs: row.clipRefs.map((ref) => ({
1483
- ...ref,
1484
- durationSec: Math.max(0.3, (ref.endMs - ref.startMs) / 1000),
1485
- })),
1486
- })),
1487
- };
1488
1547
  const payload = await api.request("/api/tracker/remake/prepare", {
1489
1548
  body: {
1490
1549
  aspectRatio,
@@ -1519,9 +1578,10 @@ export function registerCloudTools(server, config, api) {
1519
1578
  // ── 시간 동기 자막 TTS — 쇼츠 하단자막용 ─────────────────────────────────────
1520
1579
  server.tool(
1521
1580
  "tts_narration_with_captions",
1522
- "TTS 오디오와 자막을 만듭니다. 순서 Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 명시 목소리는 fallbackVoices 없이 변경하지 않습니다. ElevenLabs 선택 시 실제 char-level 타이밍을 유지하며 해당 정밀 기능은 대체하지 않습니다. 나머지는 실측 오디오 길이 기반 추정 자막이며 추가 STT 과금은 없습니다.",
1581
+ "TTS 오디오와 자막. 순서 Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 공유 모드에서는 ID를 반환하며 finish_speech_generation으로 PC 파일·제작 핸들을 완성합니다. 제작 핸들 없으면 requestId 필수, 공유 공급자 폴백 없음. ElevenLabs는 실제 문자 타이밍, 나머지는 오디오 길이 기반 추정 자막이며 추가 STT 과금은 없습니다.",
1523
1582
  {
1524
1583
  ...productionFields,
1584
+ requestId: z.string().regex(/^[a-zA-Z0-9_-]{16,80}$/u).optional().describe("공유 모드의 고정 요청 ID. 제작 핸들이 없으면 필수. 같은 ID로 조회하며 새 유료 요청을 만들지 마세요."),
1525
1585
  text: z.string().optional().describe("생략 시 제작 핸들의 기획 대본 그대로 합성. 사실·인용을 바꾸거나 뉴스체로 재작성하지 마세요."),
1526
1586
  ...ttsFallbackFields,
1527
1587
  provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동 선택 또는 제작 핸들에 선정된 공급자"),
@@ -1530,11 +1590,15 @@ export function registerCloudTools(server, config, api) {
1530
1590
  modelId: z.string().optional(), stability: z.number().min(0).max(1).optional(),
1531
1591
  style: z.number().min(0).max(1).optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
1532
1592
  },
1533
- wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, modelId, stability, style, similarityBoost, speakerBoost, fallbackVoices, allowProviderFallback }) => {
1593
+ wrapCloudHandler(config, async ({ requestId, text, provider, voice = "", speed, productionPath, productionProfile, modelId, stability, style, similarityBoost, speakerBoost, fallbackVoices, allowProviderFallback }) => {
1534
1594
  const context = readProductionContext(config.stateDir, productionPath);
1595
+ const sharedSpeech = await trySharedSpeechProduction(api, config, { context, withCaptions: true, args: {
1596
+ requestId, text, provider, voice, speed, productionPath, productionProfile, modelId, stability, style, similarityBoost, speakerBoost, fallbackVoices, allowProviderFallback,
1597
+ } });
1598
+ if (sharedSpeech?.result) return speechProductionResult(sharedSpeech.result);
1535
1599
  productionProfile = context?.productionProfile || productionProfile;
1536
1600
  text ??= context?.narrationScript;
1537
- const selection = await resolveTtsSelection(api, config, { provider, context });
1601
+ const selection = sharedSpeech?.legacySelection || await resolveTtsSelection(api, config, { provider, context });
1538
1602
  provider = selection.provider;
1539
1603
  if (!voice && context?.voice?.provider && context.voice.provider !== provider) {
1540
1604
  return textResult("보이스 공급자가 다릅니다. 선택한 provider용 voice를 다시 선정하세요.", { isError: true });
@@ -1648,18 +1712,24 @@ export function registerCloudTools(server, config, api) {
1648
1712
  sampleVideoUrl: z.string().optional().describe("사용자가 샘플로 지정한 유튜브 URL 또는 11자 영상 ID"),
1649
1713
  provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 키가 없으면 다음 후보, 합성 실패 후 자동 재과금 없음"),
1650
1714
  sampleVoiceHints: z.string().optional().describe("사용자가 말한 보이스 요구 (예: 차분한 중년 남성)"),
1715
+ requestId: z.string().uuid().optional().describe("새 추천을 명시적으로 요청할 때만 새 UUID를 지정합니다. 복구·재연결에는 같은 값을 유지하며 자동 변경하지 않습니다."),
1651
1716
  },
1652
- wrapCloudHandler(config, async ({ topic = "", scriptExcerpt = "", sampleVideoUrl = "", provider, sampleVoiceHints = "", productionPath, productionProfile }) => {
1717
+ wrapCloudHandler(config, async ({ topic = "", scriptExcerpt = "", sampleVideoUrl = "", provider, sampleVoiceHints = "", productionPath, productionProfile, requestId }) => {
1718
+ const requestedProvider = provider || 'auto';
1653
1719
  const context = readProductionContext(config.stateDir, productionPath);
1654
1720
  productionProfile = context?.productionProfile || productionProfile;
1655
1721
  const selection = await resolveTtsSelection(api, config, { provider, context });
1656
1722
  provider = selection.provider;
1657
1723
  topic ||= context?.topic || "";
1658
1724
  scriptExcerpt ||= context?.narrationScript?.slice(0, 2000) || "";
1725
+ const casting = createVoiceRecommendationClient(config, api, { requestedProvider, topic, scriptExcerpt, sampleVideoUrl: canonicalYoutubeWatchUrl(sampleVideoUrl) || sampleVideoUrl, sampleVoiceHints, productionProfile, requestId });
1726
+ const recovered = await casting.recover();
1727
+ const castingMode = recovered ? 'queue-v2' : await casting.readCapability();
1728
+ if (!['queue-v2', 'legacy-sync'].includes(castingMode)) throw Error('현재 서버에서 보이스 추천의 안전한 공유 실행을 사용할 수 없습니다.');
1659
1729
  const notes = [];
1660
1730
  let sampleAudioBase64 = "";
1661
1731
  const rawSample = String(sampleVideoUrl || "").trim();
1662
- if (rawSample) {
1732
+ if (rawSample && !recovered) {
1663
1733
  // 모바일/뮤직/shorts/youtu.be 변형을 표준 watch URL 로 정규화 — 컴패니언의
1664
1734
  // 유튜브 전용 처리(봇 우회)가 표준 형태에서만 발동한다.
1665
1735
  const watchUrl = canonicalYoutubeWatchUrl(rawSample);
@@ -1676,18 +1746,27 @@ export function registerCloudTools(server, config, api) {
1676
1746
  );
1677
1747
  }
1678
1748
  }
1679
- if (!topic && !scriptExcerpt && !sampleVoiceHints && !sampleAudioBase64) {
1749
+ if (!recovered && !topic && !scriptExcerpt && !sampleVoiceHints && !sampleAudioBase64) {
1680
1750
  // 샘플만 줬는데 추출이 실패한 경우: 누락 안내가 아니라 실패 사유를 그대로 전달한다.
1681
1751
  if (notes.length) {
1682
1752
  return textResult(`${notes.join("\n")}\n주제(topic)를 함께 주면 샘플 없이도 추천할 수 있습니다.`, { isError: true });
1683
1753
  }
1684
1754
  return textResult("topic, scriptExcerpt, sampleVoiceHints, sampleVideoUrl 중 하나는 필요합니다.", { isError: true });
1685
1755
  }
1686
- const payload = await api.request("/api/tracker/tts/recommend-voice", {
1687
- body: { provider, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic, productionProfile, ...(selection.localVoices ? { localVoiceCatalog: selection.localVoices } : {}) },
1688
- method: "POST",
1689
- timeoutMs: 180_000,
1690
- });
1756
+ let generated = recovered;
1757
+ let payload;
1758
+ if (!generated && castingMode === 'legacy-sync') payload = await casting.legacy({ provider, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic, productionProfile,
1759
+ ...(selection.localVoices ? { localVoiceCatalog: selection.localVoices } : {}) });
1760
+ else {
1761
+ if (!generated) {
1762
+ const voices = selection.localVoices || (await api.request(`/api/tracker/tts/${provider}/voices`, { method: 'GET', timeoutMs: 30000 })).voices;
1763
+ const voiceCatalog = compactVoiceCatalog(voices, provider);
1764
+ generated = await casting.run({ ttsProvider: provider, voiceCatalog, sampleAudioBase64, sampleVoiceHints, scriptExcerpt, topic,
1765
+ ...(productionProfile ? { productionProfile } : {}) });
1766
+ }
1767
+ if (generated.voiceRecommendation?.ttsProvider !== provider) throw Error('VOICE_RECOMMENDATION_PROVIDER_MISMATCH');
1768
+ payload = { ...generated.voiceRecommendation, provider, ok: true };
1769
+ }
1691
1770
  if (payload?.ok === false) {
1692
1771
  return textResult(
1693
1772
  `보이스 추천 실패: ${payload?.error || "알 수 없는 오류"}\n${payload?.guidance || ""}`.trim(),
@@ -1701,8 +1780,9 @@ export function registerCloudTools(server, config, api) {
1701
1780
  notes: [...notes, ...(Array.isArray(payload?.notes) ? payload.notes : [])],
1702
1781
  provider: payload?.provider || provider,
1703
1782
  recommendations: Array.isArray(payload?.recommendations) ? payload.recommendations : [],
1704
- sampleAnalyzed: Boolean(sampleAudioBase64 && payload?.sampleVoiceProfile),
1783
+ sampleAnalyzed: Boolean(payload?.sampleVoiceProfile),
1705
1784
  sampleVoiceProfile: payload?.sampleVoiceProfile || null,
1785
+ ...(generated ? { backgroundJobId: generated.backgroundJobId, usage: generated.usage, accountCostEvents: generated.accountCostEvents, recovered: Boolean(recovered), paidReplayAllowed: false } : {}),
1706
1786
  });
1707
1787
  }),
1708
1788
  );
package/lib/config.mjs CHANGED
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
2
2
  import path from "node:path";
3
3
 
4
4
  // 프록시 버전 — 서버가 X-AImakeAll-MCP-Version 으로 하한을 강제(426)할 수 있다.
5
- export const MCP_PROXY_VERSION = "0.14.4";
5
+ export const MCP_PROXY_VERSION = "0.14.5";
6
6
 
7
7
  export const DEFAULT_API_BASE = "https://aimakeall.com";
8
8
  export const DEFAULT_COMPANION_URL = "http://127.0.0.1:9876";
@@ -1,11 +1,16 @@
1
1
  // Closed browser/worker channel-analysis contract. Never accepts a provider,
2
2
  // credential, arbitrary prompt, callback or model from the requester.
3
+ import { VOICE_INPUT_FIELDS, validateVoiceRecommendationInput, validateVoiceRecommendationOutput } from './durable-voice-contract.mjs';
3
4
  export const DURABLE_CHANNEL_CONTRACT_VERSION = 1;
4
- export const DURABLE_CHANNEL_PATHS = Object.freeze({ 'channel-style': '/api/tracker/gemini/channel-style', 'channel-fingerprint': '/api/tracker/gemini/channel-fingerprint' });
5
+ export const DURABLE_CHANNEL_PATHS = Object.freeze({ 'channel-style': '/api/tracker/gemini/channel-style', 'channel-fingerprint': '/api/tracker/gemini/channel-fingerprint', 'voice-recommend': '/api/tracker/tts/recommend-voice' });
5
6
  export const DURABLE_CHANNEL_TYPES = Object.freeze(Object.fromEntries(Object.keys(DURABLE_CHANNEL_PATHS).map(key => [key, `durable-channel:${key}`])));
6
- export const DURABLE_CHANNEL_MODEL = Object.freeze({ 'channel-style': 'gemini-2.5-flash-lite', 'channel-fingerprint': 'gemini-3-flash' });
7
+ export const DURABLE_CHANNEL_MODEL = Object.freeze({ 'channel-style': 'gemini-2.5-flash-lite', 'channel-fingerprint': 'gemini-3-flash', 'voice-recommend': 'gemini-3-flash' });
8
+ // Old channel clients compare this map exactly. Adding casting must not break
9
+ // an already-installed client's channel capability handshake.
10
+ export const durableChannelCapabilityModels = operation => operation === 'voice-recommend' ? DURABLE_CHANNEL_MODEL
11
+ : Object.freeze(Object.fromEntries(Object.entries(DURABLE_CHANNEL_MODEL).filter(([key]) => key !== 'voice-recommend')));
7
12
  export const DURABLE_CHANNEL_LIMITS = Object.freeze({ maxInputBytes: 16 * 1024 * 1024, maxOutputBytes: 1024 * 1024, maxProjectId: 160, maxTargetId: 240, maxStages: 24, maxVideos: 12, maxFrames: 8 });
8
- export const DURABLE_CHANNEL_OUTPUT_FIELDS = Object.freeze(['operation', 'projectId', 'targetId', 'profile', 'analysis', 'fingerprint']);
13
+ export const DURABLE_CHANNEL_OUTPUT_FIELDS = Object.freeze(['operation', 'projectId', 'targetId', 'profile', 'analysis', 'fingerprint', 'voiceRecommendation']);
9
14
  const encoder = new TextEncoder();
10
15
  function fail(output = false) { const code = `DURABLE_CHANNEL_${output ? 'OUTPUT' : 'INPUT'}_INVALID`; throw Object.assign(new Error(code), { code, statusCode: output ? 502 : 400 }); }
11
16
  function record(value) { if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
@@ -30,8 +35,9 @@ function analysis(value) { if (!value || typeof value !== 'object' || Array.isAr
30
35
  for (const key of ['topKeywords', 'highlights', 'videos']) if (value[key] !== undefined && !Array.isArray(value[key])) fail();
31
36
  if (value.topKeywords?.some(v => typeof v !== 'string') || value.highlights?.some(v => !v || typeof v !== 'object') || value.videos?.some(v => !v || typeof v !== 'object')) fail(); }
32
37
  export function captureDurableChannelInput(value) { const raw = record(value); identity(raw);
33
- const body = capture(raw, ['operation', 'projectId', 'targetId', ...(raw.operation === 'channel-style' ? ['analysis'] : ['videos', 'measuredAt'])], DURABLE_CHANNEL_LIMITS.maxInputBytes);
34
- if (body.operation === 'channel-style') { analysis(body.analysis); if (encoder.encode(JSON.stringify(body.analysis)).byteLength > 750000) fail(); }
38
+ const body = capture(raw, ['operation', 'projectId', 'targetId', ...(raw.operation === 'voice-recommend' ? VOICE_INPUT_FIELDS : raw.operation === 'channel-style' ? ['analysis'] : ['videos', 'measuredAt'])], DURABLE_CHANNEL_LIMITS.maxInputBytes);
39
+ if (body.operation === 'voice-recommend') { try { validateVoiceRecommendationInput(body); } catch { fail(); } }
40
+ else if (body.operation === 'channel-style') { analysis(body.analysis); if (encoder.encode(JSON.stringify(body.analysis)).byteLength > 750000) fail(); }
35
41
  else { if (!Array.isArray(body.videos) || !body.videos.length || body.videos.length > 12 || typeof body.measuredAt !== 'string' || !Number.isFinite(Date.parse(body.measuredAt)) || new Date(body.measuredAt).toISOString() !== body.measuredAt) fail();
36
42
  for (const v of body.videos) { if (!v || Array.isArray(v) || Object.keys(v).some(k => !VIDEO_FIELDS.includes(k))) fail();
37
43
  for (const k of NUMBERS) if (v[k] !== undefined && v[k] !== null && (typeof v[k] !== 'number' || Math.abs(v[k]) > 1e9)) fail();
@@ -43,7 +49,11 @@ export function captureDurableChannelInput(value) { const raw = record(value); i
43
49
  }
44
50
  } return Object.freeze({ body, jobType: DURABLE_CHANNEL_TYPES[body.operation] }); }
45
51
  export function captureDurableChannelOutput(value) { try { const raw = capture(value, DURABLE_CHANNEL_OUTPUT_FIELDS, DURABLE_CHANNEL_LIMITS.maxOutputBytes); identity(raw);
46
- if (raw.operation === 'channel-style') { analysis(raw.analysis); if (!raw.profile || Array.isArray(raw.profile) || raw.fingerprint !== undefined) fail(true);
52
+ if (raw.operation === 'voice-recommend') {
53
+ if (Object.keys(raw).some(k => !['operation', 'projectId', 'targetId', 'voiceRecommendation'].includes(k))) fail(true);
54
+ validateVoiceRecommendationOutput(raw.voiceRecommendation);
55
+ } else if (raw.voiceRecommendation !== undefined) fail(true);
56
+ else if (raw.operation === 'channel-style') { analysis(raw.analysis); if (!raw.profile || Array.isArray(raw.profile) || raw.fingerprint !== undefined) fail(true);
47
57
  const strings = ['channelTitle', 'introPattern', 'outroPattern', 'pacing', 'recommendedFormat', 'recommendedStyleId', 'structurePattern', 'summary', 'toneGuide', 'toneLabel'];
48
58
  if (Object.keys(raw.profile).length !== strings.length + 2 || strings.some(k => typeof raw.profile[k] !== 'string') || !raw.profile.summary.trim()) fail(true);
49
59
  for (const [k, max] of [['referenceKeywords', 6], ['scriptDirectives', 5]]) if (!Array.isArray(raw.profile[k]) || raw.profile[k].length > max || raw.profile[k].some(v => typeof v !== 'string')) fail(true);