@remixmate/cli 0.9.59 → 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.
- package/dist/manifest.json +2 -2
- package/package.json +1 -1
- package/skills/export-jianying/scripts/gen_jianying_draft.py +46 -23
- package/skills/export-jianying/version.json +1 -1
- package/skills/render-video/scripts/render_video.py +14 -2
- package/skills/render-video/scripts/upload_video.py +45 -13
- package/skills/render-video/version.json +1 -1
- package/skills/video-parser/scripts/deconstruct_video.py +9 -0
- package/skills/video-parser/version.json +1 -1
- package/skills/web-screenshot/scripts/_media_upload.py +78 -1
- package/skills/web-screenshot/scripts/record.py +19 -1
- package/skills/web-screenshot/version.json +1 -1
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remixmate/cli",
|
|
3
|
-
"version": "0.9.
|
|
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",
|
|
@@ -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
|
-
|
|
732
|
-
"""
|
|
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
|
-
|
|
773
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
775
774
|
except urllib.error.HTTPError as e:
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
|
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
|
|
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": "
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
53
|
-
|
|
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(
|
|
58
|
+
parts.append(f'Content-Disposition: form-data; name="{name}"'.encode("utf-8"))
|
|
56
59
|
parts.append(b"")
|
|
57
|
-
parts.append(
|
|
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
|
|
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(
|
|
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": "
|
|
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
|
}
|
|
@@ -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": "
|
|
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
|
-
|
|
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
|
|
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": "
|
|
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
|
}
|