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 +117 -1
- package/lib/cloud-tools.mjs +131 -51
- package/lib/config.mjs +1 -1
- package/lib/durable-channel-contract.mjs +16 -6
- package/lib/durable-channel-journal.mjs +10 -5
- package/lib/durable-channel-request.mjs +4 -4
- package/lib/durable-remake-contract.mjs +88 -0
- package/lib/durable-remake-request.mjs +375 -0
- package/lib/durable-speech.mjs +2 -2
- package/lib/durable-voice-contract.mjs +62 -0
- package/lib/narration-timeline.mjs +189 -0
- package/lib/review-plan-client.mjs +73 -0
- package/lib/review-production-contract.mjs +198 -0
- package/lib/review-tools.mjs +40 -11
- package/lib/speech-production-journal.mjs +109 -0
- package/lib/speech-production.mjs +161 -0
- package/lib/studio-plan-client.mjs +64 -0
- package/lib/studio-plan-contract.mjs +128 -0
- package/lib/studio-plan-request.mjs +372 -0
- package/lib/voice-catalog.mjs +33 -0
- package/lib/voice-recommendation-client.mjs +40 -0
- package/package.json +1 -1
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
|
-
기존
|
|
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부터 지원합니다. 구형 클라이언트는 위의 자동 업데이트 전환 안내에 따라 한 번 전환하고 유휴 상태에서 다시 연결해야 새 입력과 도구 안내를 사용할 수 있습니다. 이미지 모션 렌더에는 해당 기능을 지원하는 최신 컴패니언 런타임도 필요합니다.
|
package/lib/cloud-tools.mjs
CHANGED
|
@@ -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
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
1324
|
-
|
|
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 오디오와
|
|
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
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
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(
|
|
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.
|
|
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 === '
|
|
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 === '
|
|
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);
|