@remixmate/cli 0.9.57 → 0.9.59
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 +21 -17
- package/package.json +1 -1
- package/skills/export-jianying/SKILL.md +48 -32
- package/skills/export-jianying/scripts/gen_jianying_draft.py +281 -51
- package/skills/export-jianying/skill.json +17 -13
- package/skills/export-jianying/version.json +1 -1
- package/skills/gen-digital-human/SKILL.md +8 -3
- package/skills/gen-digital-human/skill.json +2 -2
- package/skills/gen-digital-human/version.json +1 -1
- package/skills/prepare-video-assets/version.json +1 -1
- package/skills/render-video/scripts/_digital_human_segments.py +204 -0
- package/skills/render-video/scripts/render_video.py +171 -8
- package/skills/render-video/version.json +1 -1
- package/skills/template-registry/scripts/render_job_client.py +17 -0
- package/skills/template-registry/version.json +1 -1
|
@@ -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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
90
|
-
2. --system
|
|
124
|
+
1. 显式 --draft-root-path:原样使用(不做 expanduser),但必须是绝对路径;
|
|
125
|
+
2. 未给路径且脚本就跑在 --system 指定的系统上:按本机 HOME / LOCALAPPDATA 推出;
|
|
126
|
+
3. 其余情况(典型是服务端 agent):报错,要求调用方向用户要路径。
|
|
91
127
|
返回值始终作为 draftRootPath 传给 API。
|
|
92
128
|
"""
|
|
93
|
-
if
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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,77 @@ 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
|
-
|
|
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
|
+
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=""))
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def upload_draft_zip(zip_path: str, file_name: str) -> str:
|
|
738
|
+
"""
|
|
739
|
+
把后处理过的 ZIP 传回 ab-api(POST /file/uploadJianYingDraft),返回带签名的下载地址。
|
|
740
|
+
|
|
741
|
+
为什么必须传回:后处理(字幕样式 / textLayers)是在跑脚本的这台机器上做的。
|
|
742
|
+
服务端 agent 的本地文件用户拿不到,不传回的话用户下载到的仍是后端生成的原始 ZIP,
|
|
743
|
+
后处理等于白做。
|
|
744
|
+
"""
|
|
745
|
+
boundary = "----RemixmateJianYing" + uuid.uuid4().hex
|
|
746
|
+
with open(zip_path, "rb") as f:
|
|
747
|
+
zip_data = f.read()
|
|
748
|
+
# filename 里放 ASCII 占位,真实(可能含中文的)文件名走独立的 fileName 字段,
|
|
749
|
+
# 避免 multipart 头部编码在不同服务端实现间的差异。
|
|
750
|
+
parts = [
|
|
751
|
+
f"--{boundary}".encode(),
|
|
752
|
+
b'Content-Disposition: form-data; name="fileName"',
|
|
753
|
+
b"",
|
|
754
|
+
file_name.encode("utf-8"),
|
|
755
|
+
f"--{boundary}".encode(),
|
|
756
|
+
b'Content-Disposition: form-data; name="file"; filename="draft.zip"',
|
|
757
|
+
b"Content-Type: application/zip",
|
|
758
|
+
b"",
|
|
759
|
+
zip_data,
|
|
760
|
+
f"--{boundary}--".encode(),
|
|
761
|
+
b"",
|
|
762
|
+
]
|
|
763
|
+
body = b"\r\n".join(parts)
|
|
764
|
+
|
|
765
|
+
print(f"\n⬆️ Uploading post-processed draft ({len(zip_data) / 1024 / 1024:.1f} MB)...")
|
|
766
|
+
req = urllib.request.Request(
|
|
767
|
+
f"{API_BASE_URL}/file/uploadJianYingDraft",
|
|
768
|
+
data=body,
|
|
769
|
+
headers=_build_headers(f"multipart/form-data; boundary={boundary}"),
|
|
770
|
+
method="POST",
|
|
771
|
+
)
|
|
772
|
+
try:
|
|
773
|
+
with urllib.request.urlopen(req, timeout=600) as resp:
|
|
774
|
+
result = json.loads(resp.read().decode("utf-8"))
|
|
775
|
+
except urllib.error.HTTPError as e:
|
|
776
|
+
print(f"❌ draft upload failed (HTTP {e.code})", file=sys.stderr)
|
|
777
|
+
sys.exit(1)
|
|
778
|
+
except (urllib.error.URLError, OSError, ConnectionError) as e:
|
|
779
|
+
print(f"❌ draft upload failed: {e}", file=sys.stderr)
|
|
780
|
+
sys.exit(1)
|
|
781
|
+
|
|
782
|
+
if result.get("code") != 0:
|
|
783
|
+
print(f"❌ draft upload failed: {result.get('msg', 'unknown error')}", file=sys.stderr)
|
|
784
|
+
sys.exit(1)
|
|
785
|
+
url = (result.get("data") or {}).get("downloadUrl", "")
|
|
786
|
+
if not url:
|
|
787
|
+
print("❌ draft upload succeeded but no download URL was returned", file=sys.stderr)
|
|
788
|
+
sys.exit(1)
|
|
789
|
+
print(" ✅ uploaded")
|
|
790
|
+
return url
|
|
791
|
+
|
|
792
|
+
|
|
633
793
|
def download_zip(download_url: str, output_path: str) -> None:
|
|
634
794
|
"""下载 ZIP 文件到本地"""
|
|
635
|
-
print(f"\n⬇️ Downloading draft ZIP: {download_url
|
|
795
|
+
print(f"\n⬇️ Downloading draft ZIP: {_url_without_query(download_url)}")
|
|
636
796
|
safe_url = _encode_url(download_url)
|
|
637
797
|
try:
|
|
638
798
|
urllib.request.urlretrieve(safe_url, output_path)
|
|
@@ -906,6 +1066,39 @@ def has_text_layers(scenes: list) -> bool:
|
|
|
906
1066
|
return any(scene.get("textLayers") for scene in scenes)
|
|
907
1067
|
|
|
908
1068
|
|
|
1069
|
+
def _render_job_client():
|
|
1070
|
+
"""
|
|
1071
|
+
导入共享的 render_job_client(在 skills/template-registry/scripts/,跨 skill 共享
|
|
1072
|
+
Python 的约定位置,render-video 也是这么引的)。
|
|
1073
|
+
"""
|
|
1074
|
+
shared = os.path.join(os.path.dirname(__file__), "..", "..", "template-registry", "scripts")
|
|
1075
|
+
if shared not in sys.path:
|
|
1076
|
+
sys.path.insert(0, shared)
|
|
1077
|
+
import render_job_client # noqa: PLC0415 — 路径注入之后才能导入
|
|
1078
|
+
|
|
1079
|
+
return render_job_client
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def print_draft_summary(scenes: list, width: int, height: int, draft_root_path: str) -> None:
|
|
1083
|
+
"""
|
|
1084
|
+
打印草稿里**实际**装了什么。
|
|
1085
|
+
|
|
1086
|
+
调用方(agent)要据此向用户转述,而不是凭自己传了什么去猜——线上会话里模型把
|
|
1087
|
+
被丢掉的字幕也说成「已包含」。
|
|
1088
|
+
"""
|
|
1089
|
+
total = len(scenes)
|
|
1090
|
+
video = sum(1 for s in scenes if s.get("videoUrl"))
|
|
1091
|
+
image = sum(1 for s in scenes if s.get("imageUrl") and not s.get("videoUrl"))
|
|
1092
|
+
audio = sum(1 for s in scenes if s.get("audioUrl"))
|
|
1093
|
+
subtitle = sum(1 for s in scenes if s.get("subtitleText") or s.get("textLayers"))
|
|
1094
|
+
duration = sum(float(s.get("duration") or 0) for s in scenes)
|
|
1095
|
+
print("\n📦 Draft contents:")
|
|
1096
|
+
print(f" scenes: {total} (video {video}, image {image}), total {duration:.1f}s, canvas {width}x{height}")
|
|
1097
|
+
print(f" narration audio: {audio}/{total} scene(s)")
|
|
1098
|
+
print(f" subtitles / text layers: {subtitle}/{total} scene(s)")
|
|
1099
|
+
print(f" draft root written into the draft: {draft_root_path}")
|
|
1100
|
+
|
|
1101
|
+
|
|
909
1102
|
def main() -> None:
|
|
910
1103
|
parser = argparse.ArgumentParser(
|
|
911
1104
|
description="Jianying draft generator — packages asset URLs into a Jianying-importable draft ZIP",
|
|
@@ -941,7 +1134,10 @@ Scene fields:
|
|
|
941
1134
|
"--from-job-id",
|
|
942
1135
|
type=int,
|
|
943
1136
|
default=0,
|
|
944
|
-
help=
|
|
1137
|
+
help=(
|
|
1138
|
+
"Load the RenderPlan from the database (pass a jobId; requires PRIV_TOKEN). When no source is given "
|
|
1139
|
+
"and CONVERSATION_ID is set, the latest prepared job of that conversation is used automatically"
|
|
1140
|
+
),
|
|
945
1141
|
)
|
|
946
1142
|
parser.add_argument("--width", type=int, default=1080, help="Canvas width (default 1080)")
|
|
947
1143
|
parser.add_argument("--height", type=int, default=1920, help="Canvas height (default 1920)")
|
|
@@ -950,17 +1146,24 @@ Scene fields:
|
|
|
950
1146
|
"--system",
|
|
951
1147
|
choices=["mac", "windows"],
|
|
952
1148
|
default="mac",
|
|
953
|
-
help="
|
|
1149
|
+
help="OS of the computer that will open the draft (default: mac)",
|
|
954
1150
|
)
|
|
955
1151
|
parser.add_argument(
|
|
956
1152
|
"--draft-root-path",
|
|
957
1153
|
default="",
|
|
958
|
-
help=
|
|
1154
|
+
help=(
|
|
1155
|
+
"Absolute Jianying draft root on the user's computer, e.g. "
|
|
1156
|
+
f"{DRAFT_ROOT_TEMPLATES['mac']} or {DRAFT_ROOT_TEMPLATES['windows']}. "
|
|
1157
|
+
"Required unless this script runs on that computer itself; '~' and %%VARS%% are rejected"
|
|
1158
|
+
),
|
|
959
1159
|
)
|
|
960
1160
|
parser.add_argument(
|
|
961
1161
|
"--no-download",
|
|
962
1162
|
action="store_true",
|
|
963
|
-
help=
|
|
1163
|
+
help=(
|
|
1164
|
+
"Do not keep a local copy of the ZIP; only print the download URL. When subtitles / textLayers "
|
|
1165
|
+
"need post-processing the ZIP is fetched to a temp file, styled, uploaded back and the temp file removed"
|
|
1166
|
+
),
|
|
964
1167
|
)
|
|
965
1168
|
parser.add_argument("-o", "--output", default=None, help="Download path (default jianying_draft_{timestamp}.zip, prevents multi-user collisions)")
|
|
966
1169
|
parser.add_argument("--poll-interval", type=int, default=5, help="Polling interval (seconds, default 5)")
|
|
@@ -980,22 +1183,38 @@ Scene fields:
|
|
|
980
1183
|
print("❌ PrivToken not configured", file=sys.stderr)
|
|
981
1184
|
sys.exit(1)
|
|
982
1185
|
|
|
1186
|
+
conversation_id = os.environ.get("CONVERSATION_ID", "").strip()
|
|
1187
|
+
|
|
1188
|
+
# 没给任何素材来源、但处在一个会话里(ab-agent 会注入 CONVERSATION_ID):
|
|
1189
|
+
# 默认导出这个会话最近一版已准备好素材的任务。
|
|
1190
|
+
if not (args.from_job_id or args.from_render_plan or args.scenes) and conversation_id:
|
|
1191
|
+
rjc = _render_job_client()
|
|
1192
|
+
try:
|
|
1193
|
+
latest = rjc.latest_job(conversation_id, PRIVATE_TOKEN)
|
|
1194
|
+
except RuntimeError as e:
|
|
1195
|
+
print(f"❌ could not find the latest prepared render job of this conversation: {e}", file=sys.stderr)
|
|
1196
|
+
print(" run prepare_video_assets first, or pass --from-job-id / --scenes", file=sys.stderr)
|
|
1197
|
+
sys.exit(1)
|
|
1198
|
+
args.from_job_id = int(latest["jobId"])
|
|
1199
|
+
print(
|
|
1200
|
+
f"📋 using the latest prepared render job of this conversation: "
|
|
1201
|
+
f"jobId={args.from_job_id} (attempt {latest.get('attempt', '?')}, updated {latest.get('updateTime', '?')})"
|
|
1202
|
+
)
|
|
1203
|
+
elif args.scenes and conversation_id:
|
|
1204
|
+
# 手写 scenes 时模型只能从聊天记录里拼素材 URL,而多轮之间它看不到之前的工具结果,
|
|
1205
|
+
# 很容易拼出过期的旧版素材(线上会话里就是把用户改过的 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
|
+
)
|
|
1211
|
+
|
|
983
1212
|
# --from-job-id 模式:从数据库加载 RenderPlan
|
|
984
1213
|
if args.from_job_id:
|
|
985
1214
|
print(f"📋 loading RenderPlan from database (jobId={args.from_job_id})...")
|
|
986
|
-
|
|
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
|
|
1215
|
+
rjc = _render_job_client()
|
|
997
1216
|
try:
|
|
998
|
-
render_plan_str =
|
|
1217
|
+
render_plan_str = rjc.get_plan(args.from_job_id, PRIVATE_TOKEN)
|
|
999
1218
|
except RuntimeError as e:
|
|
1000
1219
|
print(f"❌ failed to fetch RenderPlan: {e}", file=sys.stderr)
|
|
1001
1220
|
sys.exit(1)
|
|
@@ -1029,7 +1248,7 @@ Scene fields:
|
|
|
1029
1248
|
else:
|
|
1030
1249
|
# 传统模式:必须提供 --scenes 和 --title
|
|
1031
1250
|
if not args.scenes:
|
|
1032
|
-
print("❌ provide --
|
|
1251
|
+
print("❌ provide --from-job-id, --from-render-plan or --scenes", file=sys.stderr)
|
|
1033
1252
|
parser.print_help()
|
|
1034
1253
|
sys.exit(1)
|
|
1035
1254
|
if not args.title:
|
|
@@ -1063,34 +1282,45 @@ Scene fields:
|
|
|
1063
1282
|
result_data = poll_task_status(task_id, args.poll_interval, args.max_wait)
|
|
1064
1283
|
|
|
1065
1284
|
download_url = result_data.get("downloadUrl", "")
|
|
1066
|
-
file_name = result_data.get("fileName", "")
|
|
1285
|
+
file_name = result_data.get("fileName", "") or "jianying_draft.zip"
|
|
1067
1286
|
|
|
1068
1287
|
if not download_url:
|
|
1069
1288
|
print("❌ generation finished but no download URL was returned", file=sys.stderr)
|
|
1070
1289
|
sys.exit(1)
|
|
1071
1290
|
|
|
1072
1291
|
print(f"\n🎉 Jianying draft generated successfully!")
|
|
1073
|
-
|
|
1074
|
-
print(f" filename: {file_name}")
|
|
1075
|
-
print(f" download URL: {download_url}")
|
|
1076
|
-
|
|
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
|
|
1292
|
+
print(f" filename: {file_name}")
|
|
1081
1293
|
|
|
1082
|
-
|
|
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 字幕样式
|
|
1294
|
+
output_path = ""
|
|
1089
1295
|
if needs_postprocess:
|
|
1090
|
-
|
|
1296
|
+
# 后处理必须先把 ZIP 拿到本地。--no-download 时落到临时文件,传回后删掉,
|
|
1297
|
+
# 不在跑脚本的机器(常常是服务器)上留垃圾。
|
|
1298
|
+
keep_local = not args.no_download
|
|
1299
|
+
if keep_local:
|
|
1300
|
+
output_path = args.output or f"jianying_draft_{int(time.time())}.zip"
|
|
1301
|
+
else:
|
|
1302
|
+
fd, output_path = tempfile.mkstemp(prefix="jianying_draft_", suffix=".zip")
|
|
1303
|
+
os.close(fd)
|
|
1304
|
+
try:
|
|
1305
|
+
download_zip(download_url, output_path)
|
|
1306
|
+
postprocess_draft_zip(output_path, scenes)
|
|
1307
|
+
# 用户拿到的链接必须指向后处理之后的版本,否则字幕样式 / textLayers 全部丢失。
|
|
1308
|
+
download_url = upload_draft_zip(output_path, file_name)
|
|
1309
|
+
finally:
|
|
1310
|
+
if not keep_local and os.path.exists(output_path):
|
|
1311
|
+
os.remove(output_path)
|
|
1312
|
+
output_path = ""
|
|
1313
|
+
elif not args.no_download:
|
|
1314
|
+
output_path = args.output or f"jianying_draft_{int(time.time())}.zip"
|
|
1315
|
+
download_zip(download_url, output_path)
|
|
1316
|
+
|
|
1317
|
+
print_draft_summary(scenes, args.width, args.height, draft_root_path)
|
|
1318
|
+
print(f"\n download URL: {download_url}")
|
|
1319
|
+
if output_path:
|
|
1320
|
+
print(f" local file: {output_path}")
|
|
1091
1321
|
|
|
1092
1322
|
print(f"\n💡 How to import into Jianying:")
|
|
1093
|
-
print(f" 1.
|
|
1323
|
+
print(f" 1. Download and extract the ZIP into exactly this directory on the user's computer:")
|
|
1094
1324
|
print(f" {draft_root_path}")
|
|
1095
1325
|
print(f" 2. Start or restart Jianying; the draft will appear in your project list")
|
|
1096
1326
|
|
|
@@ -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.
|
|
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 (
|
|
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": "
|
|
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": "
|
|
48
|
+
"description": "OS of the computer that will open the draft"
|
|
45
49
|
},
|
|
46
50
|
"draft_root_path": {
|
|
47
51
|
"type": "string",
|
|
48
|
-
"description": "
|
|
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
|
|
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,18 +64,18 @@
|
|
|
60
64
|
},
|
|
61
65
|
"ui": {
|
|
62
66
|
"primary": [
|
|
63
|
-
"title"
|
|
64
|
-
],
|
|
65
|
-
"advanced": [
|
|
66
67
|
"system",
|
|
67
|
-
"
|
|
68
|
-
"height",
|
|
69
|
-
"output"
|
|
68
|
+
"draft_root_path"
|
|
70
69
|
],
|
|
70
|
+
"advanced": [],
|
|
71
71
|
"hidden": [
|
|
72
|
+
"title",
|
|
73
|
+
"width",
|
|
74
|
+
"height",
|
|
75
|
+
"output",
|
|
76
|
+
"from_job_id",
|
|
72
77
|
"from_render_plan",
|
|
73
78
|
"scenes",
|
|
74
|
-
"draft_root_path",
|
|
75
79
|
"no_download"
|
|
76
80
|
]
|
|
77
81
|
}
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "export-jianying",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "554",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V6",
|
|
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
|
}
|
|
@@ -29,7 +29,7 @@ Wraps ab-api's digital-human endpoints (the same ones the web studio uses), auth
|
|
|
29
29
|
| Text-driven | avatar + text + voice → speech is synthesized, then drives the avatar | `POST /digital-human/video/generate` |
|
|
30
30
|
| Audio-driven | avatar + existing audio URL → talking-head video | `POST /digital-human/video/generate` |
|
|
31
31
|
| Check status | Poll a generation job | `POST /digital-human/video/status` |
|
|
32
|
-
| Clone avatar | Custom avatar from a video (free) or portrait photo (
|
|
32
|
+
| Clone avatar | Custom avatar from a video (free) or portrait photo (charged per clone, refunded on failure) | `POST /digital-human/avatar/clone` |
|
|
33
33
|
| Clone status | `cloning` / `done` / `failed` for a cloned avatar | `POST /digital-human/avatar/clone-status` |
|
|
34
34
|
|
|
35
35
|
Avatar ids are opaque strings (e.g. `dh_xxx`) — always copy them from `--list-avatars`, never invent them.
|
|
@@ -88,7 +88,7 @@ The source must be a public http(s) URL (local files are not uploaded). Video: m
|
|
|
88
88
|
# Video clone (free)
|
|
89
89
|
remixmate gen-digital-human --clone --name "My presenter" --clone-url "https://example.com/me.mp4"
|
|
90
90
|
|
|
91
|
-
# Portrait photo clone (
|
|
91
|
+
# Portrait photo clone (charged per clone)
|
|
92
92
|
remixmate gen-digital-human --clone --clone-type image --name "My presenter" --clone-url "https://example.com/me.jpg"
|
|
93
93
|
|
|
94
94
|
# Submit without waiting, then poll
|
|
@@ -119,7 +119,12 @@ remixmate gen-digital-human --clone-status --avatar-id dh_xxx
|
|
|
119
119
|
|
|
120
120
|
## Credits
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
Photo cloning and video generation charge credits (rates on https://www.remixmate.ai/pricing):
|
|
123
|
+
|
|
124
|
+
- **Clone**: video clones are free. Photo clones cost a flat per-clone fee, charged at submission and refunded automatically if cloning fails.
|
|
125
|
+
- **Video**: per second of output video (5 s minimum), charged when the video completes. The balance is checked before submission; too low → `insufficient_credits`, nothing is generated.
|
|
126
|
+
|
|
127
|
+
The CLI prints a footer on stdout when a charge is reported in the response:
|
|
123
128
|
|
|
124
129
|
```
|
|
125
130
|
💳 Charged 31 credits · balance 1,240
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"tier": "atomic",
|
|
5
5
|
"category": "asset",
|
|
6
6
|
"title": "Digital-Human Talking-Head",
|
|
7
|
-
"description": "Digital-human video (HiFly): list or clone avatars; produce a talking-head video from text via TTS, or drive an avatar from an existing audio URL. Aspect ratio follows the avatar's cloned source and cannot be chosen.",
|
|
7
|
+
"description": "Digital-human video (HiFly): list or clone avatars; produce a talking-head video from text via TTS, or drive an avatar from an existing audio URL. Aspect ratio follows the avatar's cloned source and cannot be chosen. Talking-head videos cost credits per second of output (5 s minimum); fails with insufficient_credits when the balance is too low.",
|
|
8
8
|
"auth": "required",
|
|
9
9
|
"joinsTake": true,
|
|
10
10
|
"envVars": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"clone": {
|
|
47
47
|
"type": "boolean",
|
|
48
|
-
"description": "Clone mode: create a custom HiFly avatar from a video (free) or a portrait image (costs
|
|
48
|
+
"description": "Clone mode: create a custom HiFly avatar from a video (free) or a portrait image (costs credits per clone, refunded automatically if cloning fails; see the pricing page). Requires clone_url and name. Waits until cloning finishes unless wait=false."
|
|
49
49
|
},
|
|
50
50
|
"clone_url": {
|
|
51
51
|
"type": "string",
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "gen-digital-human",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "336",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V10",
|
|
6
6
|
"skillDescription": "Digital-human (talking-head) skill backed by HiFly: list avatars, clone a custom avatar from a video or photo, drive an avatar with text (TTS) or an existing audio URL, and check the status of pending jobs.\n\nUse this skill immediately whenever the user asks for any of:\n- Digital human, talking-head video, AI presenter, virtual host\n- \"Make a talking-head video out of this script / this audio file\"\n- List or browse digital-human avatars\n- Clone / create a custom avatar from a video or photo\n\nEven without an explicit \"use AI\", any request that turns text or audio into a synthesized presenter video should route here."
|
|
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": "
|
|
5
|
+
"version": "V6",
|
|
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
|
}
|