@remixmate/cli 0.9.58 → 0.9.60

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.58",
4
- "generatedAt": "2026-09-29T10:20:23.290Z",
3
+ "version": "0.9.60",
4
+ "generatedAt": "2026-09-30T02:10:05.308Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
@@ -82,7 +82,7 @@
82
82
  "tier": "orchestration",
83
83
  "category": "consuming",
84
84
  "title": "Jianying (CapCut) Draft Export",
85
- "summary": "Jianying (CapCut) draft export: package asset URLs into a draft ZIP that Jianying can import. Supports automatic conversion from a RenderPlan.",
85
+ "summary": "Jianying (CapCut) draft export: package asset URLs into a draft ZIP that Jianying can import. By default exports the latest prepared render job of the current conversation (no scenes needed).",
86
86
  "triggers": [
87
87
  "Export to Jianying, Jianying draft, package for Jianying, import into Jianying",
88
88
  "Export materials to the Jianying format, generate a Jianying project",
@@ -102,13 +102,17 @@
102
102
  "parameters": {
103
103
  "type": "object",
104
104
  "properties": {
105
+ "from_job_id": {
106
+ "type": "number",
107
+ "description": "Render job id to export (the `jobId` printed by prepare_video_assets). Omit it together with scenes to export the latest prepared job of the current conversation — preferred, because asset URLs copied from chat history go stale after re-renders"
108
+ },
105
109
  "from_render_plan": {
106
110
  "type": "string",
107
- "description": "Build the draft from a RenderPlan JSON (replaces --scenes)"
111
+ "description": "Build the draft from a local RenderPlan JSON file (local debugging only)"
108
112
  },
109
113
  "scenes": {
110
114
  "type": "string",
111
- "description": "Scene array as JSON (inline string or file path)"
115
+ "description": "Hand-written scene array as JSON — only for assets that never went through prepare_video_assets. Fields per scene: imageUrl | videoUrl (one required), duration (seconds, required), audioUrl, audioDuration, subtitleText, textLayers, width, height. Any other field (text, image, audio…) is rejected"
112
116
  },
113
117
  "title": {
114
118
  "type": "string",
@@ -128,15 +132,15 @@
128
132
  "mac",
129
133
  "windows"
130
134
  ],
131
- "description": "Draft-root preset"
135
+ "description": "OS of the computer that will open the draft"
132
136
  },
133
137
  "draft_root_path": {
134
138
  "type": "string",
135
- "description": "Explicit Jianying draft root path (overrides --system)"
139
+ "description": "Absolute Jianying draft root on the user's computer (Jianying → Settings → Draft location), e.g. /Users/<username>/Movies/JianyingPro/User Data/Projects/com.lveditor.draft or C:/Users/<username>/AppData/Local/JianyingPro/User Data/Projects/com.lveditor.draft. Required when the agent runs on a server; never guess the username, ask the user"
136
140
  },
137
141
  "no_download": {
138
142
  "type": "boolean",
139
- "description": "Do not download the ZIP; print the URL only"
143
+ "description": "Do not keep a local copy of the ZIP; print the download URL only"
140
144
  },
141
145
  "output": {
142
146
  "type": "string",
@@ -147,7 +151,8 @@
147
151
  },
148
152
  "ui": {
149
153
  "primary": [
150
- "system"
154
+ "system",
155
+ "draft_root_path"
151
156
  ],
152
157
  "advanced": [],
153
158
  "hidden": [
@@ -155,9 +160,9 @@
155
160
  "width",
156
161
  "height",
157
162
  "output",
163
+ "from_job_id",
158
164
  "from_render_plan",
159
165
  "scenes",
160
- "draft_root_path",
161
166
  "no_download"
162
167
  ]
163
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.58",
3
+ "version": "0.9.60",
4
4
  "description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -44,24 +44,37 @@ There is no dedicated env file; the **process environment** variables of the cal
44
44
 
45
45
  1. **Script path**: read the system-injected `Base directory for this skill: <path>` as `<SkillDir>`. Every command below uses `<SkillDir>/scripts/gen_jianying_draft.py`; never hard-code paths.
46
46
  2. **Pick the input form**:
47
- - **Form A (recommended)**: `--from-job-id` — load the RenderPlan from the database without passing a file path (safe for multi-user concurrent flows).
48
- - **Form B (fallback, single-user / local debugging)**: `--from-render-plan` — read a local `render-plan.json`.
49
- 3. **Draft root**: by default the `--system mac` preset resolves the target Jianying draft directory; switch with `--system windows`, or override with `--draft-root-path`. This path is sent to the API as `draftRootPath` and embedded in the draft files.
50
- 4. **By default download the ZIP** locally (`--output` sets the path); pass `--no-download` if you only need the download URL (this flag is ignored when scenes contain `textLayers` / `subtitleText`).
47
+ - **Default (recommended)**: pass **no** `--scenes` / `--from-job-id`. When `CONVERSATION_ID` is set (always true inside the agent), the script exports the latest render job of this conversation that has prepared assets — i.e. whatever the user last re-rendered, including voices or images they changed in the editor.
48
+ - **`--from-job-id <jobId>`**: export a specific earlier job.
49
+ - **`--scenes`**: only for assets that never went through `prepare_video_assets`. **Never** rebuild scenes from URLs in the chat history: earlier tables go stale after every re-render, and you cannot see previous tool results.
50
+ - `--from-render-plan`: local debugging only.
51
+ 3. **Draft root**: pass the user's real Jianying draft directory via `--draft-root-path` (an absolute path on **the user's computer**). It is sent to the API as `draftRootPath` and embedded in the draft files, so a wrong path makes Jianying lose the media. If the user already gave a path (e.g. a `草稿目录` / `Draft folder` line in their message), use it verbatim. Otherwise ask for it — never guess the username. The path is only optional when this script runs on the user's own computer of the `--system` OS; anywhere else (e.g. a server-side agent) the script exits with an error asking for it.
52
+ 4. **Download URL**: pass `--no-download` in the agent (no local copy is kept). When subtitles / textLayers need styling the script still fetches the ZIP to a temp file, styles it, uploads it back and prints the URL of the **styled** ZIP. The printed `download URL:` is a signed link that expires after 7 days — give it to the user exactly as printed, including the whole `?Expires=…&Signature=…` query; never strip or re-encode it.
51
53
  5. **Async job**: draft generation is async (assets must be downloaded and packaged); the script polls until completion.
54
+ 6. **Report what is actually in the draft**: repeat the `📦 Draft contents` block (scene count, narration audio N/N, subtitles N/N, draft root). Don't claim subtitles or a particular voice version the block doesn't show.
52
55
 
53
- ### Load the RenderPlan from the database (recommended, multi-user safe)
56
+ ### Export the latest prepared job of this conversation (default)
54
57
 
55
- When `prepare_video_assets` was invoked with `--save-job` (default) and persisted the RenderPlan, load it directly via `--from-job-id` — no file path required:
58
+ ```bash
59
+ python3 <SkillDir>/scripts/gen_jianying_draft.py \
60
+ --system mac \
61
+ --draft-root-path "/Users/<username>/Movies/JianyingPro/User Data/Projects/com.lveditor.draft" \
62
+ --no-download
63
+ ```
64
+
65
+ - Requires `PRIV_TOKEN` and `CONVERSATION_ID` (both injected by the agent). The title defaults to the plan's title.
66
+ - Fails with a clear message if the conversation has no prepared job yet — run `prepare_video_assets` first.
67
+
68
+ ### Load a specific job from the database
56
69
 
57
70
  ```bash
58
71
  python3 <SkillDir>/scripts/gen_jianying_draft.py \
59
72
  --from-job-id <jobId> \
60
- --title "My Video"
73
+ --system mac \
74
+ --draft-root-path "/Users/<username>/Movies/JianyingPro/User Data/Projects/com.lveditor.draft"
61
75
  ```
62
76
 
63
77
  - `<jobId>` is the task id printed by `prepare_video_assets` (the line `📦 render job jobId: N`).
64
- - Requires the `PRIV_TOKEN` and `MM_BACKEND_API_URL` env vars.
65
78
 
66
79
  ### Read from a RenderPlan file (fallback, single-user / local debugging)
67
80
 
@@ -145,23 +158,26 @@ python3 <SkillDir>/scripts/gen_jianying_draft.py \
145
158
 
146
159
  ### Specify the Jianying draft root
147
160
 
148
- Default is `--system mac`. Switch to the Windows preset:
161
+ macOS:
149
162
 
150
163
  ```bash
151
164
  python3 <SkillDir>/scripts/gen_jianying_draft.py \
152
- --title "Win draft" --system windows \
165
+ --title "Mac draft" --system mac \
166
+ --draft-root-path "/Users/<username>/Movies/JianyingPro/User Data/Projects/com.lveditor.draft" \
153
167
  --scenes '[{"videoUrl":"https://example.com/v.mp4","duration":5}]'
154
168
  ```
155
169
 
156
- Explicitly specify a path (overrides `--system`):
170
+ Windows:
157
171
 
158
172
  ```bash
159
173
  python3 <SkillDir>/scripts/gen_jianying_draft.py \
160
- --title "Custom path" \
161
- --draft-root-path "/Users/yourname/Movies/JianyingPro/User Data/Projects/com.lveditor.draft" \
174
+ --title "Win draft" --system windows \
175
+ --draft-root-path "C:/Users/<username>/AppData/Local/JianyingPro/User Data/Projects/com.lveditor.draft" \
162
176
  --scenes '[{"videoUrl":"https://example.com/v.mp4","duration":5}]'
163
177
  ```
164
178
 
179
+ `<username>` is a placeholder for the user's real value — the script rejects paths that still contain `<...>`, start with `~`, contain `%VAR%` / `$VAR`, or are not absolute. Users who moved their draft folder can copy the exact path from Jianying → Settings → Draft location.
180
+
165
181
  ### Only print the download URL (no file write)
166
182
 
167
183
  ```bash
@@ -170,7 +186,7 @@ python3 <SkillDir>/scripts/gen_jianying_draft.py \
170
186
  --scenes '[{"videoUrl":"https://example.com/v.mp4","duration":5}]'
171
187
  ```
172
188
 
173
- 6. **Show the result**: by default the script prints the download URL and saves the ZIP to `--output`. At the end it prints "extract into the draft root → restart Jianying" to import. With `--no-download`, only the download URL is printed.
189
+ **Show the result**: the script prints `📦 Draft contents`, the signed `download URL:` and the import steps. Without `--no-download` it also keeps the ZIP at `--output`.
174
190
 
175
191
  See every option: `python3 <SkillDir>/scripts/gen_jianying_draft.py --help`.
176
192
 
@@ -178,31 +194,31 @@ See every option: `python3 <SkillDir>/scripts/gen_jianying_draft.py --help`.
178
194
 
179
195
  | Flag | Description | Default |
180
196
  |------|-------------|---------|
181
- | `--from-render-plan` | Convert from a RenderPlan JSON automatically (replaces `--scenes`). | — |
182
- | `--from-job-id` | Load the RenderPlan from the database (pass a jobId, replaces `--from-render-plan`). | — |
183
- | `--scenes` | Scenes JSON (inline string or file path). | required (unless using `--from-render-plan` or `--from-job-id`) |
184
- | `--title` | Draft title. | required (optional when using `--from-render-plan`) |
197
+ | `--from-job-id` | Load the RenderPlan of a specific job from the database. | latest prepared job of `CONVERSATION_ID` when no source is given |
198
+ | `--from-render-plan` | Convert a local RenderPlan JSON (local debugging). | — |
199
+ | `--scenes` | Hand-written scenes JSON (inline string or file path); unknown fields are rejected. | — |
200
+ | `--title` | Draft title. | plan title (required with `--scenes`) |
185
201
  | `--width` | Canvas width in pixels. | `1080` |
186
202
  | `--height` | Canvas height in pixels. | `1920` |
187
203
  | `--draft-name` | Jianying draft name. | same as title |
188
- | `--system` | Draft-root preset: `mac` / `windows`. | `mac` |
189
- | `--draft-root-path` | Explicit Jianying draft root path (overrides `--system`). | resolved from `--system` |
190
- | `--no-download` | Skip the ZIP download; print only the URL (ignored when textLayers / subtitleText is present). | off |
204
+ | `--system` | OS of the computer that will open the draft: `mac` / `windows`. | `mac` |
205
+ | `--draft-root-path` | Absolute Jianying draft root on the user's computer. | required unless the script runs on that computer |
206
+ | `--no-download` | Keep no local copy; print only the (post-processed, signed) URL. | off |
191
207
  | `-o` / `--output` | Download path. | `jianying_draft.zip` |
192
208
  | `--poll-interval` | Polling interval (seconds). | `5` |
193
209
  | `--max-wait` | Maximum wait time (seconds). | `300` |
194
210
  | `--private-token` | Override the token. | see table above |
195
211
 
196
- ### Draft-root presets
212
+ ### Default draft roots
197
213
 
198
- | `--system` | Resolved path |
199
- |------------|---------------|
200
- | `mac` | `~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft` (`~` expands on the runner machine). |
201
- | `windows` | `%LOCALAPPDATA%/JianyingPro/User Data/Projects/com.lveditor.draft` (literal, not expanded — the target Windows machine resolves it). |
214
+ | `--system` | Jianying default (replace `<username>`) | Derived automatically only when |
215
+ |------------|------------------------------------------|---------------------------------|
216
+ | `mac` | `/Users/<username>/Movies/JianyingPro/User Data/Projects/com.lveditor.draft` | the script runs on macOS |
217
+ | `windows` | `C:/Users/<username>/AppData/Local/JianyingPro/User Data/Projects/com.lveditor.draft` | the script runs on Windows with `LOCALAPPDATA` set |
202
218
 
203
219
  ## Scene JSON shape
204
220
 
205
- Fields supported per scene:
221
+ Fields supported per scene (anything else — e.g. `text`, `image`, `audio` — makes the script exit with the correct field name; `id` is ignored):
206
222
 
207
223
  | Field | Type | Description |
208
224
  |-------|------|-------------|
@@ -240,11 +256,8 @@ When `textLayers` is present the script downloads the ZIP and post-processes `dr
240
256
 
241
257
  ## How to import into Jianying
242
258
 
243
- 1. By default the draft ZIP is saved to `--output` (default `jianying_draft.zip`).
244
- 2. Extract the ZIP into the "draft root" the script prints — i.e. the `draftRootPath` passed to the API:
245
- - mac default: `~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft/`
246
- - windows default: `%LOCALAPPDATA%/JianyingPro/User Data/Projects/com.lveditor.draft/`
247
- - custom: the path passed via `--draft-root-path`
259
+ 1. Download the ZIP from the printed `download URL:` (or use the local `--output` file).
260
+ 2. Extract the ZIP into exactly the draft root that was passed as `--draft-root-path` (the script prints it as `draft root:`). Tell the user that path verbatim — not a generic `~/...` one.
248
261
  3. Start (or restart) Jianying; the draft appears in the project list.
249
262
 
250
263
  > The backend embeds `draftRootPath` into `draft_content.json` / `draft_meta_info.json`. As long as the extraction directory matches the path that was passed in, Jianying resolves the material paths correctly without extra rewrites.
@@ -253,5 +266,8 @@ When `textLayers` is present the script downloads the ZIP and post-processes `dr
253
266
 
254
267
  - **401** / **token missing** (non-OpenClaw): check or set the token via the Feishu link above.
255
268
  - **Business `code != 0`**: read the `msg` printed on stderr.
269
+ - **`unsupported field(s)`**: rename the field as the message says — or better, drop `--scenes` and export the latest job.
270
+ - **`no render job with a saved RenderPlan`**: the conversation has no prepared assets yet; run `prepare_video_assets` first.
271
+ - **`--draft-root-path is required` / `invalid --draft-root-path`**: ask the user for the real path; don't retry with a guessed one.
256
272
  - **Generation timeout**: increase `--max-wait` or retry later.
257
273
  - **Network**: check connectivity and `MM_API_BASE_URL`.
@@ -22,7 +22,9 @@ from __future__ import annotations
22
22
  import argparse
23
23
  import json
24
24
  import os
25
+ import re
25
26
  import sys
27
+ import tempfile
26
28
  import time
27
29
  import urllib.error
28
30
  import urllib.parse
@@ -75,30 +77,87 @@ PILL_SUBTITLE_POSITION_Y = -0.458
75
77
  # 剪映草稿根目录解析
76
78
  # ---------------------------------------------------------------------------
77
79
 
78
- DRAFT_ROOT_PRESETS = {
79
- "mac": "~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft",
80
- # Windows 预设保留字面量 %LOCALAPPDATA%:在 Mac 上执行时不展开,
81
- # 原样传给 API;用户在 Windows 解压时目录会自然匹配。
82
- "windows": "%LOCALAPPDATA%/JianyingPro/User Data/Projects/com.lveditor.draft",
80
+ # 剪映把 draftRootPath 原样写进 draft_meta_info.json(draft_root_path / draft_fold_path),
81
+ # 所以它必须是**用户电脑上**的绝对路径。
82
+ #
83
+ # 2026-09-29 事故:agent 在服务器上跑本脚本,mac 预设 `~/...` 被 expanduser 成
84
+ # `/root/Movies/...` 写进了草稿,用户 Mac 上根本没有这个目录。`~` / `%LOCALAPPDATA%`
85
+ # 只能在「脚本就跑在那台目标电脑上」时展开,其余情况必须由调用方显式给出路径。
86
+ DRAFT_ROOT_SUFFIX = ("JianyingPro", "User Data", "Projects", "com.lveditor.draft")
87
+
88
+ # 报错时给调用方看的路径样例(<username> 要换成用户电脑上的真实用户名)。
89
+ DRAFT_ROOT_TEMPLATES = {
90
+ "mac": "/Users/<username>/Movies/JianyingPro/User Data/Projects/com.lveditor.draft",
91
+ "windows": "C:/Users/<username>/AppData/Local/JianyingPro/User Data/Projects/com.lveditor.draft",
83
92
  }
84
93
 
94
+ _WINDOWS_ABS_PATH = re.compile(r"^[A-Za-z]:[\\/]")
95
+
96
+
97
+ def draft_root_path_error(path: str) -> str:
98
+ """校验显式给出的草稿根目录;合法返回空串,否则返回原因。"""
99
+ if "<" in path or ">" in path:
100
+ return "still contains a placeholder such as <username>; replace it with the real value"
101
+ if path.startswith("~"):
102
+ return "'~' would expand on the machine running this script, not on the user's computer; pass an absolute path"
103
+ if "%" in path or "$" in path:
104
+ return "environment variables are not expanded; pass an absolute path"
105
+ if not (path.startswith("/") or _WINDOWS_ABS_PATH.match(path)):
106
+ return "must be an absolute path (/Users/... on macOS, C:/... on Windows)"
107
+ return ""
108
+
109
+
110
+ def _local_draft_root(system: str) -> str:
111
+ """脚本恰好跑在目标系统上时,按本机环境推出草稿根目录;推不出返回空串。"""
112
+ if system == "mac" and sys.platform == "darwin":
113
+ return os.path.join(os.path.expanduser("~/Movies"), *DRAFT_ROOT_SUFFIX)
114
+ if system == "windows" and os.name == "nt":
115
+ local_app_data = os.environ.get("LOCALAPPDATA", "")
116
+ if local_app_data:
117
+ return os.path.join(local_app_data, *DRAFT_ROOT_SUFFIX)
118
+ return ""
119
+
85
120
 
86
121
  def resolve_draft_root_path(explicit: str, system: str) -> str:
87
122
  """
88
123
  解析剪映草稿根目录,按优先级:
89
- 1. 显式 --draft-root-path(做 expanduser)
90
- 2. --system 预设(mac → expanduser,windows → 字面量)
124
+ 1. 显式 --draft-root-path:原样使用(不做 expanduser),但必须是绝对路径;
125
+ 2. 未给路径且脚本就跑在 --system 指定的系统上:按本机 HOME / LOCALAPPDATA 推出;
126
+ 3. 其余情况(典型是服务端 agent):报错,要求调用方向用户要路径。
91
127
  返回值始终作为 draftRootPath 传给 API。
92
128
  """
93
- if explicit:
94
- return os.path.expanduser(explicit)
95
- preset = DRAFT_ROOT_PRESETS.get(system)
96
- if not preset:
129
+ if system not in DRAFT_ROOT_TEMPLATES:
97
130
  print(f"❌ unsupported --system value: {system} (supported: mac / windows)", file=sys.stderr)
98
131
  sys.exit(1)
99
- if system == "mac":
100
- return os.path.expanduser(preset)
101
- return preset
132
+ template = DRAFT_ROOT_TEMPLATES[system]
133
+
134
+ if explicit:
135
+ path = explicit.strip()
136
+ # 去掉末尾分隔符,但保留 "C:/" 与 "/" 这种根本身。
137
+ while len(path) > 3 and path[-1] in "/\\":
138
+ path = path[:-1]
139
+ reason = draft_root_path_error(path)
140
+ if reason:
141
+ print(f"❌ invalid --draft-root-path {path!r}: {reason}", file=sys.stderr)
142
+ print(f" expected shape on {system}: {template}", file=sys.stderr)
143
+ sys.exit(1)
144
+ return path
145
+
146
+ local = _local_draft_root(system)
147
+ if local:
148
+ return local
149
+
150
+ print(
151
+ f"❌ --draft-root-path is required: this script is not running on the user's {system} computer, "
152
+ f"so the Jianying draft directory cannot be derived here.",
153
+ file=sys.stderr,
154
+ )
155
+ print(f" expected shape: {template}", file=sys.stderr)
156
+ print(
157
+ " Ask the user for the real path (Jianying → Settings → Draft location) and pass it via --draft-root-path.",
158
+ file=sys.stderr,
159
+ )
160
+ sys.exit(1)
102
161
 
103
162
 
104
163
  def resolve_token() -> str:
@@ -456,6 +515,42 @@ def convert_render_plan_to_scenes(render_plan: dict) -> tuple:
456
515
  return scenes, width, height
457
516
 
458
517
 
518
+ # --scenes 里认的字段。后端 JianYingSceneInput 只收前 8 个,textLayers 由本脚本后处理消费。
519
+ SCENE_FIELDS = (
520
+ "videoUrl", "imageUrl", "duration", "width", "height",
521
+ "audioUrl", "audioDuration", "subtitleText", "textLayers",
522
+ )
523
+ # 纯标识、不影响草稿内容的字段:写了也无害,放行。
524
+ SCENE_IGNORED_FIELDS = ("id", "sceneId")
525
+ # 常见误写 → 正确字段名。
526
+ #
527
+ # 2026-09-29 线上会话:模型先写 image/audio 被拦,改成 imageUrl/audioUrl 后却把旁白
528
+ # 写成了 text —— 这个字段以前被静默丢掉,草稿里没有字幕,模型还告诉用户「已包含字幕」。
529
+ # 未知字段一律报错,比静默丢值强。
530
+ SCENE_FIELD_ALIASES = {
531
+ "image": "imageUrl", "img": "imageUrl", "imageURL": "imageUrl", "image_url": "imageUrl",
532
+ "video": "videoUrl", "videoURL": "videoUrl", "video_url": "videoUrl",
533
+ "audio": "audioUrl", "audioURL": "audioUrl", "audio_url": "audioUrl",
534
+ "narration": "subtitleText", "text": "subtitleText", "subtitle": "subtitleText",
535
+ "caption": "subtitleText", "subtitle_text": "subtitleText",
536
+ "audio_duration": "audioDuration",
537
+ }
538
+
539
+
540
+ def unknown_field_error(index: int, scene: dict) -> None:
541
+ """scene 里出现不认识的字段就退出,并指出该用哪个字段名。"""
542
+ unknown = [k for k in scene if k not in SCENE_FIELDS and k not in SCENE_IGNORED_FIELDS]
543
+ if not unknown:
544
+ return
545
+ hints = []
546
+ for key in unknown:
547
+ target = SCENE_FIELD_ALIASES.get(key)
548
+ hints.append(f"'{key}' → use '{target}'" if target else f"'{key}' is not supported")
549
+ print(f"❌ scene {index + 1} has unsupported field(s): {'; '.join(hints)}", file=sys.stderr)
550
+ print(f" supported fields: {', '.join(SCENE_FIELDS)}", file=sys.stderr)
551
+ sys.exit(1)
552
+
553
+
459
554
  def parse_scenes(scenes_arg: str) -> list:
460
555
  """解析 --scenes 参数:支持 inline JSON 字符串或文件路径"""
461
556
  # 尝试作为文件路径读取
@@ -489,6 +584,7 @@ def parse_scenes(scenes_arg: str) -> list:
489
584
  if not isinstance(scene, dict):
490
585
  print(f"❌ scene {i + 1} is malformed: must be an object", file=sys.stderr)
491
586
  sys.exit(1)
587
+ unknown_field_error(i, scene)
492
588
  if not scene.get("videoUrl") and not scene.get("imageUrl"):
493
589
  print(f"❌ scene {i + 1} is missing videoUrl or imageUrl", file=sys.stderr)
494
590
  sys.exit(1)
@@ -626,13 +722,80 @@ def poll_task_status(task_id: str, poll_interval: int = 5, max_wait: int = 300)
626
722
  def _encode_url(raw_url: str) -> str:
627
723
  """对 URL 中的非 ASCII 字符做 percent-encoding(保留已编码部分和合法字符)"""
628
724
  parsed = urllib.parse.urlparse(raw_url)
629
- encoded_path = urllib.parse.quote(parsed.path, safe="/:@!$&'()*+,;=-._~")
725
+ # '%' 必须留在 safe 里:ab-api 返回的地址已经是编码过的(中文文件名 → %E9%A3…),
726
+ # 再编一次会变成 %25E9,对象 key 对不上;带签名的地址还会 SignatureDoesNotMatch。
727
+ encoded_path = urllib.parse.quote(parsed.path, safe="/:@!$&'()*+,;=-._~%")
630
728
  return urllib.parse.urlunparse(parsed._replace(path=encoded_path))
631
729
 
632
730
 
731
+ class DraftUploadError(RuntimeError):
732
+ """后处理 ZIP 传回失败。调用方据此降级为交付后端原始 ZIP,而不是整次导出失败。"""
733
+
734
+
735
+ def upload_draft_zip(zip_path: str, file_name: str) -> str:
736
+ """
737
+ 把后处理过的 ZIP 传回 ab-api(POST /file/uploadJianYingDraft),返回带签名的下载地址。
738
+ 失败抛 DraftUploadError(带 HTTP 状态码 / 业务 msg)。
739
+
740
+ 为什么必须传回:后处理(字幕样式 / textLayers)是在跑脚本的这台机器上做的。
741
+ 服务端 agent 的本地文件用户拿不到,不传回的话用户下载到的仍是后端生成的原始 ZIP,
742
+ 后处理等于白做。
743
+ """
744
+ boundary = "----RemixmateJianYing" + uuid.uuid4().hex
745
+ with open(zip_path, "rb") as f:
746
+ zip_data = f.read()
747
+ # filename 里放 ASCII 占位,真实(可能含中文的)文件名走独立的 fileName 字段,
748
+ # 避免 multipart 头部编码在不同服务端实现间的差异。
749
+ parts = [
750
+ f"--{boundary}".encode(),
751
+ b'Content-Disposition: form-data; name="fileName"',
752
+ b"",
753
+ file_name.encode("utf-8"),
754
+ f"--{boundary}".encode(),
755
+ b'Content-Disposition: form-data; name="file"; filename="draft.zip"',
756
+ b"Content-Type: application/zip",
757
+ b"",
758
+ zip_data,
759
+ f"--{boundary}--".encode(),
760
+ b"",
761
+ ]
762
+ body = b"\r\n".join(parts)
763
+
764
+ print(f"\n⬆️ Uploading post-processed draft ({len(zip_data) / 1024 / 1024:.1f} MB)...")
765
+ req = urllib.request.Request(
766
+ f"{API_BASE_URL}/file/uploadJianYingDraft",
767
+ data=body,
768
+ headers=_build_headers(f"multipart/form-data; boundary={boundary}"),
769
+ method="POST",
770
+ )
771
+ try:
772
+ with urllib.request.urlopen(req, timeout=600) as resp:
773
+ raw = resp.read().decode("utf-8", errors="replace")
774
+ except urllib.error.HTTPError as e:
775
+ # 网关(413 请求体过大、404 路由未上线)返回的是 HTML,只取一小段帮定位。
776
+ snippet = e.read().decode("utf-8", errors="replace")[:200].replace("\n", " ")
777
+ raise DraftUploadError(f"HTTP {e.code} {e.reason}: {snippet}") from e
778
+ except (urllib.error.URLError, OSError, ConnectionError) as e:
779
+ raise DraftUploadError(f"network error: {e}") from e
780
+
781
+ try:
782
+ result = json.loads(raw)
783
+ except json.JSONDecodeError as e:
784
+ raise DraftUploadError(f"non-JSON response: {raw[:200]}") from e
785
+ if result.get("code") != 0:
786
+ raise DraftUploadError(f"API error: {result.get('msg', 'unknown error')}")
787
+ url = (result.get("data") or {}).get("downloadUrl", "")
788
+ if not url:
789
+ raise DraftUploadError("upload succeeded but no download URL was returned")
790
+ print(" ✅ uploaded")
791
+ return url
792
+
793
+
633
794
  def download_zip(download_url: str, output_path: str) -> None:
634
795
  """下载 ZIP 文件到本地"""
635
- print(f"\n⬇️ Downloading draft ZIP: {download_url[:80]}...")
796
+ # 不打印地址:去掉签名的地址打开就是 403,而模型会把 stdout 里任何像链接的东西
797
+ # 转交给用户(conv_U9oYmHmE1iJbx2qETDuh 就是把这一行当成了下载链接)。
798
+ print("\n⬇️ Downloading draft ZIP for post-processing...")
636
799
  safe_url = _encode_url(download_url)
637
800
  try:
638
801
  urllib.request.urlretrieve(safe_url, output_path)
@@ -906,6 +1069,39 @@ def has_text_layers(scenes: list) -> bool:
906
1069
  return any(scene.get("textLayers") for scene in scenes)
907
1070
 
908
1071
 
1072
+ def _render_job_client():
1073
+ """
1074
+ 导入共享的 render_job_client(在 skills/template-registry/scripts/,跨 skill 共享
1075
+ Python 的约定位置,render-video 也是这么引的)。
1076
+ """
1077
+ shared = os.path.join(os.path.dirname(__file__), "..", "..", "template-registry", "scripts")
1078
+ if shared not in sys.path:
1079
+ sys.path.insert(0, shared)
1080
+ import render_job_client # noqa: PLC0415 — 路径注入之后才能导入
1081
+
1082
+ return render_job_client
1083
+
1084
+
1085
+ def print_draft_summary(scenes: list, width: int, height: int, draft_root_path: str) -> None:
1086
+ """
1087
+ 打印草稿里**实际**装了什么。
1088
+
1089
+ 调用方(agent)要据此向用户转述,而不是凭自己传了什么去猜——线上会话里模型把
1090
+ 被丢掉的字幕也说成「已包含」。
1091
+ """
1092
+ total = len(scenes)
1093
+ video = sum(1 for s in scenes if s.get("videoUrl"))
1094
+ image = sum(1 for s in scenes if s.get("imageUrl") and not s.get("videoUrl"))
1095
+ audio = sum(1 for s in scenes if s.get("audioUrl"))
1096
+ subtitle = sum(1 for s in scenes if s.get("subtitleText") or s.get("textLayers"))
1097
+ duration = sum(float(s.get("duration") or 0) for s in scenes)
1098
+ print("\n📦 Draft contents:")
1099
+ print(f" scenes: {total} (video {video}, image {image}), total {duration:.1f}s, canvas {width}x{height}")
1100
+ print(f" narration audio: {audio}/{total} scene(s)")
1101
+ print(f" subtitles / text layers: {subtitle}/{total} scene(s)")
1102
+ print(f" draft root written into the draft: {draft_root_path}")
1103
+
1104
+
909
1105
  def main() -> None:
910
1106
  parser = argparse.ArgumentParser(
911
1107
  description="Jianying draft generator — packages asset URLs into a Jianying-importable draft ZIP",
@@ -941,7 +1137,10 @@ Scene fields:
941
1137
  "--from-job-id",
942
1138
  type=int,
943
1139
  default=0,
944
- help="Load the RenderPlan from the database (pass a jobId, replaces --from-render-plan; requires PRIV_TOKEN)",
1140
+ help=(
1141
+ "Load the RenderPlan from the database (pass a jobId; requires PRIV_TOKEN). When no source is given "
1142
+ "and CONVERSATION_ID is set, the latest prepared job of that conversation is used automatically"
1143
+ ),
945
1144
  )
946
1145
  parser.add_argument("--width", type=int, default=1080, help="Canvas width (default 1080)")
947
1146
  parser.add_argument("--height", type=int, default=1920, help="Canvas height (default 1920)")
@@ -950,17 +1149,24 @@ Scene fields:
950
1149
  "--system",
951
1150
  choices=["mac", "windows"],
952
1151
  default="mac",
953
- help="Pick the draft-root preset by OS (default: mac)",
1152
+ help="OS of the computer that will open the draft (default: mac)",
954
1153
  )
955
1154
  parser.add_argument(
956
1155
  "--draft-root-path",
957
1156
  default="",
958
- help="Explicitly specify the Jianying draft root path (overrides the --system preset)",
1157
+ help=(
1158
+ "Absolute Jianying draft root on the user's computer, e.g. "
1159
+ f"{DRAFT_ROOT_TEMPLATES['mac']} or {DRAFT_ROOT_TEMPLATES['windows']}. "
1160
+ "Required unless this script runs on that computer itself; '~' and %%VARS%% are rejected"
1161
+ ),
959
1162
  )
960
1163
  parser.add_argument(
961
1164
  "--no-download",
962
1165
  action="store_true",
963
- help="Skip the ZIP download; only print the download URL (ignored when scenes include textLayers / subtitleText)",
1166
+ help=(
1167
+ "Do not keep a local copy of the ZIP; only print the download URL. When subtitles / textLayers "
1168
+ "need post-processing the ZIP is fetched to a temp file, styled, uploaded back and the temp file removed"
1169
+ ),
964
1170
  )
965
1171
  parser.add_argument("-o", "--output", default=None, help="Download path (default jianying_draft_{timestamp}.zip, prevents multi-user collisions)")
966
1172
  parser.add_argument("--poll-interval", type=int, default=5, help="Polling interval (seconds, default 5)")
@@ -980,22 +1186,46 @@ Scene fields:
980
1186
  print("❌ PrivToken not configured", file=sys.stderr)
981
1187
  sys.exit(1)
982
1188
 
1189
+ conversation_id = os.environ.get("CONVERSATION_ID", "").strip()
1190
+
1191
+ # 没给任何素材来源、但处在一个会话里(ab-agent 会注入 CONVERSATION_ID):
1192
+ # 默认导出这个会话最近一版已准备好素材的任务。
1193
+ if not (args.from_job_id or args.from_render_plan or args.scenes) and conversation_id:
1194
+ rjc = _render_job_client()
1195
+ try:
1196
+ latest = rjc.latest_job(conversation_id, PRIVATE_TOKEN)
1197
+ except RuntimeError as e:
1198
+ print(f"❌ could not find the latest prepared render job of this conversation: {e}", file=sys.stderr)
1199
+ print(" run prepare_video_assets first, or pass --from-job-id / --scenes", file=sys.stderr)
1200
+ sys.exit(1)
1201
+ args.from_job_id = int(latest["jobId"])
1202
+ print(
1203
+ f"📋 using the latest prepared render job of this conversation: "
1204
+ f"jobId={args.from_job_id} (attempt {latest.get('attempt', '?')}, updated {latest.get('updateTime', '?')})"
1205
+ )
1206
+ elif args.scenes and conversation_id:
1207
+ # 手写 scenes 时模型只能从聊天记录里拼素材 URL,而多轮之间它看不到之前的工具结果,
1208
+ # 很容易拼出过期的旧版素材(线上会话里就是把用户改过的 v3 配音换回了 v1)。
1209
+ #
1210
+ # 只在会话里**真有**准备好的任务时才提醒:纯数字人 / 纯生图的会话没有 RenderPlan,
1211
+ # 手写 scenes 是唯一的路,这时提醒只会让模型怀疑自己(conv_U9oYmHmE1iJbx2qETDuh)。
1212
+ try:
1213
+ latest = _render_job_client().latest_job(conversation_id, PRIVATE_TOKEN)
1214
+ except RuntimeError:
1215
+ latest = None
1216
+ if latest:
1217
+ print(
1218
+ f"⚠️ --scenes was hand-written, but this conversation has a prepared render job "
1219
+ f"(jobId={latest['jobId']}); omit --scenes to export it instead of URLs copied from chat history",
1220
+ file=sys.stderr,
1221
+ )
1222
+
983
1223
  # --from-job-id 模式:从数据库加载 RenderPlan
984
1224
  if args.from_job_id:
985
1225
  print(f"📋 loading RenderPlan from database (jobId={args.from_job_id})...")
986
- if not PRIVATE_TOKEN:
987
- print("❌ --from-job-id mode requires PRIV_TOKEN", file=sys.stderr)
988
- sys.exit(1)
989
- # render_job_client lives in skills/template-registry/scripts/ — the
990
- # shared location for cross-skill Python helpers (matches the pattern
991
- # render-video uses for the same import).
992
- _template_registry_scripts = os.path.join(
993
- os.path.dirname(__file__), "..", "..", "template-registry", "scripts"
994
- )
995
- sys.path.insert(0, _template_registry_scripts)
996
- from render_job_client import get_plan as rjc_get_plan
1226
+ rjc = _render_job_client()
997
1227
  try:
998
- render_plan_str = rjc_get_plan(args.from_job_id, PRIVATE_TOKEN)
1228
+ render_plan_str = rjc.get_plan(args.from_job_id, PRIVATE_TOKEN)
999
1229
  except RuntimeError as e:
1000
1230
  print(f"❌ failed to fetch RenderPlan: {e}", file=sys.stderr)
1001
1231
  sys.exit(1)
@@ -1029,7 +1259,7 @@ Scene fields:
1029
1259
  else:
1030
1260
  # 传统模式:必须提供 --scenes 和 --title
1031
1261
  if not args.scenes:
1032
- print("❌ provide --scenes or --from-render-plan", file=sys.stderr)
1262
+ print("❌ provide --from-job-id, --from-render-plan or --scenes", file=sys.stderr)
1033
1263
  parser.print_help()
1034
1264
  sys.exit(1)
1035
1265
  if not args.title:
@@ -1063,34 +1293,57 @@ Scene fields:
1063
1293
  result_data = poll_task_status(task_id, args.poll_interval, args.max_wait)
1064
1294
 
1065
1295
  download_url = result_data.get("downloadUrl", "")
1066
- file_name = result_data.get("fileName", "")
1296
+ file_name = result_data.get("fileName", "") or "jianying_draft.zip"
1067
1297
 
1068
1298
  if not download_url:
1069
1299
  print("❌ generation finished but no download URL was returned", file=sys.stderr)
1070
1300
  sys.exit(1)
1071
1301
 
1072
1302
  print(f"\n🎉 Jianying draft generated successfully!")
1073
- if file_name:
1074
- print(f" filename: {file_name}")
1075
- print(f" download URL: {download_url}")
1303
+ print(f" filename: {file_name}")
1076
1304
 
1077
- # 默认下载 ZIP;含 textLayers/subtitleText 时强制下载以便后处理
1078
- if args.no_download and not needs_postprocess:
1079
- print(f"\n💡 To download the ZIP, drop --no-download (or open the URL above)")
1080
- return
1081
-
1082
- if args.no_download and needs_postprocess:
1083
- print("⚠️ textLayers/subtitleText detected; --no-download is ignored (post-processing is local)")
1084
-
1085
- output_path = args.output or f"jianying_draft_{int(time.time())}.zip"
1086
- download_zip(download_url, output_path)
1087
-
1088
- # 后处理 textLayers / Pill 字幕样式
1305
+ # 在 agent 里(有 CONVERSATION_ID)本地文件落在服务器上,用户拿不到:除非显式给了
1306
+ # --output,否则不留副本。conv_U9oYmHmE1iJbx2qETDuh 一次导出在服务器 cwd 里留了 3 个 5MB 的 ZIP。
1307
+ keep_local = bool(args.output) or (not args.no_download and not conversation_id)
1308
+ styled = True
1309
+ output_path = ""
1310
+ if needs_postprocess:
1311
+ # 后处理必须先把 ZIP 拿到本地;不保留副本时落到临时文件,用完删掉。
1312
+ if keep_local:
1313
+ output_path = args.output or f"jianying_draft_{int(time.time())}.zip"
1314
+ else:
1315
+ fd, output_path = tempfile.mkstemp(prefix="jianying_draft_", suffix=".zip")
1316
+ os.close(fd)
1317
+ try:
1318
+ download_zip(download_url, output_path)
1319
+ postprocess_draft_zip(output_path, scenes)
1320
+ # 用户拿到的链接必须指向后处理之后的版本,否则字幕样式 / textLayers 全部丢失。
1321
+ download_url = upload_draft_zip(output_path, file_name)
1322
+ except DraftUploadError as e:
1323
+ # 降级而不是失败:后端生成的原始 ZIP 已经包含素材、配音和字幕文本,只是没有
1324
+ # 套字幕样式 / textLayers。整次失败的话模型只会原样重试(每次再生成一遍草稿),
1325
+ # 用户什么都拿不到。
1326
+ styled = False
1327
+ print(f"⚠️ could not upload the styled draft ({e}); delivering the backend draft instead.", file=sys.stderr)
1328
+ print(" subtitle text is included, but the pill subtitle style / textLayers were NOT applied. "
1329
+ "Do not retry the same call; report this to the user.", file=sys.stderr)
1330
+ finally:
1331
+ if not keep_local and os.path.exists(output_path):
1332
+ os.remove(output_path)
1333
+ output_path = ""
1334
+ elif keep_local:
1335
+ output_path = args.output or f"jianying_draft_{int(time.time())}.zip"
1336
+ download_zip(download_url, output_path)
1337
+
1338
+ print_draft_summary(scenes, args.width, args.height, draft_root_path)
1089
1339
  if needs_postprocess:
1090
- postprocess_draft_zip(output_path, scenes)
1340
+ print(f" subtitle styling applied: {'yes' if styled else 'NO (upload failed, backend draft delivered)'}")
1341
+ print(f"\n download URL: {download_url}")
1342
+ if output_path:
1343
+ print(f" local file: {output_path}")
1091
1344
 
1092
1345
  print(f"\n💡 How to import into Jianying:")
1093
- print(f" 1. Extract {output_path} into the Jianying draft directory:")
1346
+ print(f" 1. Download and extract the ZIP into exactly this directory on the user's computer:")
1094
1347
  print(f" {draft_root_path}")
1095
1348
  print(f" 2. Start or restart Jianying; the draft will appear in your project list")
1096
1349
 
@@ -4,7 +4,7 @@
4
4
  "tier": "orchestration",
5
5
  "category": "consuming",
6
6
  "title": "Jianying (CapCut) Draft Export",
7
- "description": "Jianying (CapCut) draft export: package asset URLs into a draft ZIP that Jianying can import. Supports automatic conversion from a RenderPlan.",
7
+ "description": "Jianying (CapCut) draft export: package asset URLs into a draft ZIP that Jianying can import. By default exports the latest prepared render job of the current conversation (no scenes needed).",
8
8
  "auth": "required",
9
9
  "envVars": [
10
10
  "PRIV_TOKEN",
@@ -15,13 +15,17 @@
15
15
  "parameters": {
16
16
  "type": "object",
17
17
  "properties": {
18
+ "from_job_id": {
19
+ "type": "number",
20
+ "description": "Render job id to export (the `jobId` printed by prepare_video_assets). Omit it together with scenes to export the latest prepared job of the current conversation — preferred, because asset URLs copied from chat history go stale after re-renders"
21
+ },
18
22
  "from_render_plan": {
19
23
  "type": "string",
20
- "description": "Build the draft from a RenderPlan JSON (replaces --scenes)"
24
+ "description": "Build the draft from a local RenderPlan JSON file (local debugging only)"
21
25
  },
22
26
  "scenes": {
23
27
  "type": "string",
24
- "description": "Scene array as JSON (inline string or file path)"
28
+ "description": "Hand-written scene array as JSON — only for assets that never went through prepare_video_assets. Fields per scene: imageUrl | videoUrl (one required), duration (seconds, required), audioUrl, audioDuration, subtitleText, textLayers, width, height. Any other field (text, image, audio…) is rejected"
25
29
  },
26
30
  "title": {
27
31
  "type": "string",
@@ -41,15 +45,15 @@
41
45
  "mac",
42
46
  "windows"
43
47
  ],
44
- "description": "Draft-root preset"
48
+ "description": "OS of the computer that will open the draft"
45
49
  },
46
50
  "draft_root_path": {
47
51
  "type": "string",
48
- "description": "Explicit Jianying draft root path (overrides --system)"
52
+ "description": "Absolute Jianying draft root on the user's computer (Jianying → Settings → Draft location), e.g. /Users/<username>/Movies/JianyingPro/User Data/Projects/com.lveditor.draft or C:/Users/<username>/AppData/Local/JianyingPro/User Data/Projects/com.lveditor.draft. Required when the agent runs on a server; never guess the username, ask the user"
49
53
  },
50
54
  "no_download": {
51
55
  "type": "boolean",
52
- "description": "Do not download the ZIP; print the URL only"
56
+ "description": "Do not keep a local copy of the ZIP; print the download URL only"
53
57
  },
54
58
  "output": {
55
59
  "type": "string",
@@ -60,7 +64,8 @@
60
64
  },
61
65
  "ui": {
62
66
  "primary": [
63
- "system"
67
+ "system",
68
+ "draft_root_path"
64
69
  ],
65
70
  "advanced": [],
66
71
  "hidden": [
@@ -68,9 +73,9 @@
68
73
  "width",
69
74
  "height",
70
75
  "output",
76
+ "from_job_id",
71
77
  "from_render_plan",
72
78
  "scenes",
73
- "draft_root_path",
74
79
  "no_download"
75
80
  ]
76
81
  }
@@ -2,6 +2,6 @@
2
2
  "skillName": "export-jianying",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "554",
5
- "version": "V4",
5
+ "version": "V7",
6
6
  "skillDescription": "Jianying (CapCut) draft-generation skill. Packages asset URLs into a draft ZIP that Jianying can import; supports automatic conversion from a RenderPlan (calls ab-api /file/generateJianYing).\n\nUse this skill as soon as the user mentions any of these intents:\n- Export to Jianying, Jianying draft, package for Jianying, import into Jianying\n- Export materials to the Jianying format, generate a Jianying project\n- Bundle video / image / audio into a Jianying draft\n- Export a Jianying draft from a RenderPlan\n\nEven when the user does not say \"Jianying\" explicitly, use this skill whenever they want to package materials into a draft that can be edited in Jianying."
7
7
  }
@@ -1516,7 +1516,12 @@ def resolve_digital_human_segments(
1516
1516
  else:
1517
1517
  local = dhs.concat_drive_audio(pieces, out_dir, asset_id, ffmpeg)
1518
1518
  from upload_video import upload_media_file
1519
- url = upload_media_file(local, title=f"digital-human drive {asset_id}")["fileUrl"]
1519
+ url = upload_media_file(
1520
+ local,
1521
+ title=f"digital-human drive {asset_id}",
1522
+ role="dh_drive_audio",
1523
+ retention="intermediate",
1524
+ )["fileUrl"]
1520
1525
  total = sum(p["durationSec"] for p in pieces)
1521
1526
  log(f"{asset_id}: drive audio for {len(pieces)} scene(s), {total:.1f}s")
1522
1527
  payload["audioUrl"] = url
@@ -3064,11 +3069,18 @@ Examples:
3064
3069
 
3065
3070
  upload_title = upload_title_hint
3066
3071
  LogPrint(f"\n📤 Uploading video to OSS...", file=sys.stderr)
3067
- upload_result = upload_media_file(file_path=output_path, title=upload_title)
3072
+ upload_result = upload_media_file(
3073
+ file_path=output_path,
3074
+ title=upload_title,
3075
+ role="final_video",
3076
+ retention="output",
3077
+ )
3068
3078
  manifest["upload"] = {
3069
3079
  "fileUrl": upload_result["fileUrl"],
3070
3080
  "title": upload_title,
3071
3081
  "uploadedAt": now_iso(),
3082
+ # ab-api 保存 manifest 时按 fileId 给成片建语义卡 / 片段 / 血缘
3083
+ "fileId": upload_result.get("fileId"),
3072
3084
  }
3073
3085
  if manifest_path:
3074
3086
  save_json(manifest, manifest_path)
@@ -8,9 +8,11 @@
8
8
  `<MM_API_BASE_URL>/file/uploadMediaFile`,解析响应 JSON,输出 fileUrl。
9
9
 
10
10
  用法:
11
- python3 upload_video.py --file output/video.mp4
12
- python3 upload_video.py --file output/video.mp4 --title "我的视频"
13
- python3 upload_video.py --file output/video.mp4 --json-output
11
+ python3 upload_video.py --file output/video.mp4 --role final_video
12
+ python3 upload_video.py --file output/video.mp4 --role final_video --title "我的视频"
13
+ python3 upload_video.py --file output/video.mp4 --role final_video --json-output
14
+
15
+ 环境变量 PRIV_TOKEN 必填(接口在 ab-api 私有组,产物落素材表并归到调用者名下)。
14
16
 
15
17
  环境变量:
16
18
  MM_API_BASE_URL - API 根地址(默认 MM_API_BASE_URL 环境变量或 https://api-agent.remixmate.ai/api)
@@ -39,9 +41,9 @@ def _guess_content_type(file_path: str) -> str:
39
41
  return ctype or "application/octet-stream"
40
42
 
41
43
 
42
- def _build_multipart_body(file_path: str, title: str = None) -> (bytes, str):
44
+ def _build_multipart_body(file_path: str, fields: dict) -> (bytes, str):
43
45
  """
44
- 手动构建 multipart/form-data body。
46
+ 手动构建 multipart/form-data body。fields 里值为空的字段不发。
45
47
 
46
48
  返回 (body_bytes, content_type_header)。
47
49
  """
@@ -49,12 +51,13 @@ def _build_multipart_body(file_path: str, title: str = None) -> (bytes, str):
49
51
  crlf = b"\r\n"
50
52
  parts = []
51
53
 
52
- # title 字段(可选)
53
- if title:
54
+ for name, value in fields.items():
55
+ if value in (None, ""):
56
+ continue
54
57
  parts.append(("--" + boundary).encode("utf-8"))
55
- parts.append(b'Content-Disposition: form-data; name="title"')
58
+ parts.append(f'Content-Disposition: form-data; name="{name}"'.encode("utf-8"))
56
59
  parts.append(b"")
57
- parts.append(title.encode("utf-8"))
60
+ parts.append(str(value).encode("utf-8"))
58
61
 
59
62
  # file 字段
60
63
  file_name = os.path.basename(file_path)
@@ -87,13 +90,21 @@ def upload_media_file(
87
90
  api_base_url: str = None,
88
91
  title: str = None,
89
92
  timeout: int = 600,
93
+ *,
94
+ role: str,
95
+ retention: str = None,
90
96
  ) -> dict:
91
97
  """
92
- 将本地媒体文件以 multipart/form-data 形式上传到 ab-api。
98
+ 将本地媒体文件以 multipart/form-data 形式上传到 ab-api,并落素材表。
99
+
100
+ role 必填:素材语义角色(final_video / dh_drive_audio / source_audio ...),由调用方
101
+ 按「这是什么」声明——ab-api 不再按文件类型猜。retention 可选(intermediate / output / user)。
93
102
 
94
- 返回服务端 data 字段,通常形如 {"fileUrl": "https://..."}。
103
+ 返回服务端 data 字段,形如 {"fileUrl": "https://...", "fileId": 123, "fileUid": "file_xxx"}。
95
104
  上传失败会抛出异常。
96
105
  """
106
+ if not role:
107
+ raise ValueError("upload_media_file: role is required")
97
108
  if not os.path.exists(file_path):
98
109
  raise FileNotFoundError(f"file not found: {file_path}")
99
110
 
@@ -101,14 +112,22 @@ def upload_media_file(
101
112
  url = f"{api_base_url}/file/uploadMediaFile"
102
113
 
103
114
  file_size = os.path.getsize(file_path)
104
- print(f"📤 Uploading: {file_path} ({file_size / 1024 / 1024:.2f} MB)", file=sys.stderr)
115
+ print(f"📤 Uploading: {file_path} ({file_size / 1024 / 1024:.2f} MB, role={role})", file=sys.stderr)
105
116
  print(f" target: {url}", file=sys.stderr)
106
117
 
107
- body, content_type = _build_multipart_body(file_path, title=title)
118
+ body, content_type = _build_multipart_body(
119
+ file_path, {"title": title, "role": role, "retention": retention}
120
+ )
121
+ # 该接口在 ab-api 私有组:产物要落素材表、归到调用者名下,必须带 PrivToken。
108
122
  headers = {
109
123
  "Content-Type": content_type,
110
124
  "Content-Length": str(len(body)),
125
+ "X-Priv-Token": os.environ.get("PRIV_TOKEN", ""),
126
+ "x-invoke-skill": "render-video",
111
127
  }
128
+ conversation_id = os.environ.get("CONVERSATION_ID", "")
129
+ if conversation_id:
130
+ headers["x-conversation-id"] = conversation_id
112
131
 
113
132
  # Use http.client directly to avoid urllib RemoteDisconnected issues
114
133
  # with servers that return 'connection: close'
@@ -161,6 +180,17 @@ def main():
161
180
  parser = argparse.ArgumentParser(description="Upload a media file to ab-api / Alibaba Cloud OSS")
162
181
  parser.add_argument("--file", required=True, help="Local file path")
163
182
  parser.add_argument("--title", default=None, help="File title (optional, defaults to filename)")
183
+ parser.add_argument(
184
+ "--role",
185
+ required=True,
186
+ help="Asset role, e.g. final_video / dh_drive_audio / source_audio (required)",
187
+ )
188
+ parser.add_argument(
189
+ "--retention",
190
+ default=None,
191
+ choices=["intermediate", "output", "user"],
192
+ help="Retention policy (optional)",
193
+ )
164
194
  parser.add_argument(
165
195
  "--api-base-url",
166
196
  default=None,
@@ -177,6 +207,8 @@ def main():
177
207
  api_base_url=args.api_base_url,
178
208
  title=args.title,
179
209
  timeout=args.timeout,
210
+ role=args.role,
211
+ retention=args.retention,
180
212
  )
181
213
  except Exception as e:
182
214
  print(f"❌ {e}", file=sys.stderr)
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V33",
5
+ "version": "V34",
6
6
  "skillDescription": "Final-render skill: loads a persisted RenderPlan by `job_id` and drives the Remotion engine to produce the final video.\n\nUse this skill as soon as the user mentions any of these intents (after assets are already prepared):\n- Render the video, composite the video, export the video\n- Turn the prepared assets into the final clip\n- Render with Remotion\n\nPrerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.\n\n⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering."
7
7
  }
@@ -172,6 +172,23 @@ def save_manifest(job_id: int, manifest: dict, priv_token: str) -> None:
172
172
  _post("/renderJob/saveManifest", {"jobId": job_id, "manifest": manifest}, priv_token)
173
173
 
174
174
 
175
+ def latest_job(conversation_id: str, priv_token: str) -> dict:
176
+ """
177
+ 按会话取最近一次**已存 RenderPlan** 的渲染任务摘要(POST /renderJob/latest)。
178
+
179
+ 多轮对话里模型看不到之前的工具结果,拿不到 jobId;消费上一轮成果的 skill
180
+ (如 export-jianying)用注入的 CONVERSATION_ID 反查最新一版,而不是让模型从
181
+ 聊天记录里拼素材 URL —— 那样会拿到过期的旧版素材。
182
+
183
+ Returns:
184
+ {"jobId": int, "status": str, "attempt": int, "createTime": str, "updateTime": str}
185
+ """
186
+ data = _post("/renderJob/latest", {"conversationId": conversation_id}, priv_token)
187
+ if not data.get("jobId"):
188
+ raise RuntimeError("no render job with a saved RenderPlan in this conversation")
189
+ return data
190
+
191
+
175
192
  def get_job(job_id: int, priv_token: str) -> dict:
176
193
  """
177
194
  获取渲染任务完整详情。
@@ -2,6 +2,6 @@
2
2
  "skillName": "template-registry",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "475",
5
- "version": "V25",
5
+ "version": "V26",
6
6
  "skillDescription": "Video-template registry skill. Stores every video-template definition and lists the available templates (templateId / name / aspect ratio / style tags).\n\nUse this skill as soon as the user mentions any of these intents:\n- View available templates / list every template\n\nNote: DSL→TemplateBinding is no longer a separate exposed step — once prepare_video_assets receives a template_id it builds the binding internally."
7
7
  }
@@ -197,6 +197,13 @@ def upload_audio(audio_path: str) -> str:
197
197
  body += b'Content-Disposition: form-data; name="title"\r\n\r\n'
198
198
  body += filename.encode()
199
199
  body += b"\r\n"
200
+ # role / retention:ab-api 落素材表时必填角色。抽离音频只为 ASR,属中间产物;
201
+ # 源视频是第三方的,检索侧据 role 默认排除(license=third_party_unknown)。
202
+ for name, value in (("role", "source_audio"), ("retention", "intermediate")):
203
+ body += f"--{boundary}\r\n".encode()
204
+ body += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
205
+ body += value.encode()
206
+ body += b"\r\n"
200
207
  # 结束
201
208
  body += f"--{boundary}--\r\n".encode()
202
209
 
@@ -207,6 +214,8 @@ def upload_audio(audio_path: str) -> str:
207
214
  }
208
215
  if AGENT_NAME:
209
216
  headers["x-invoke-agent"] = AGENT_NAME
217
+ if os.environ.get("CONVERSATION_ID"):
218
+ headers["x-conversation-id"] = os.environ["CONVERSATION_ID"]
210
219
 
211
220
  req = urllib.request.Request(url, data=body, headers=headers, method="POST")
212
221
 
@@ -2,6 +2,6 @@
2
2
  "skillName": "video-parser",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "553",
5
- "version": "V2",
5
+ "version": "V3",
6
6
  "skillDescription": "Video deconstruction skill. Splits a video into reusable content assets — audio, ASR transcript, scene segments, keyframe images.\n\nUse this skill as soon as the user mentions any of these intents:\n- Deconstruct / split / analyze / parse a video\n- Extract keyframes, extract the script from a video, transcribe a video\n- Pull content assets or raw material out of a video\n\nEven when the user does not say \"deconstruct\" explicitly, use this skill whenever they want to extract script text, keyframes, or structured information from a video."
7
7
  }
@@ -83,6 +83,8 @@ def _submit(
83
83
  "coverAtSec": str(cover_at_sec),
84
84
  "resolveCdn": "1" if resolve_cdn else "0",
85
85
  "ext": ext,
86
+ # 素材角色:ab-api 按它落 file.role(录屏贡献 video_clip 能力、按成品保留)
87
+ "role": "screen_record",
86
88
  }
87
89
  if title:
88
90
  query["title"] = title
@@ -230,4 +232,79 @@ def finalize_recording(
230
232
  )
231
233
 
232
234
 
233
- __all__ = ["finalize_recording", "FinalizeError"]
235
+ DEFAULT_ABAPI_BASE_URL = "https://api-agent.remixmate.ai/api"
236
+
237
+ # 分镜里承载「画面在讲什么」的文本字段(caption / title-card / type 等场景)
238
+ _STORY_TEXT_KEYS = ("title", "subtitle", "text", "caption", "label", "content")
239
+
240
+
241
+ def storyboard_text(story: Optional[dict]) -> str:
242
+ """把分镜里的可读文本收成一段:标题卡、字幕条、打字内容等,按场景顺序拼接。
243
+
244
+ 录屏本身没有旁白,这段文本就是它最便宜的语义——生产者手上现成有,不需要 ASR。
245
+ """
246
+ if not isinstance(story, dict):
247
+ return ""
248
+ parts: list[str] = []
249
+ for scene in story.get("scenes") or []:
250
+ if not isinstance(scene, dict):
251
+ continue
252
+ for key in _STORY_TEXT_KEYS:
253
+ val = scene.get(key)
254
+ if isinstance(val, str) and val.strip():
255
+ parts.append(val.strip())
256
+ seen: set[str] = set()
257
+ out = [p for p in parts if not (p in seen or seen.add(p))]
258
+ return "\n".join(out)
259
+
260
+
261
+ def annotate_asset(
262
+ *,
263
+ private_token: str,
264
+ file_id: Optional[int],
265
+ file_url: str,
266
+ text: str,
267
+ source_url: str,
268
+ duration_sec: Optional[float],
269
+ request_timeout: float = 30.0,
270
+ ) -> None:
271
+ """把录屏的语义写进 ab-api 素材库(POST /asset/annotate):分镜文本 + 来源页面。
272
+
273
+ 分镜里没有逐场景时间戳,整条录屏作为一个片段。失败抛 FinalizeError,调用方只记日志——
274
+ 语义是附加信息,不能让「录屏已完成」失败。见 ab-platform/docs/asset-semantic-library-design.md §6.1。
275
+ """
276
+ if not private_token or not (file_id or file_url):
277
+ return
278
+ base = (os.environ.get("MM_API_BASE_URL") or DEFAULT_ABAPI_BASE_URL).rstrip("/")
279
+ payload: dict = {"provenance": {"sourceUrl": source_url, "producer": SKILL_NAME}}
280
+ if file_id:
281
+ payload["fileId"] = int(file_id)
282
+ else:
283
+ payload["fileUrl"] = file_url
284
+ if text:
285
+ payload["text"] = text
286
+ if isinstance(duration_sec, (int, float)) and duration_sec > 0:
287
+ payload["segments"] = [
288
+ {"startSec": 0, "endSec": round(float(duration_sec), 2), "text": text, "shot": "screen_record"}
289
+ ]
290
+ headers = _headers(
291
+ private_token,
292
+ content_type="application/json",
293
+ conversation_id=os.environ.get("CONVERSATION_ID") or None,
294
+ )
295
+ req = urllib.request.Request(
296
+ f"{base}/asset/annotate",
297
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
298
+ headers=headers,
299
+ method="POST",
300
+ )
301
+ try:
302
+ with urllib.request.urlopen(req, timeout=request_timeout) as resp:
303
+ result = json.loads(resp.read().decode("utf-8"))
304
+ except (urllib.error.URLError, OSError) as exc:
305
+ raise FinalizeError(f"/asset/annotate 网络错误: {exc}") from exc
306
+ if result.get("code") != 0:
307
+ raise FinalizeError(f"/asset/annotate code={result.get('code')}: {result.get('msg')}")
308
+
309
+
310
+ __all__ = ["finalize_recording", "FinalizeError", "annotate_asset", "storyboard_text"]
@@ -23,7 +23,12 @@ from _media_screenshot import ( # noqa: E402
23
23
  template,
24
24
  trim,
25
25
  )
26
- from _media_upload import finalize_recording # noqa: E402
26
+ from _media_upload import ( # noqa: E402
27
+ FinalizeError,
28
+ annotate_asset,
29
+ finalize_recording,
30
+ storyboard_text,
31
+ )
27
32
 
28
33
 
29
34
  def main() -> None:
@@ -253,6 +258,19 @@ def main() -> None:
253
258
  vod_ref = result.get("vodRef") or ""
254
259
  file_id = result.get("fileId")
255
260
 
261
+ # 语义入库:分镜文本 + 来源页面。失败只提示,不影响录屏结果。
262
+ try:
263
+ annotate_asset(
264
+ private_token=priv_token,
265
+ file_id=file_id,
266
+ file_url=cdn_url,
267
+ text=storyboard_text(story),
268
+ source_url=cfg.get("url", ""),
269
+ duration_sec=duration if isinstance(duration, (int, float)) else None,
270
+ )
271
+ except FinalizeError as e:
272
+ print(f"[record] 录屏语义写入素材库失败(不影响录屏):{e}", file=sys.stderr)
273
+
256
274
  # 上传成功,默认删本地 webm(VOD 已持有),除非 --keep-webm
257
275
  if not args.keep_webm:
258
276
  try:
@@ -2,6 +2,6 @@
2
2
  "skillName": "web-screenshot",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "555",
5
- "version": "V6",
5
+ "version": "V7",
6
6
  "skillDescription": "Web-page screenshot skill (still images only, png/jpg), built on Playwright Python — captures any URL to a local image file.\nSupports: full-page / viewport / element / region capture, device emulation, waiting for dynamic content, hiding or masking distracting elements, and static annotations (highlight box / arrow / caption / numbered sequence / redact / code-line highlight).\n\nImages only (`.png` / `.jpg`). For video (`.webm` / screen recording / storyboard clips) use web-record / `web_record`.\n\nUse this skill immediately whenever the user asks for any of:\n- Web page screenshot, screen capture, full-page screenshot, long screenshot\n- Capture a specific element / region, partial screenshot, focus on an area\n- Hide or mask elements before capturing (cookie banner, sensitive information)\n- Annotate a still screenshot (highlight box / arrow / label / caption bar / numbering)\n- Highlight lines L5-L20 of a file and screenshot it\n- Wait for an element to appear, then capture\n- Mobile / device-emulated screenshot (iPhone / Pixel, etc.)\n- Capture with cookies / a logged-in session / storageState\n\nEven when the user does not say \"playwright\", any request to screenshot a URL and get a local image file should route here."
7
7
  }