@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.
@@ -0,0 +1,204 @@
1
+ """数字人连续段:分段、驱动音频规划、起播帧注入。
2
+
3
+ 一个 ``source=gen-digital-human`` 的 avatar 资产被**相邻**几个场景的
4
+ ``props.avatarAssetId`` 同时引用 = 一个连续段:生成一段视频,人物跨场景不重启。
5
+ 要让第 k 个场景的第 0 帧正好对上视频里的对应位置,驱动音频必须是「段内各场景
6
+ 旁白、每段补静音到场景最终时长」拼成的一条——所以数字人只能在**排轴之后**生成
7
+ (场景时长由真实 TTS 时长决定,见 render_video.adjust_timeline_to_audio)。
8
+
9
+ 本模块只放纯函数与 ffmpeg 拼接;调度(何时生成、如何回写 RenderPlan 状态)在
10
+ render_video.resolve_digital_human_segments。全部按 RenderPlan 的通用字段工作,
11
+ 不识别具体模板:模板是否支持由 template.json 的 ``capabilities.digitalHuman`` 声明,
12
+ 写没写 avatar 资产由上游决定。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ from typing import Optional
21
+
22
+ DIGITAL_HUMAN_SOURCE = "gen-digital-human"
23
+
24
+ #: 驱动音频下限。飞影要求 5 秒 ~ 30 分钟;不足时在段尾补静音。
25
+ MIN_DRIVE_SEC = 5.0
26
+
27
+ #: 拼接输出的采样率 / 声道。口型驱动不需要立体声,统一成单声道避免混格式失败。
28
+ _SAMPLE_RATE = 44100
29
+
30
+
31
+ def _entry_props(entry: dict) -> dict:
32
+ props = entry.get("props")
33
+ return props if isinstance(props, dict) else {}
34
+
35
+
36
+ def digital_human_asset_ids(render_plan: dict) -> set[str]:
37
+ return {
38
+ a.get("assetId")
39
+ for a in render_plan.get("resolvedAssets", [])
40
+ if a.get("source") == DIGITAL_HUMAN_SOURCE and a.get("assetId")
41
+ }
42
+
43
+
44
+ def collect_segments(render_plan: dict) -> dict[str, list[int]]:
45
+ """avatar 资产 id → 引用它的 timeline 下标(按出场顺序)。
46
+
47
+ 只收 gen-digital-human 资产:``existing`` 的数字人视频是作者自备的,起播点由作者
48
+ 自己负责(``digitalHuman.startFromSec``),管线不去猜。
49
+ """
50
+ dh_ids = digital_human_asset_ids(render_plan)
51
+ segments: dict[str, list[int]] = {}
52
+ for idx, entry in enumerate(render_plan.get("timeline", [])):
53
+ ref = _entry_props(entry).get("avatarAssetId")
54
+ if ref in dh_ids:
55
+ segments.setdefault(ref, []).append(idx)
56
+ return segments
57
+
58
+
59
+ def is_contiguous(indices: list[int]) -> bool:
60
+ return all(b == a + 1 for a, b in zip(indices, indices[1:]))
61
+
62
+
63
+ def segment_role(pos: int, count: int) -> str:
64
+ if count == 1:
65
+ return "only"
66
+ if pos == 0:
67
+ return "first"
68
+ return "last" if pos == count - 1 else "middle"
69
+
70
+
71
+ def inject_segment_props(render_plan: dict, segments: dict[str, list[int]]) -> list[str]:
72
+ """给段内每个场景写 ``avatarOffsetFrames`` / ``avatarSegmentRole``。
73
+
74
+ 偏移用 RenderPlan 里最终的 ``durationFrames`` 累加,不按秒重算,避免累计舍入。
75
+ 返回告警(不相邻的同一资产:偏移仍按出场顺序累加,但中间隔开的场景会让口型错位)。
76
+ """
77
+ warnings: list[str] = []
78
+ timeline = render_plan.get("timeline", [])
79
+ for asset_id, indices in segments.items():
80
+ if not is_contiguous(indices):
81
+ scene_ids = [timeline[i].get("sceneId", str(i)) for i in indices]
82
+ warnings.append(
83
+ f"digital-human asset {asset_id} is referenced by non-adjacent scenes "
84
+ f"{scene_ids}; one video can only span adjacent scenes — lip sync will drift"
85
+ )
86
+ offset = 0
87
+ for pos, idx in enumerate(indices):
88
+ entry = timeline[idx]
89
+ props = entry.setdefault("props", {})
90
+ props["avatarOffsetFrames"] = offset
91
+ props["avatarSegmentRole"] = segment_role(pos, len(indices))
92
+ offset += int(entry.get("durationFrames") or 0)
93
+ return warnings
94
+
95
+
96
+ def plan_drive_pieces(render_plan: dict, indices: list[int]) -> list[dict]:
97
+ """段内每个场景一块:``{"sceneId", "url" | None, "durationSec"}``。
98
+
99
+ ``url`` 取场景旁白资产(``props.narrationAssetId``)已生成的地址;没有旁白的场景
100
+ 是整段静音。块长 = 场景最终时长;总长不足 MIN_DRIVE_SEC 时把最后一块拉长。
101
+ """
102
+ fps = (render_plan.get("renderConfig") or {}).get("fps") or 30
103
+ assets = {a.get("assetId"): a for a in render_plan.get("resolvedAssets", [])}
104
+ timeline = render_plan.get("timeline", [])
105
+ pieces: list[dict] = []
106
+ for idx in indices:
107
+ entry = timeline[idx]
108
+ nar = assets.get(_entry_props(entry).get("narrationAssetId"))
109
+ url = nar.get("url") if nar and nar.get("status") == "generated" else None
110
+ pieces.append({
111
+ "sceneId": entry.get("sceneId", str(idx)),
112
+ "url": url or None,
113
+ "durationSec": round(int(entry.get("durationFrames") or 0) / fps, 3),
114
+ })
115
+ total = sum(p["durationSec"] for p in pieces)
116
+ if pieces and total < MIN_DRIVE_SEC:
117
+ pieces[-1]["durationSec"] = round(pieces[-1]["durationSec"] + MIN_DRIVE_SEC - total, 3)
118
+ return pieces
119
+
120
+
121
+ def direct_drive_url(render_plan: dict, indices: list[int]) -> Optional[str]:
122
+ """单场景段、且旁白本身就够长时,直接拿旁白音频驱动,不必拼接。
123
+
124
+ 视频比场景短几百毫秒(旁白尾巴)无妨:模板默认 ``onEnd: freeze`` 停在最后一帧。
125
+ """
126
+ if len(indices) != 1:
127
+ return None
128
+ entry = render_plan.get("timeline", [])[indices[0]]
129
+ assets = {a.get("assetId"): a for a in render_plan.get("resolvedAssets", [])}
130
+ nar = assets.get(_entry_props(entry).get("narrationAssetId"))
131
+ if not nar or nar.get("status") != "generated" or not nar.get("url"):
132
+ return None
133
+ duration_ms = nar.get("duration") or 0
134
+ if duration_ms and duration_ms / 1000.0 >= MIN_DRIVE_SEC:
135
+ return nar["url"]
136
+ return None
137
+
138
+
139
+ def split_segment_per_scene(render_plan: dict, asset_id: str, indices: list[int]) -> list[str]:
140
+ """把一个连续段拆成每场景一段(拿不到 ffmpeg 拼接时的降级)。
141
+
142
+ 人物会在场景边界重新起播,但每段都能直接用本场景旁白驱动,口型不会错位。
143
+ 新资产 id = ``<原 id>.<sceneId>``;原资产从 resolvedAssets 移除,场景改指新 id。
144
+ """
145
+ assets = render_plan.get("resolvedAssets", [])
146
+ original = next((a for a in assets if a.get("assetId") == asset_id), None)
147
+ if original is None:
148
+ return []
149
+ timeline = render_plan.get("timeline", [])
150
+ new_ids: list[str] = []
151
+ for idx in indices:
152
+ entry = timeline[idx]
153
+ new_id = f"{asset_id}.{entry.get('sceneId', idx)}"
154
+ clone = {**original, "assetId": new_id, "status": "pending", "url": "", "splitFrom": asset_id}
155
+ assets.append(clone)
156
+ entry.setdefault("props", {})["avatarAssetId"] = new_id
157
+ new_ids.append(new_id)
158
+ render_plan["resolvedAssets"] = [a for a in assets if a.get("assetId") != asset_id]
159
+ return new_ids
160
+
161
+
162
+ def build_concat_command(pieces: list[dict], out_path: str, ffmpeg: str = "ffmpeg") -> list[str]:
163
+ """ffmpeg 参数:每块重采样成单声道 → 补静音 → 精确截到块长 → 顺序拼接成 mp3。"""
164
+ cmd = [ffmpeg, "-y", "-loglevel", "error"]
165
+ filters: list[str] = []
166
+ labels: list[str] = []
167
+ for i, piece in enumerate(pieces):
168
+ dur = f"{max(piece['durationSec'], 0.01):.3f}"
169
+ if piece.get("url"):
170
+ cmd += ["-i", piece["url"]]
171
+ else:
172
+ cmd += ["-f", "lavfi", "-t", dur, "-i", f"anullsrc=r={_SAMPLE_RATE}:cl=mono"]
173
+ filters.append(
174
+ f"[{i}:a]aresample={_SAMPLE_RATE},aformat=sample_fmts=fltp:channel_layouts=mono,"
175
+ f"apad,atrim=0:{dur},asetpts=N/SR/TB[a{i}]"
176
+ )
177
+ labels.append(f"[a{i}]")
178
+ filters.append(f"{''.join(labels)}concat=n={len(pieces)}:v=0:a=1[out]")
179
+ cmd += [
180
+ "-filter_complex", ";".join(filters),
181
+ "-map", "[out]",
182
+ "-c:a", "libmp3lame", "-b:a", "128k",
183
+ out_path,
184
+ ]
185
+ return cmd
186
+
187
+
188
+ def find_ffmpeg() -> Optional[str]:
189
+ return shutil.which("ffmpeg")
190
+
191
+
192
+ def concat_drive_audio(pieces: list[dict], out_dir: str, name: str, ffmpeg: str, timeout: int = 300) -> str:
193
+ """拼出驱动音频,返回本地文件路径。失败抛 RuntimeError。"""
194
+ os.makedirs(out_dir, exist_ok=True)
195
+ safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in name)
196
+ out_path = os.path.join(out_dir, f"dh-drive-{safe}.mp3")
197
+ cmd = build_concat_command(pieces, out_path, ffmpeg)
198
+ try:
199
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
200
+ except subprocess.TimeoutExpired as exc:
201
+ raise RuntimeError(f"ffmpeg concat timed out after {timeout}s") from exc
202
+ if result.returncode != 0 or not os.path.exists(out_path):
203
+ raise RuntimeError(f"ffmpeg concat failed: {(result.stderr or '').strip()[:500]}")
204
+ return out_path
@@ -1198,8 +1198,19 @@ ASSET_RESOLVERS = {
1198
1198
  }
1199
1199
 
1200
1200
 
1201
- def _resolve_single_asset(asset: dict, render_plan: dict, private_token: str, max_retries: int, timeout: int) -> bool:
1202
- """Resolve one asset. Returns True if generated, False if failed/skipped."""
1201
+ def _resolve_single_asset(
1202
+ asset: dict,
1203
+ render_plan: dict,
1204
+ private_token: str,
1205
+ max_retries: int,
1206
+ timeout: int,
1207
+ payload_override: Optional[dict] = None,
1208
+ ) -> bool:
1209
+ """Resolve one asset. Returns True if generated, False if failed/skipped.
1210
+
1211
+ ``payload_override`` 整份替换资产 payload,且**不回写 DSL**:数字人连续段的驱动
1212
+ 音频是按本次排轴现拼的,写回 DSL 的话下次改了旁白还会拿旧音频去驱动。
1213
+ """
1203
1214
  source = asset.get("source", "")
1204
1215
  resolver = ASSET_RESOLVERS.get(source)
1205
1216
  if not resolver:
@@ -1218,11 +1229,14 @@ def _resolve_single_asset(asset: dict, render_plan: dict, private_token: str, ma
1218
1229
  break
1219
1230
 
1220
1231
  asset_with_payload = asset.copy()
1221
- if dsl_asset and "payload" in dsl_asset:
1232
+ if payload_override is not None:
1233
+ asset_with_payload["payload"] = dict(payload_override)
1234
+ elif dsl_asset and "payload" in dsl_asset:
1222
1235
  asset_with_payload["payload"] = dsl_asset["payload"]
1223
1236
 
1224
- # For digital-human assets: inject audioUrl from a resolved TTS narration asset
1225
- if source == "gen-digital-human":
1237
+ # Legacy:没走连续段调度(见 resolve_digital_human_segments)的数字人资产,
1238
+ # 沿用「取第一个已生成的旁白」—— 只对全片一个数字人成立。
1239
+ if source == "gen-digital-human" and payload_override is None:
1226
1240
  payload = asset_with_payload.get("payload", {})
1227
1241
  if not payload.get("audioUrl"):
1228
1242
  # Find the first generated TTS audio asset and use its URL
@@ -1319,12 +1333,21 @@ def apply_stub_urls(render_plan: dict, stub_image_url: str = "", stub_video_url:
1319
1333
  return stubbed
1320
1334
 
1321
1335
 
1322
- def resolve_assets(render_plan: dict, private_token: str, max_retries: int, timeout: int) -> dict:
1336
+ def resolve_assets(
1337
+ render_plan: dict,
1338
+ private_token: str,
1339
+ max_retries: int,
1340
+ timeout: int,
1341
+ defer_digital_human: bool = False,
1342
+ ) -> dict:
1323
1343
  """Resolve all pending assets in the render plan.
