@remixmate/cli 0.9.59 → 0.9.61

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.59",
4
- "generatedAt": "2026-09-29T12:43:37.496Z",
3
+ "version": "0.9.61",
4
+ "generatedAt": "2026-09-30T06:42:11.032Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.59",
3
+ "version": "0.9.61",
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",
@@ -728,15 +728,14 @@ def _encode_url(raw_url: str) -> str:
728
728
  return urllib.parse.urlunparse(parsed._replace(path=encoded_path))
729
729
 
730
730
 
731
- def _url_without_query(raw_url: str) -> str:
732
- """日志里只打对象地址:带签名的 query(Signature / OSSAccessKeyId)不进日志。"""
733
- parsed = urllib.parse.urlparse(raw_url)
734
- return urllib.parse.urlunparse(parsed._replace(query="", fragment=""))
731
+ class DraftUploadError(RuntimeError):
732
+ """后处理 ZIP 传回失败。调用方据此降级为交付后端原始 ZIP,而不是整次导出失败。"""
735
733
 
736
734
 
737
735
  def upload_draft_zip(zip_path: str, file_name: str) -> str:
738
736
  """
739
737
  把后处理过的 ZIP 传回 ab-api(POST /file/uploadJianYingDraft),返回带签名的下载地址。
738
+ 失败抛 DraftUploadError(带 HTTP 状态码 / 业务 msg)。
740
739
 
741
740
  为什么必须传回:后处理(字幕样式 / textLayers)是在跑脚本的这台机器上做的。
742
741
  服务端 agent 的本地文件用户拿不到,不传回的话用户下载到的仍是后端生成的原始 ZIP,
@@ -771,28 +770,32 @@ def upload_draft_zip(zip_path: str, file_name: str) -> str:
771
770
  )
772
771
  try:
773
772
  with urllib.request.urlopen(req, timeout=600) as resp:
774
- result = json.loads(resp.read().decode("utf-8"))
773
+ raw = resp.read().decode("utf-8", errors="replace")
775
774
  except urllib.error.HTTPError as e:
776
- print(f"❌ draft upload failed (HTTP {e.code})", file=sys.stderr)
777
- sys.exit(1)
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
778
  except (urllib.error.URLError, OSError, ConnectionError) as e:
779
- print(f"❌ draft upload failed: {e}", file=sys.stderr)
780
- sys.exit(1)
779
+ raise DraftUploadError(f"network error: {e}") from e
781
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
782
785
  if result.get("code") != 0:
783
- print(f"❌ draft upload failed: {result.get('msg', 'unknown error')}", file=sys.stderr)
784
- sys.exit(1)
786
+ raise DraftUploadError(f"API error: {result.get('msg', 'unknown error')}")
785
787
  url = (result.get("data") or {}).get("downloadUrl", "")
786
788
  if not url:
787
- print("❌ draft upload succeeded but no download URL was returned", file=sys.stderr)
788
- sys.exit(1)
789
+ raise DraftUploadError("upload succeeded but no download URL was returned")
789
790
  print(" ✅ uploaded")
790
791
  return url
791
792
 
792
793
 
793
794
  def download_zip(download_url: str, output_path: str) -> None:
794
795
  """下载 ZIP 文件到本地"""
795
- print(f"\n⬇️ Downloading draft ZIP: {_url_without_query(download_url)}")
796
+ # 不打印地址:去掉签名的地址打开就是 403,而模型会把 stdout 里任何像链接的东西
797
+ # 转交给用户(conv_U9oYmHmE1iJbx2qETDuh 就是把这一行当成了下载链接)。
798
+ print("\n⬇️ Downloading draft ZIP for post-processing...")
796
799
  safe_url = _encode_url(download_url)
797
800
  try:
798
801
  urllib.request.urlretrieve(safe_url, output_path)
@@ -1203,11 +1206,19 @@ Scene fields:
1203
1206
  elif args.scenes and conversation_id:
1204
1207
  # 手写 scenes 时模型只能从聊天记录里拼素材 URL,而多轮之间它看不到之前的工具结果,
1205
1208
  # 很容易拼出过期的旧版素材(线上会话里就是把用户改过的 v3 配音换回了 v1)。
1206
- print(
1207
- "⚠️ --scenes was hand-written; omit --scenes/--from-job-id to export the latest prepared "
1208
- "render job of this conversation instead of URLs copied from chat history",
1209
- file=sys.stderr,
1210
- )
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
+ )
1211
1222
 
1212
1223
  # --from-job-id 模式:从数据库加载 RenderPlan
1213
1224
  if args.from_job_id:
@@ -1291,11 +1302,13 @@ Scene fields:
1291
1302
  print(f"\n🎉 Jianying draft generated successfully!")
1292
1303
  print(f" filename: {file_name}")
1293
1304
 
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
1294
1309
  output_path = ""
1295
1310
  if needs_postprocess:
1296
- # 后处理必须先把 ZIP 拿到本地。--no-download 时落到临时文件,传回后删掉,
1297
- # 不在跑脚本的机器(常常是服务器)上留垃圾。
1298
- keep_local = not args.no_download
1311
+ # 后处理必须先把 ZIP 拿到本地;不保留副本时落到临时文件,用完删掉。
1299
1312
  if keep_local:
1300
1313
  output_path = args.output or f"jianying_draft_{int(time.time())}.zip"
1301
1314
  else:
@@ -1306,15 +1319,25 @@ Scene fields:
1306
1319
  postprocess_draft_zip(output_path, scenes)
1307
1320
  # 用户拿到的链接必须指向后处理之后的版本,否则字幕样式 / textLayers 全部丢失。
1308
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)
1309
1330
  finally:
1310
1331
  if not keep_local and os.path.exists(output_path):
1311
1332
  os.remove(output_path)
1312
1333
  output_path = ""
1313
- elif not args.no_download:
1334
+ elif keep_local:
1314
1335
  output_path = args.output or f"jianying_draft_{int(time.time())}.zip"
1315
1336
  download_zip(download_url, output_path)
1316
1337
 
1317
1338
  print_draft_summary(scenes, args.width, args.height, draft_root_path)
1339
+ if needs_postprocess:
1340
+ print(f" subtitle styling applied: {'yes' if styled else 'NO (upload failed, backend draft delivered)'}")
1318
1341
  print(f"\n download URL: {download_url}")
1319
1342
  if output_path:
1320
1343
  print(f" local file: {output_path}")
@@ -2,6 +2,6 @@
2
2
  "skillName": "export-jianying",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "554",
5
- "version": "V6",
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
  }
@@ -2,6 +2,6 @@
2
2
  "skillName": "prepare-video-assets",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "476",
5
- "version": "V6",
5
+ "version": "V7",
6
6
  "skillDescription": "Asset-preparation skill: resolves and generates every asset (image / audio / video) referenced by a Video DSL, persists a RenderPlan to the database, and returns a `job_id` for the subsequent `render_video` call.\n\nUse this skill as soon as the user mentions any of these intents:\n- Generate / prepare video assets, resolve assets, render-ready\n- \"Make me a video about X\" (the agent calls gen_script → prepare_video_assets → render_video)\n- Regenerate one asset (image / audio) for a specific scene\n\nNext step: after the user confirms the resolved assets, call `render_video` with the `job_id` returned by this skill.\n\n⚠️ Stop-and-confirm gate: this skill runs only after the user has confirmed the script, and after it returns you must show the resolved assets and wait for the user's explicit confirmation. Never call `render_video` in the same turn."
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
@@ -2945,6 +2950,23 @@ Examples:
2945
2950
  for asset in image_assets:
2946
2951
  url = asset.get("url", "")
2947
2952
  print(f" {asset['assetId']:<25} {url}")
2953
+
2954
+ # 数字人段也要列出来:模型按这里写成片总结。线上出过 DSL 里没有数字人、
2955
+ # 模型却照着自己的意图汇报「数字人出镜」的事(conv_lvFaCClP7HrW5gDOZU1e),
2956
+ # 没有这一节时它无从核对。没有数字人时明说,而不是什么都不打。
2957
+ avatar_assets = [a for a in render_plan.get("resolvedAssets", []) if a.get("type") == "avatar"]
2958
+ avatar_scenes: dict = {}
2959
+ for entry in render_plan.get("timeline", []):
2960
+ ref = (entry.get("props") or {}).get("avatarAssetId")
2961
+ if ref:
2962
+ avatar_scenes.setdefault(ref, []).append(entry.get("sceneId", "?"))
2963
+ if avatar_assets:
2964
+ print(f"\n🧑 Digital-human segments:")
2965
+ for asset in avatar_assets:
2966
+ scenes_on = ", ".join(avatar_scenes.get(asset["assetId"], [])) or "(no scene)"
2967
+ print(f" {asset['assetId']:<25} {asset.get('status', '?'):<10} scenes: {scenes_on} {asset.get('url', '')}")
2968
+ else:
2969
+ print(f"\n🧑 Digital-human segments: none (this render has no presenter)")
2948
2970
  # ─────────────────────────────────────────────────────────────────────
2949
2971
 
2950
2972
  if failed > 0:
@@ -3064,11 +3086,18 @@ Examples:
3064
3086
 
3065
3087
  upload_title = upload_title_hint
3066
3088
  LogPrint(f"\n📤 Uploading video to OSS...", file=sys.stderr)
3067
- upload_result = upload_media_file(file_path=output_path, title=upload_title)
3089
+ upload_result = upload_media_file(
3090
+ file_path=output_path,
3091
+ title=upload_title,
3092
+ role="final_video",
3093
+ retention="output",
3094
+ )
3068
3095
  manifest["upload"] = {
3069
3096
  "fileUrl": upload_result["fileUrl"],
3070
3097
  "title": upload_title,
3071
3098
  "uploadedAt": now_iso(),
3099
+ # ab-api 保存 manifest 时按 fileId 给成片建语义卡 / 片段 / 血缘
3100
+ "fileId": upload_result.get("fileId"),
3072
3101
  }
3073
3102
  if manifest_path:
3074
3103
  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": "V35",
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
  }
@@ -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
  }