@starreel/mcp 0.1.136 → 0.1.138

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/SKILL.md CHANGED
@@ -85,11 +85,13 @@ announces a condensed version as MCP `instructions` at connect time.
85
85
  | Their own portraits / scene / prop / shot images | `upload_image` · `set_character_portrait` · `upload_scene_image` · `upload_prop_sheet` · `upload_shot_frame` — **check the image before uploading** (see *Customer-supplied images* below); after a portrait swap, `generate_character_sheet` for that character (the old sheet is invalidated and **not** regenerated automatically) | rendering a "fix" elsewhere and uploading it — use `generate_shot_frame`; uploading a group photo, a captioned/watermarked image, or a multi-view sheet as a portrait; uploading a scene photo with people in it |
86
86
  | Their own **footage** for a shot (screen recording, product b-roll, an existing clip) | `upload_shot_footage` → that shot is no longer AI-generated (frames/video skipped, final cut uses the clip as-is, duration written back from the clip); **it overwrites whatever AI video that shot already had**, and afterwards every AI video call on it (including `edit_video_shot`) is refused with **409** until you `clear_shot_footage` or pass `replace_user_footage: true` | pasting an externally AI-generated clip to "fix" a shot — it carries none of this film's identity / style anchors; use `regenerate_shot_video` instead |
87
87
  | A clip that should **drive the motion** of an AI shot (a blocking/previz pass, a dance or action reference) | `edit_video_shot` with `reference_video_urls` — the shot keeps its AI video and borrows the clip's movement. To make the clip itself the source and restyle it in place, `upload_shot_footage` it first, then `edit_video_shot` with `replace_user_footage: true` | `upload_shot_footage` alone — that registers the clip as the finished shot, so nothing gets restyled and every later AI call on it returns 409 |
88
+ | A clip whose **motion should be kept while the people / outfits are redrawn** (video repaint) — their own footage or a shot already generated here | `get_repaint_capabilities` (is it enabled for this drama; `relayMaxSegments`) → for their own clip, `upload_shot_footage` it onto a shot first → `quote_repaint_shot_video` → `repaint_shot_video` (prompt + reference image; with no `reference_image_url` the shot's character portrait is used — without any reference the person is redrawn too) **or** `quote_reskin_shot_video` → `reskin_shot_video` (`first_frame_url` = an already-reskinned first frame that matches the original first frame's composition, pose and props exactly) → `get_repaint_status` with the returned id until `completed` → `restore_repaint_original` to switch back (free). Motion, lip timing and camera move follow the source and its audio is carried over. ≤5 s is one charge (billed as 5 s); with long-shot relay enabled, 5–~14 s is split into ≤3 relayed segments, one charge each, and the quote is the total and the charge cap. A quote is tied to the shot's current video — if that video changes, generating returns 409; re-quote (free) | `edit_video_shot` to swap the whole cast or wardrobe (it edits by instruction); clips longer than ~14 s (`split_shot` first) |
88
89
  | A scene plate that came out wrong (backdrop, era, light, layout) | `get_scene_prompt` → `update_scene` (`image_prompt`) → `quote_regenerate_scene_image` (tell the customer the points — it includes the lighting reference the platform auto-derives after a plate change when no wide-shot frame exists) → `regenerate_scene_image` with that `quote_id` (after `upload_scene_image` the platform does **not** auto-create the lighting reference — if the reply says `lighting_ref.needed`, quote it with `quote_scene_lighting_ref` and, once the customer agrees, `generate_scene_lighting_ref`); already-rendered shot frames still anchor on the old plate, so regenerate those shots too | re-running `generate_scene_images` (it only fills scenes that have **no** plate — it will not touch this one) |
89
90
  | A voice sample / a required voice | `clone_voice` → `speak_with_voice` → `set_character_voice` / `assign_voices` | cloning without the rights-holder's consent |