1324
1344
 
1325
1345
  Two-phase resolution:
1326
1346
  Phase 1: resolve non-avatar assets (TTS, images, video) — **in parallel**
1327
1347
  Phase 2: resolve avatar/digital-human assets which may depend on generated audio URLs — serial
1348
+
1349
+ ``defer_digital_human=True`` 时跳过 Phase 2:场景引用的数字人要等排轴之后按连续段
1350
+ 生成(驱动音频要按场景最终时长补静音),由 resolve_digital_human_segments 接手。
1328
1351
  """
1329
1352
  from concurrent.futures import ThreadPoolExecutor, as_completed
1330
1353
 
@@ -1340,6 +1363,10 @@ def resolve_assets(render_plan: dict, private_token: str, max_retries: int, time
1340
1363
  phase1 = [a for a in pending if a.get("source") != "gen-digital-human"]
1341
1364
  # Phase 2: resolve digital-human assets (can now use generated audio URLs)
1342
1365
  phase2 = [a for a in pending if a.get("source") == "gen-digital-human"]
1366
+ if defer_digital_human:
1367
+ from _digital_human_segments import collect_segments
1368
+ referenced = set(collect_segments(render_plan))
1369
+ phase2 = [a for a in phase2 if a.get("assetId") not in referenced]
1343
1370
 
1344
1371
  generated = 0
1345
1372
  failed = 0
@@ -1390,6 +1417,133 @@ def resolve_assets(render_plan: dict, private_token: str, max_retries: int, time
1390
1417
  return render_plan
1391
1418
 
1392
1419
 
1420
+ def resolve_digital_human_segments(
1421
+ render_plan: dict,
1422
+ private_token: str,
1423
+ max_retries: int,
1424
+ timeout: int,
1425
+ work_dir: Optional[str] = None,
1426
+ ) -> dict:
1427
+ """排轴之后按连续段生成数字人,并给段内场景注入起播帧 / 段角色。
1428
+
1429
+ 必须在 adjust_timeline_to_audio 之后调:驱动音频 = 段内各场景旁白、每段补静音
1430
+ 到该场景**最终**时长,视频第 Σ前序场景帧 帧才正好是本场景第 0 帧。
1431
+
1432
+ - 单场景段且旁白 ≥ 5 秒:直接用旁白驱动,不拼接。
1433
+ - 其余情况用 ffmpeg 拼一条(ab-agent 镜像自带 ffmpeg),上传后驱动。
1434
+ - 没有 ffmpeg:多场景段拆成每场景一段(人物在场景边界重新起播,但口型不错位),
1435
+ 并在 logs 里说明。
1436
+ - 已生成的资产(续跑 / 复用)不重做,只重新注入偏移——偏移只取决于排轴。
1437
+ """
1438
+ import _digital_human_segments as dhs
1439
+
1440
+ segments = dhs.collect_segments(render_plan)
1441
+ if not segments:
1442
+ return render_plan
1443
+
1444
+ assets = {a.get("assetId"): a for a in render_plan.get("resolvedAssets", [])}
1445
+ dsl_payloads = {
1446
+ a.get("assetId"): dict(a.get("payload") or {})
1447
+ for a in render_plan.get("_dsl_assets", [])
1448
+ if a.get("assetId")
1449
+ }
1450
+ ffmpeg = dhs.find_ffmpeg()
1451
+ out_dir = work_dir or os.path.join(OUTPUT_DIR, "digital-human")
1452
+ failed = 0
1453
+
1454
+ def log(message: str, level: str = "info") -> None:
1455
+ render_plan.setdefault("logs", []).append({
1456
+ "phase": "digital-human",
1457
+ "level": level,
1458
+ "message": message,
1459
+ "timestamp": now_iso(),
1460
+ })
1461
+ LogPrint(f" 🧑‍💼 {message}", file=sys.stderr)
1462
+
1463
+ # 先决定每段怎么驱动;没有 ffmpeg 时把多场景段拆开,拆完再统一生成。
1464
+ jobs: list[tuple[str, dict]] = [] # (assetId, payload)
1465
+ for asset_id, indices in list(segments.items()):
1466
+ asset = assets.get(asset_id)
1467
+ if not asset or asset.get("status") != "pending":
1468
+ continue
1469
+ base_payload = dsl_payloads.get(asset_id, {})
1470
+ if base_payload.get("audioUrl"):
1471
+ # 作者显式给了驱动音频:尊重它,不再拼接。
1472
+ jobs.append((asset_id, base_payload))
1473
+ continue
1474
+ if len(indices) > 1 and not ffmpeg:
1475
+ new_ids = dhs.split_segment_per_scene(render_plan, asset_id, indices)
1476
+ log(
1477
+ f"ffmpeg not found; split continuous segment {asset_id} into {len(new_ids)} "
1478
+ "per-scene videos (the presenter restarts at each scene boundary)",
1479
+ level="warn",
1480
+ )
1481
+ timeline = render_plan.get("timeline", [])
1482
+ for idx, new_id in zip(indices, new_ids):
1483
+ if not dhs.plan_drive_pieces(render_plan, [idx])[0].get("url"):
1484
+ # 没有旁白的场景单独成段就没有东西可驱动:这一屏不出人,而不是整段失败。
1485
+ timeline[idx].get("props", {}).pop("avatarAssetId", None)
1486
+ render_plan["resolvedAssets"] = [
1487
+ a for a in render_plan["resolvedAssets"] if a.get("assetId") != new_id
1488
+ ]
1489
+ log(f"{new_id}: scene has no narration to drive a standalone presenter; skipped", level="warn")
1490
+ continue
1491
+ dsl_payloads[new_id] = base_payload
1492
+ jobs.append((new_id, base_payload))
1493
+ continue
1494
+ jobs.append((asset_id, base_payload))
1495
+
1496
+ segments = dhs.collect_segments(render_plan)
1497
+ assets = {a.get("assetId"): a for a in render_plan.get("resolvedAssets", [])}
1498
+
1499
+ for asset_id, base_payload in jobs:
1500
+ asset = assets.get(asset_id)
1501
+ indices = segments.get(asset_id, [])
1502
+ if not asset or not indices:
1503
+ continue
1504
+ payload = dict(base_payload)
1505
+ if not payload.get("audioUrl"):
1506
+ try:
1507
+ url = dhs.direct_drive_url(render_plan, indices)
1508
+ if not url:
1509
+ pieces = dhs.plan_drive_pieces(render_plan, indices)
1510
+ if not any(p.get("url") for p in pieces):
1511
+ raise RuntimeError("no generated narration audio in this segment to drive the presenter")
1512
+ if not ffmpeg:
1513
+ # 单场景但旁白不足 5 秒,又没法补静音:照样提交,由服务端判定。
1514
+ url = next(p["url"] for p in pieces if p.get("url"))
1515
+ log(f"{asset_id}: narration shorter than {dhs.MIN_DRIVE_SEC:.0f}s and ffmpeg not found; submitting unpadded", level="warn")
1516
+ else:
1517
+ local = dhs.concat_drive_audio(pieces, out_dir, asset_id, ffmpeg)
1518
+ from upload_video import upload_media_file
1519
+ url = upload_media_file(local, title=f"digital-human drive {asset_id}")["fileUrl"]
1520
+ total = sum(p["durationSec"] for p in pieces)
1521
+ log(f"{asset_id}: drive audio for {len(pieces)} scene(s), {total:.1f}s")
1522
+ payload["audioUrl"] = url
1523
+ except Exception as exc: # noqa: BLE001 — 任一段失败不拖垮其它段
1524
+ asset["status"] = "failed"
1525
+ failed += 1
1526
+ render_plan.setdefault("errors", []).append({
1527
+ "phase": "digital-human",
1528
+ "message": f"drive audio failed: {exc}"[:1000],
1529
+ "assetId": asset_id,
1530
+ "timestamp": now_iso(),
1531
+ })
1532
+ log(f"{asset_id}: drive audio failed: {exc}", level="error")
1533
+ continue
1534
+ # 口型音轨就是驱动音频,文本 / 音色字段留着会让服务端改走 TTS 驱动。
1535
+ payload.pop("text", None)
1536
+ payload.pop("voiceId", None)
1537
+ if not _resolve_single_asset(asset, render_plan, private_token, max_retries, timeout, payload_override=payload):
1538
+ failed += 1
1539
+
1540
+ for warning in dhs.inject_segment_props(render_plan, dhs.collect_segments(render_plan)):
1541
+ log(warning, level="warn")
1542
+ if failed:
1543
+ render_plan["status"] = "failed"
1544
+ return render_plan
1545
+
1546
+
1393
1547
  def _auto_highlight_map(
1394
1548
  entry: dict,
1395
1549
  narration_asset: Optional[dict],
@@ -2658,15 +2812,24 @@ Examples:
2658
2812
  private_token=private_token,
2659
2813
  max_retries=args.max_asset_retries,
2660
2814
  timeout=args.asset_timeout,
2815
+ defer_digital_human=True,
2661
2816
  )
2662
2817
  else:
2663
2818
  LogPrint(f"✅ all assets ready", file=sys.stderr)
2664
2819
  render_plan["status"] = "assets-ready"
2665
2820
 
2666
- render_plan.pop("_dsl_assets", None)
2667
-
2668
2821
  if not args.skip_asset_resolve:
2669
2822
  render_plan = adjust_timeline_to_audio(render_plan)
2823
+ # 数字人按连续段生成要用到排轴后的场景时长,所以排在 adjust 之后;
2824
+ # 它还要读 DSL 里的资产 payload,_dsl_assets 因此晚一步再丢。
2825
+ render_plan = resolve_digital_human_segments(
2826
+ render_plan,
2827
+ private_token=private_token,
2828
+ max_retries=args.max_asset_retries,
2829
+ timeout=args.asset_timeout,
2830
+ )
2831
+
2832
+ render_plan.pop("_dsl_assets", None)
2670
2833
 
2671
2834
  output_path = os.path.join(OUTPUT_DIR, "video.mp4")
2672
2835
  rp_output = args.render_plan_output or os.path.join(OUTPUT_DIR, "render-plan.json")
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V32",
5
+ "version": "V33",
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
  }