90
91
  | No sample, but a character already speaks in a rendered shot | `set_voice_anchor_from_shot` (free; native-audio dramas: locks the voice for later videos) → regenerate `stale_shots` | picking the voice after the whole episode is rendered |
91
92
  | **No sample** — just an idea of the voice (a narrator, a character that deserves its own voice) | `design_voice` (one-sentence description, free) → `get_voice_design` until `done` (candidates arrive as local wav files — let the customer listen) → `save_designed_voice` (pick one; billed like a clone) → `set_character_voice` | generating every line straight from the description — each generation is a different person; naming a real person to imitate their voice |
92
93
  | A song + lyrics | `create_drama` (`project_type: "mv"`) → `set_mv_lyrics` → `generate_mv_story` → `generate_mv_script` | `rewrite_script` (blocked for MV) |
94
+ | Lyrics only, no song (MV) | `create_drama` (`project_type: "mv"`) → `set_mv_lyrics` → `suggest_mv_song_styles` → `quote_mv_song` → `generate_mv_song` → `get_mv_song` → `select_mv_song` → `generate_mv_story` → `generate_mv_script` | Confirming lyric rights on the user's behalf (`lyrics_rights_confirmed` must come from the user); picking a candidate by QC text alone without letting the user listen |
93
95
  | A product / brand | `create_drama` (`project_type: "ad"` or `"brand_film"`) → `add_product` → `generate_product_sheet` | writing brand copy as dialogue (it gets spoken) |
94
96
  | Generated shots / a cut that needs changes | `scan_dialogue_coverage` / `scan_intra_shot_cuts` first, then `get_shot_prompts` · `update_shot` · `replace_shot_dialogue` · `repair_episode_dialogue` · `split_shot` · `trim_shot` · `regenerate_shot_video` · `edit_video_shot` → `rerender_episode`. To put **specific shots on a different video engine** in a whole-episode run, pass `shot_engines` (`{storyboard_id: engine}`) to `quote_videos` (a scene group that contains a picked shot is **split into per-shot generation** — listed in `shot_engines_ungrouped`; its other shots stay on the drama engine but lose the group's one-take continuity, so don't break a good group for one shot; a shot that already has a video is not re-run by the episode run, so picking an engine for it is rejected). Re-quoting after a partial run prices only the shots that will actually be dispatched (`shots_total`; already-finished ones are counted in `shots_already_done`, except that a scene group with any unfinished shot is re-run whole). To make a shot **always** use a different engine (single, batch and regen alike), set `video_engine` with `update_shot` (stored on the shot, shown in the web workbench; such a shot is never put in a scene group; explicit per-call parameters still win). To re-run **one scene group** (one request, keeps in-group continuity) — optionally on a different engine for that run — use `quote_regenerate_scene_group` (`shot_numbers` = exactly one group from `get_scene_group_plan`, optional `video_engine`; the whole group must fit that engine's single-clip limit) → `regenerate_scene_group`. To retry **one shot on a different video engine**, pass `model` to `quote_regenerate_shot_video` (that shot, that regeneration only — the drama's `video_engine` is untouched; the quote pins the engine, so changing it means re-quoting) | re-composing to fix what a clip *says*; switching the whole drama's `video_engine` just to fix one shot |
95
97
  | Wants old portraits / sheets / a shot's old tail frame "deleted" | `set_character_portrait` (old assets listed in `old_assets`) · `clear_shot_frame` (old tail frame) → `regenerate_shot_video`; `list_unlinked_assets` lists the drama's unlinked old assets (a history list, not proof of ownership). Both only **unlink** — files are not deleted (`physically_deleted` is always empty; `in_history: false` means the platform kept no record, so save that URL if it matters). Tell the customer exactly that | telling the customer the old image was deleted; writing `last_frame_image: null` through the internal API |
@@ -1190,7 +1192,7 @@ to close") tells the vendor to fit that entire sequence into each 3-second shot.
1190
1192
  and all subtitle/dialogue/SFX offsets are relative to **each shot's own trimmed
1191
1193
  start**, not to the final timeline — add whatever overlapping transitions you like
1192
1194
  and let `compile_timeline.py` expand them, never hand-compute the shift.
1193
- - **Edit**: `edit_video_shot`, `regenerate_shot_video`, `split_shot`,
1195
+ - **Edit**: `edit_video_shot`, `repaint_shot_video` / `reskin_shot_video` (keep the motion, redraw people / outfits), `regenerate_shot_video`, `split_shot`,
1194
1196
  `trim_shot`, `rerender_episode`. One generation per shot at a time: a **409 with
1195
1197
  `in_flight_generation_id`** means that shot is still generating from *different*
1196
1198
  input (another prompt, an edit, a prompt or shot you changed since, a first/last frame
@@ -124,6 +124,21 @@ export const ENTRY_POINTS = [
124
124
  ],
125
125
  note: 'MV 不走标准 `rewrite_script`(会被拦),之后回到 extract_assets → 分镜 → 出图的标准链。',
126
126
  },
127
+ {
128
+ customer_has: '只有歌词、没有歌曲(想做 MV)',
129
+ use: [
130
+ 'create_drama(project_type=mv)',
131
+ 'set_mv_lyrics',
132
+ 'suggest_mv_song_styles',
133
+ 'quote_mv_song',
134
+ 'generate_mv_song',
135
+ 'get_mv_song',
136
+ 'select_mv_song',
137
+ ],
138
+ note: 'AI 作曲:用歌词生成带人声的整曲(每首按首计费,末尾带约 1 秒 AI 生成标识音)。' +
139
+ '生成前必须问用户是否拥有歌词的使用权利,不能替用户确认;质检只是提示,让用户试听后再选。灰度中,未开放的剧返回 403。',
140
+ flow: 'set_mv_lyrics → suggest_mv_song_styles → quote_mv_song(用户同意) → generate_mv_song → get_mv_song(试听) → select_mv_song → generate_mv_story → generate_mv_script',
141
+ },
127
142
  {
128
143
  customer_has: '产品 / 品牌(广告、品牌微电影)',
129
144
  use: [
@@ -149,11 +164,25 @@ export const ENTRY_POINTS = [
149
164
  'recommend_trim_window',
150
165
  'regenerate_shot_video',
151
166
  'edit_video_shot(就地编辑;get_edit_capabilities 先看当前引擎支持什么)',
167
+ 'repaint_shot_video / reskin_shot_video(保留原片动作、整体换人换装;get_repaint_capabilities 先看本剧开没开)',
152
168
  'rerender_episode(改完后免费重拼)',
153
169
  ],
154
170
  note: '改了台词而视频已存在 → 视频仍念旧词,必须 `regenerate_shot_video`,重拼救不了。',
155
171
  flow: 'scan_dialogue_coverage / scan_intra_shot_cuts 先定病因 → update_shot / replace_shot_dialogue / repair_episode_dialogue / split_shot / trim_shot / regenerate_shot_video / edit_video_shot → rerender_episode',
156
172
  },
173
+ {
174
+ customer_has: '一段现成视频(自有素材或已出的镜头),想保留动作、把人物 / 服装整体换掉(视频转绘)',
175
+ use: [
176
+ 'get_repaint_capabilities(看本剧开没开、长镜头接力最多几段)',
177
+ 'upload_shot_footage(自有视频先登记成某镜实拍素材;已是本剧镜头则跳过)',
178
+ 'quote_repaint_shot_video → repaint_shot_video(按描述 + 参考图重画)',
179
+ 'quote_reskin_shot_video → reskin_shot_video(给一张换好装、与原片首帧构图对齐的首帧)',
180
+ 'get_repaint_status(按返回 id 查到完成)',
181
+ 'restore_repaint_original(不满意切回原片,免费)',
182
+ ],
183
+ avoid: '别拿 edit_video_shot 做整体换人换装(那是按指令局部改);超过约 14 秒先 split_shot。',
184
+ flow: 'get_repaint_capabilities → upload_shot_footage(自有视频) → quote_repaint_shot_video / quote_reskin_shot_video → repaint_shot_video / reskin_shot_video → get_repaint_status',
185
+ },
157
186
  {
158
187
  customer_has: '想自己剪:要逐镜素材包(裸片 / 对白轨 / 音效 / 配乐 / 字幕)',
159
188
  use: ['export_handoff_pack', 'get_handoff_toolchain', 'save_handoff_toolchain'],
@@ -234,7 +263,7 @@ export const PIPELINE = [
234
263
  },
235
264
  {
236
265
  step: '8 出视频',
237
- tools: ['quote_videos', 'generate_videos', 'get_scene_group_plan', 'generate_scene_groups', 'quote_regenerate_shot_video', 'regenerate_shot_video', 'quote_edit_video_shot', 'edit_video_shot', 'upload_shot_footage(实拍素材镜:客户录屏/产品实拍直接当该镜视频,不出视频不扣费)', 'get_edit_capabilities'],
266
+ tools: ['quote_videos', 'generate_videos', 'get_scene_group_plan', 'generate_scene_groups', 'quote_regenerate_shot_video', 'regenerate_shot_video', 'quote_edit_video_shot', 'edit_video_shot', 'upload_shot_footage(实拍素材镜:客户录屏/产品实拍直接当该镜视频,不出视频不扣费)', 'get_edit_capabilities', 'get_repaint_capabilities', 'quote_repaint_shot_video', 'repaint_shot_video', 'quote_reskin_shot_video', 'reskin_shot_video', 'get_repaint_status', 'restore_repaint_original'],
238
267
  billing: '报价确认后扣点',
239
268
  note: 'video_engine 必须在出视频前定(seedance-2.5 默认 / hailuo-3 降本 / wan3.0 风格化·绝不用于写实真人);切换不回溯已生成镜头。',
240
269
  },
@@ -140,7 +140,7 @@ const WORKFLOW_HINT = '★三档执行策略(别把三档混着问客户):' +
140
140
  '进度自检看 get_pipeline_status 的 generate_character_sheets 步(completed/total,portrait_only=只有定妆图的角色数)。' +
141
141
  '★客户自带图:定妆图须单人、单张单角度、无文字;场景图须空景无人、无叠加文字——set_character_portrait/upload_scene_image 会自动检查、不合规 422 拒收。' +
142
142
  '★别先建角色形象/道具设定图/动作模板再分镜——分镜是纯文本步、不依赖任何图;资产在分镜后建更省更准(动作模板本就必须分镜后)。收费步照现有 quote 报价确认流程。' +
143
- '广告另需 add_product+generate_product_sheet;MV 走 set_mv_lyrics→generate_mv_story→generate_mv_script。' +
143
+ '广告另需 add_product+generate_product_sheet;MV 走 set_mv_lyrics→generate_mv_story→generate_mv_script(没有现成歌曲:set_mv_lyrics 后先 suggest_mv_song_styles→quote_mv_song→generate_mv_song→get_mv_song→select_mv_song)。' +
144
144
  '★★【原声剧先锁声线,再批量出视频】use_clip_audio=true(默认)时每镜嗓音由视频厂商各自采样,不锁必漂,事后统一要逐镜重出。' +
145
145
  '有客户授权音源→clone_voice+set_character_voice,在 generate_videos 之前;没有→每个说话角色先出一镜台词较长的,' +
146
146
  '用 set_voice_anchor_from_shot 从那一镜选定声线,再出其余镜。get_pipeline_status 的 native_voice_anchor 会列出还没锁的' +
@@ -1256,6 +1256,63 @@ export function registerProduceTools(server, client) {
1256
1256
  replace_user_footage: z.boolean().optional().describe('本镜是实拍素材镜(upload_shot_footage 传过)时,显式允许以它为源做编辑并用 AI 产物覆盖它。' +
1257
1257
  '不传则被守卫拒(409,带中文出路)。想保留素材就别传,改用 clear_shot_footage 换回 AI 生成。'),
1258
1258
  }, async ({ storyboard_id, ...rest }) => jsonResult(await client.producePost(`/storyboards/${storyboard_id}/edit/generate`, rest)));
1259
+ // ========== B2 · VACE 视频重绘 / 换装重绘(保留原片动作,重画人物与服装) ==========
1260
+ const VACE_COMMON = '\n★与 edit_video_shot 的分工:edit 是按指令改画面(换背景/调色/局部改动);重绘/换装重绘是**以本镜现有视频为动作骨架,把人物与服装整体重画**,' +
1261
+ '动作、口型时机、镜头运动跟原片走,音轨贴回原片。' +
1262
+ '\n★**客户自己的视频也能转绘**:先 upload_shot_footage 把它登记成某镜的实拍素材(会抽出首尾帧、回写时长),再对该镜报价/发起即可;' +
1263
+ '转绘结果写回后该镜不再是实拍素材镜。' +
1264
+ '\n★时长:≤5 秒一单(按 5 秒计费);本剧开了长镜头接力(get_repaint_capabilities 的 relayMaxSegments>0)时 5~约14 秒自动切 ≤3 段接力,' +
1265
+ '每段一单、报价是合计;再长先 split_shot。' +
1266
+ '\n★异步:发起返回一条生成记录(接力时是段 1),用 get_repaint_status(storyboard_id, generation_id) 查到 completed/failed;' +
1267
+ '接力进行中会带 relay.segments_done/segments。完成后本镜视频即换成转绘结果;不满意用 restore_repaint_original 切回原片(免费)。';
1268
+ server.tool('get_repaint_capabilities', '查本剧能用哪些视频转绘:repaint(视频重绘)/ reskin(换装重绘)是否开放、单段源片上限 maxSourceSec、' +
1269
+ '长镜头接力最多段数 relayMaxSegments(0=没开,>5 秒的镜不能转绘)。免费。', { drama_id: z.number().int().positive() }, async ({ drama_id }) => jsonResult(await client.produceGet(`/dramas/${drama_id}/vace-capabilities`)));
1270
+ server.tool('quote_repaint_shot_video', '报价:对某镜做**视频重绘**(按描述 + 参考图重画人物与服装,保留原片动作)要多少点。返回 quote_id。零扣费。本镜须已有视频。' +
1271
+ '报价只认本镜当前这段视频——之后视频被换(重生/上传/编辑完成),发起会 409,重新报价即可。' + VACE_COMMON, { storyboard_id: z.number().int().positive() }, async ({ storyboard_id }) => jsonResult(await client.producePost(`/storyboards/${storyboard_id}/repaint/quote`)));
1272
+ server.tool('repaint_shot_video', '确认后对某镜做视频重绘。扣费上限 = 报价的 estimated_points(接力时为整条合计)。' +
1273
+ '\n★参考图:不传 reference_image_url 时自动取本镜角色的(换装)定妆图——**不带参考图人也会被换掉**;' +
1274
+ 'auto_reference=false 表示明确不要参考图(只按描述画)。' +
1275
+ '\n★keep_scene 缺省 true:把本镜场景/道具/动作补进描述,只换人与衣服;传 false = 连场景一起重画。' +
1276
+ '\n★control:posebodyface(缺省,保口型,说话镜用它)/ posebody(不带脸,嘴不动)/ depth(按景深,纯色背景易出竖条纹)/ scribble(线稿)。' +
1277
+ 'strength 只收 0 或 1。' +
1278
+ '\n★本镜绑了产品文字(需保留商品原文)时会被拒(重绘会重画画面里的字)。' + VACE_COMMON + CONFIRM_HINT, {
1279
+ storyboard_id: z.number().int().positive(),
1280
+ quote_id: z.string().describe('来自 quote_repaint_shot_video'),
1281
+ prompt: z.string().describe('要画成什么样(人物外貌、服装、风格)。正向描述,别写「不要 X」'),
1282
+ control: z.enum(['posebodyface', 'posebody', 'depth', 'scribble']).optional().describe('动作控制方式,缺省 posebodyface'),
1283
+ strength: z.union([z.literal(0), z.literal(1)]).optional().describe('0 或 1,缺省 1'),
1284
+ reference_image_url: z.string().optional().describe('主体参考图 URL(先 upload_image 拿);缺省自动取本镜角色定妆图'),
1285
+ auto_reference: z.boolean().optional().describe('false = 明确不带参考图(人会按描述重画)'),
1286
+ keep_scene: z.boolean().optional().describe('缺省 true:保留原片场景与道具,只换人和衣服'),
1287
+ }, async ({ storyboard_id, ...rest }) => jsonResult(await client.producePost(`/storyboards/${storyboard_id}/repaint/generate`, rest)));
1288
+ server.tool('quote_reskin_shot_video', '报价:对某镜做**换装重绘**(给一张已换好装的首帧,按原片动作把整段视频续成这身打扮)要多少点。返回 quote_id。零扣费。' +
1289
+ '本镜须已有视频;该功能按剧开放,先看 get_repaint_capabilities 的 reskin。' + VACE_COMMON, { storyboard_id: z.number().int().positive() }, async ({ storyboard_id }) => jsonResult(await client.producePost(`/storyboards/${storyboard_id}/reskin/quote`)));
1290
+ server.tool('reskin_shot_video', '确认后对某镜做换装重绘。扣费上限 = 报价的 estimated_points。' +
1291
+ '\n★first_frame_url 是**换好装的首帧**:必须与原片首帧**构图、人物姿势、道具位置逐一对齐**(只换衣服),' +
1292
+ '否则整段会跑偏——实测失败都出在首帧。完成后它会成为本镜正式首帧。只收公网 https 图片(先 upload_image 拿)。' +
1293
+ '\n★prompt 可留空(系统看首帧自动写);写的话别用「制服/军装」这类服装类别词,模型会自行加肩章条纹,直接描述颜色款式。' +
1294
+ '\n★control:posebodyface(缺省,保口型)/ depth。本镜绑了产品文字时会被拒。' + VACE_COMMON + CONFIRM_HINT, {
1295
+ storyboard_id: z.number().int().positive(),
1296
+ quote_id: z.string().describe('来自 quote_reskin_shot_video'),
1297
+ first_frame_url: z.string().describe('换好装的首帧 https URL,须与原片首帧构图对齐'),
1298
+ prompt: z.string().optional().describe('可留空:系统看首帧自动写'),
1299
+ control: z.enum(['posebodyface', 'depth']).optional().describe('缺省 posebodyface'),
1300
+ }, async ({ storyboard_id, ...rest }) => jsonResult(await client.producePost(`/storyboards/${storyboard_id}/reskin/generate`, rest)));
1301
+ server.tool('get_repaint_status', '查一次视频重绘/换装重绘的进度。generation_id 用发起时返回的 id(接力时就是段 1 的 id,返回的是整条链的状态)。免费。' +
1302
+ '\n· status=processing:进行中;接力时带 relay.segments_done/segments。' +
1303
+ '\n· status=completed:本镜视频已换成转绘结果(video_url / local_path);接力完成时带 relay.final_generation_id——' +
1304
+ '**切回原片要用这个 final_generation_id**,不是段 1 的 id。' +
1305
+ '\n· status=completed 但 errorMsg 含「未写回」:任务期间本镜视频被换掉,结果存档在这条记录里、没覆盖本镜。' +
1306
+ '\n· status=failed:errorMsg 是中文原因(接力会说明第几段失败),镜头未改动,已扣的按规则退。', {
1307
+ storyboard_id: z.number().int().positive(),
1308
+ generation_id: z.number().int().positive().describe('repaint_shot_video / reskin_shot_video 返回的 id'),
1309
+ }, async ({ storyboard_id, generation_id }) => jsonResult(await client.produceGet(`/storyboards/${storyboard_id}/vace/${generation_id}`)));
1310
+ server.tool('restore_repaint_original', '把某镜从转绘结果切回转绘前的原片(换装重绘连首帧一起恢复)。免费。' +
1311
+ 'generation_id = 本镜当前视频对应的那次转绘(接力取 get_repaint_status 的 relay.final_generation_id)。' +
1312
+ '\n★409 = 之后本镜又出过别的视频,不覆盖(保护后来的结果);有视频任务在跑时也别切回,在跑那单会因源片不符被存档(白花钱)。', {
1313
+ storyboard_id: z.number().int().positive(),
1314
+ generation_id: z.number().int().positive(),
1315
+ }, async ({ storyboard_id, generation_id }) => jsonResult(await client.producePost(`/storyboards/${storyboard_id}/vace-restore`, { generation_id })));
1259
1316
  server.tool('quote_regenerate_shot_video', '报价:重生某镜视频要多少点。返回 quote_id。零扣费。' +
1260
1317
  '\n★**按镜指定视频引擎**:传 model 即只为这一镜这一次重生换引擎(不改剧级 video_engine,其它镜照旧);' +
1261
1318
  '报价按该引擎计价,quote_id 把引擎钉死,regenerate_shot_video 派发的就是它——要换引擎必须重新报价,生成时再传引擎无效。' +
@@ -1660,6 +1717,39 @@ export function registerProduceTools(server, client) {
1660
1717
  server.tool('generate_mv_story', 'MV 项目:据歌词 AI 编一个 MV 故事线。后台/同步,按用量后付。', { drama_id: z.number().int().positive() }, async ({ drama_id }) => jsonResult(await client.producePost(`/dramas/${drama_id}/mv/story`)));
1661
1718
  server.tool('generate_mv_script', 'MV 项目:据歌词+故事写首集可拍剧本(写 scriptContent)。之后走标准 extract_assets → 分镜 → …。按用量后付。', { drama_id: z.number().int().positive() }, async ({ drama_id }) => jsonResult(await client.producePost(`/dramas/${drama_id}/mv/script`)));
1662
1719
  server.tool('get_mv', 'MV 项目:读歌词/故事/剧本当前状态。免费。', { drama_id: z.number().int().positive() }, async ({ drama_id }) => jsonResult(await client.produceGet(`/dramas/${drama_id}/mv`)));
1720
+ // ---- MV AI 作曲:客户没有现成歌曲时,用本剧歌词生成带人声的整曲 ----
1721
+ server.tool('suggest_mv_song_styles', 'MV 项目·AI 作曲第 1 步:读本剧已存的歌词(先 set_mv_lyrics),给 3 套曲风方案(name/why/bpm/vocal/prompt)。' +
1722
+ '不出歌,只花一次少量文本 LLM 费。把方案讲给用户挑,用户可以改 prompt;挑定后 quote_mv_song 报价。' +
1723
+ '仅在客户**没有**现成歌曲时用;有歌就让客户上传整曲。灰度中:未开放的剧返回 403 feature_disabled。', {
1724
+ drama_id: z.number().int().positive(),
1725
+ hint: z.string().max(300).optional().describe('用户对曲风的偏好(可选),如「想要男女对唱」'),
1726
+ }, async ({ drama_id, hint }) => jsonResult(await client.producePost(`/dramas/${drama_id}/mv/song/styles`, hint ? { hint } : {})));
1727
+ server.tool('quote_mv_song', 'MV 项目·AI 作曲第 2 步:报价(免费)。总点数 = candidates × 单首价;返回 quote_id(15 分钟有效、一次性)。' +
1728
+ '⚠️ 把 points 原样告诉用户,用户明确同意后才拿 quote_id 调 generate_mv_song(candidates 必须一致),不要擅自确认。', {
1729
+ drama_id: z.number().int().positive(),
1730
+ candidates: z.number().int().min(1).max(3).describe('出几首候选对比(1~3),每首单独计费'),
1731
+ }, async ({ drama_id, candidates }) => jsonResult(await client.produceGet(`/dramas/${drama_id}/mv/song/quote?candidates=${candidates}`)));
1732
+ server.tool('generate_mv_song', 'MV 项目·AI 作曲第 3 步(扣费):用本剧歌词 + 选定的曲风描述生成 candidates 首带人声的整曲候选,后台跑约 1~2 分钟。' +
1733
+ '必须带 quote_mv_song 拿到的 quote_id。lyrics_rights_confirmed 必须是 true,且**只能在用户明确表示拥有这份歌词的使用权利后**才传 true' +
1734
+ '(拿别人已发表歌曲的歌词生成属于未经授权的翻唱),不要替用户确认。生成的歌末尾带约 1 秒的 AI 生成标识音。' +
1735
+ '完成后 get_mv_song 看候选与唱词质检,再 select_mv_song 选定。', {
1736
+ drama_id: z.number().int().positive(),
1737
+ style_prompt: z.string().min(1).max(2000).describe('曲风描述:suggest_mv_song_styles 返回的某套 prompt(可改)'),
1738
+ style_name: z.string().max(60).optional().describe('方案名,用于区分候选'),
1739
+ candidates: z.number().int().min(1).max(3),
1740
+ quote_id: z.string().describe('quote_mv_song 返回的 quote_id'),
1741
+ lyrics_rights_confirmed: z.boolean().describe('用户确认拥有歌词使用权利后才传 true'),
1742
+ }, async ({ drama_id, style_prompt, style_name, candidates, quote_id, lyrics_rights_confirmed }) => jsonResult(await client.producePost(`/dramas/${drama_id}/mv/song`, {
1743
+ style_prompt, candidates, quote_id, lyrics_rights_confirmed,
1744
+ ...(style_name ? { style_name } : {}),
1745
+ })));
1746
+ server.tool('get_mv_song', 'MV 项目·AI 作曲:查进度与本剧已生成的候选(免费)。candidates[].qc 是唱词质检摘要(字错率 / 疑似没唱的段落),' +
1747
+ '只是提示——转写对歌声有误差,请让用户试听 file_url 后再选;bound=true 是当前绑定的整曲;' +
1748
+ 'lyrics_match=false 表示这首是按修改前的歌词生成的,不能再选(select_mv_song 会拒),要用当前歌词重新生成。', { drama_id: z.number().int().positive() }, async ({ drama_id }) => jsonResult(await client.produceGet(`/dramas/${drama_id}/mv/song`)));
1749
+ server.tool('select_mv_song', 'MV 项目·AI 作曲第 4 步(免费):把某首候选设为本 MV 的整曲,并按本剧歌词自动对轴(之后 generate_mv_story / generate_mv_script 照常走)。', {
1750
+ drama_id: z.number().int().positive(),
1751
+ track_id: z.number().int().positive().describe('get_mv_song 返回的 candidates[].track_id'),
1752
+ }, async ({ drama_id, track_id }) => jsonResult(await client.producePost(`/dramas/${drama_id}/mv/song/select`, { track_id })));
1663
1753
  // ========== P1 · 资产精修(提取后改设定再出图)==========
1664
1754
  server.tool('update_character', '改角色设定(名字/外貌/年龄段/族裔/性别/性格/戏份/档案锁定)。改后需重出定妆图/相关图。免费。' +
1665
1755
  '★profile_locked=1:客户确认外观后锁定档案,后续 AI 提取不再覆盖外貌/性格/描述——外貌锚着定妆图与人脸锁定,被覆盖=全片换脸;确认满意即锁,要再改先传 0 解锁。', {