@remixmate/cli 0.9.45 → 0.9.47

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.45",
4
- "generatedAt": "2026-09-23T05:34:40.922Z",
3
+ "version": "0.9.47",
4
+ "generatedAt": "2026-09-24T01:36:07.207Z",
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.45",
3
+ "version": "0.9.47",
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",
@@ -40,7 +40,8 @@
40
40
  "test:countdown": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-countdown-strategy.py",
41
41
  "test:prepare-contracts": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-prepare-contracts.py",
42
42
  "test:image-sequence": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-image-sequence.py",
43
- "test:ranking-count": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-ranking-count.py"
43
+ "test:ranking-count": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-ranking-count.py",
44
+ "test:image-prompt-compose": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-image-prompt-compose.py"
44
45
  },
45
46
  "keywords": [
46
47
  "claude-code",
@@ -60,10 +60,17 @@ except ImportError:
60
60
  _HAS_COUNTDOWN = False
61
61
 
62
62
  try:
63
- from video_dsl.runtime.prompt_enhancer import build_enhanced_prompt
63
+ from video_dsl.runtime.prompt_enhancer import (
64
+ COMPOSE_SUBJECT_PLACEHOLDER,
65
+ SKELETON_PREFIXES,
66
+ build_enhanced_prompt,
67
+ is_compose_mode,
68
+ resolve_style_guide,
69
+ )
64
70
  _HAS_ENHANCER = True
65
71
  except ImportError:
66
72
  _HAS_ENHANCER = False
73
+ SKELETON_PREFIXES = ("[骨架待填充]", "[skeleton placeholder]")
67
74
 
68
75
  # 场景数上限的唯一实现在 dsl_validator(它同时是门禁方)。守护式导入的理由同上:
69
76
  # 共享目录可能不在(单测 / 裁剪安装),那时退回历史常量 20,行为与改动前一致。
@@ -188,11 +195,10 @@ def _check_narration_language(narrations, expected):
188
195
  """
189
196
  cjk_re = __import__("re").compile(r"[\u4e00-\u9fff]")
190
197
  latin_re = __import__("re").compile(r"[A-Za-z]")
191
- skeleton_prefixes = (NARRATION_SKELETON_MARKER, "[skeleton placeholder]")
192
198
  for text in narrations:
193
199
  if not isinstance(text, str) or not text.strip():
194
200
  continue
195
- if text.lstrip().startswith(skeleton_prefixes):
201
+ if text.lstrip().startswith(SKELETON_PREFIXES):
196
202
  continue
197
203
  has_cjk = bool(cjk_re.search(text))
198
204
  has_latin = bool(latin_re.search(text))
@@ -328,7 +334,11 @@ def plan_scenes(
328
334
  opening_duration = max(3, round(duration * 0.12))
329
335
  ending_duration = max(3, round(duration * 0.12))
330
336
  content_total = duration - opening_duration - ending_duration
331
- per_content = max(3, round(content_total / content_scenes))
337
+ # 余数摊到前几段,保证各段加总正好等于 duration。以前是 round(均值) × 段数:
338
+ # 2026-09-23 线上 ppt-to-video 传 --duration 600 --scenes 23,21 段 × round(21.7)=22
339
+ # 加总 606s,撞上模板 supportedDurations.max=600 被自己的时长门禁拒掉。
340
+ # 每段仍有 3s 下限 —— 段数多到摊不开时加总会超出,那是真超出,交给门禁报。
341
+ base_content, extra_content = divmod(max(content_total, 0), content_scenes)
332
342
 
333
343
  scenes.append({
334
344
  "purpose": "opening",
@@ -339,7 +349,7 @@ def plan_scenes(
339
349
  for i in range(content_scenes):
340
350
  scenes.append({
341
351
  "purpose": "point",
342
- "duration": per_content,
352
+ "duration": max(3, base_content + (1 if i < extra_content else 0)),
343
353
  "label": _localize_label(language, "label_point", n=i + 1),
344
354
  })
345
355
 
@@ -373,7 +383,7 @@ def estimate_narration_text(label: str, purpose: str, duration: int, topic: str,
373
383
 
374
384
 
375
385
  # 骨架标记:出现此前缀说明 narration 仍是 gen_script 原始骨架,未被 agent 填充真实内容
376
- NARRATION_SKELETON_MARKER = "[骨架待填充]"
386
+ NARRATION_SKELETON_MARKER = SKELETON_PREFIXES[0]
377
387
 
378
388
 
379
389
  def generate_image_prompt(
@@ -617,6 +627,7 @@ def _resolve_contract(template_config: dict | None) -> dict:
617
627
  "payload_defaults": payload_defaults,
618
628
  "primary_visual_type": primary_visual_type,
619
629
  "default_layout": caps.get("defaultLayout"),
630
+ "authored_scenes": caps.get("authoredScenes") is True,
620
631
  }
621
632
 
622
633
 
@@ -791,6 +802,16 @@ def _plan_contract_scenes(
791
802
 
792
803
  fixed = contract["fixed_scenes"] or [{"purpose": "opening"}]
793
804
  n = len(fixed)
805
+ # 非 arc 策略的场景数由模板钉死,--scenes 不生效 —— 但必须出声。2026-09-23 线上
806
+ # screen-walkthrough 传 --scenes 6 拿回 2 场,没有任何提示,模型只能自己猜为什么。
807
+ if scene_count is not None and scene_count != n:
808
+ print(
809
+ f"⚠️ requested {scene_count} scene(s) but this template's sceneStrategy="
810
+ f"{contract['scene_strategy']} always plans {n}: --scenes is ignored here.\n"
811
+ f" The DSL below has {n} scenes — read the count back from it before showing "
812
+ f"the script to the user.",
813
+ file=sys.stderr,
814
+ )
794
815
  per = max(1, round(duration / n))
795
816
  plans = []
796
817
  for i, fs in enumerate(fixed):
@@ -1441,6 +1462,13 @@ def build_dsl(
1441
1462
  assets = []
1442
1463
  scenes = []
1443
1464
 
1465
+ # 模板的生图指南。compose 模式(imageStyleGuide.mode == "compose")下骨架里的
1466
+ # payload.prompt 只是「画面主体」占位 —— 画风 / 配色 / 负向词由 render_video 在旁白
1467
+ # 定稿后统一拼上,这里若先拼一份完整 prompt,agent 会以为写好了,或在它上面叠写。
1468
+ image_style_guide = resolve_style_guide(template_config) if _HAS_ENHANCER else {}
1469
+ image_compose = _HAS_ENHANCER and is_compose_mode(image_style_guide)
1470
+ image_ratio = image_style_guide.get("ratio") or ratio
1471
+
1444
1472
  for idx, plan in enumerate(scene_plans):
1445
1473
  scene_id = f"scene-{idx + 1:02d}"
1446
1474
  # 视觉素材的 assetId 命名遵循类型前缀:image → img-,video → video-
@@ -1480,7 +1508,13 @@ def build_dsl(
1480
1508
  "url": stub_image_url,
1481
1509
  })
1482
1510
  else:
1483
- image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)
1511
+ if image_compose:
1512
+ image_result = {"prompt": COMPOSE_SUBJECT_PLACEHOLDER}
1513
+ else:
1514
+ image_result = generate_image_prompt(
1515
+ plan["purpose"], topic, style,
1516
+ narration_text=narration_text, style_guide=image_style_guide,
1517
+ )
1484
1518
 
1485
1519
  # 用户在 AI 段里写了提示词就用他的。**只替换主体,不动 negativePrompt /
1486
1520
  # guidanceScale**:那两项是模板的护栏("不要文字水印、不要变形"),把它们
@@ -1492,7 +1526,7 @@ def build_dsl(
1492
1526
  img_payload = {
1493
1527
  "prompt": image_result["prompt"],
1494
1528
  "model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
1495
- "ratio": ratio,
1529
+ "ratio": image_ratio,
1496
1530
  }
1497
1531
  if "negativePrompt" in image_result:
1498
1532
  img_payload["negativePrompt"] = image_result["negativePrompt"]
@@ -1687,6 +1721,8 @@ def build_dsl(
1687
1721
  "outputLanguage": output_language,
1688
1722
  "style": style or "",
1689
1723
  "description": _localize_label(output_language, "video_description", topic=topic, duration=duration),
1724
+ # compose 模式:留一个空位让 agent 写全书共用的角色外观设定,每张图都会逐字注入
1725
+ **({"characterSheet": ""} if image_compose and has_visual else {}),
1690
1726
  },
1691
1727
  "global": {
1692
1728
  "aspectRatio": ratio,
@@ -1709,6 +1745,20 @@ def build_dsl(
1709
1745
 
1710
1746
  _record_source_document(dsl, template_config, template_id, source_document_url)
1711
1747
 
1748
+ if contract["authored_scenes"]:
1749
+ # 场景表由调用方按用户素材编写(如按操作时间点切录屏),骨架只提供 meta/global。
1750
+ # 这个标记必须由骨架携带:ab-agent 看到它才会整份采用模型提交的 DSL,
1751
+ # 否则它以骨架为基底合并,新增场景、existing 素材、visuals 都会被静默丢掉。
1752
+ dsl["meta"]["authoredScenes"] = True
1753
+ print(
1754
+ f"ℹ️ template '{template_id}' declares capabilities.authoredScenes: the scene list below "
1755
+ f"is only a placeholder.\n"
1756
+ f" Write the full DSL yourself from the user's material (scenes, assets, visuals, "
1757
+ f"customPayload) and submit all of it to prepare_video_assets — it is used as-is, "
1758
+ f"not merged onto this skeleton. Keep meta.templateId.",
1759
+ file=sys.stderr,
1760
+ )
1761
+
1712
1762
  _enforce_supported_duration(dsl, template_config, template_id)
1713
1763
 
1714
1764
  return dsl
@@ -110,7 +110,10 @@ def _rebalance_subtitle_segments(segs: list[dict]) -> list[dict]:
110
110
  for p in parts:
111
111
  ratio = len(p) / total_chars
112
112
  frames = max(int(total_frames * ratio), 1)
113
+ # `**seg` 而不是只写三个键:切出来的各部分仍属于**同一句**,
114
+ # 所以 sentenceIndex 等字段要原样继承(见 build_render_plan 处的说明)。
113
115
  expanded.append({
116
+ **seg,
114
117
  "text": _strip_subtitle_trailing_punct(p.strip()),
115
118
  "startFrame": cur_frame,
116
119
  "endFrame": cur_frame + frames,
@@ -550,6 +553,26 @@ def _hydrate_dsl_payload(
550
553
  LogPrint(f" {line}", file=sys.stderr)
551
554
 
552
555
 
556
+ def _compose_image_prompts(dsl: dict, template_id: str | None) -> None:
557
+ """按模板 imageStyleGuide 把画风套到待生成图片的 prompt 上(就地改 dsl)。
558
+
559
+ 只对 ``imageStyleGuide.mode == "compose"`` 的模板生效:agent 在 payload.prompt 里
560
+ 只写画面主体,模板的画风 / 配色 / 负向词 / 比例在这里确定地、只一次地拼上去。
561
+ 放在这里(而不是 gen_script)是因为此刻旁白已是定稿,不是骨架占位。
562
+ 其余模板是零成本 no-op;registry 不可达时同样跳过,与 _hydrate_dsl_payload 一致。
563
+ """
564
+ try:
565
+ from registry_loader import get_template # noqa: E402
566
+ from video_dsl.runtime.prompt_enhancer import compose_dsl_image_prompts # noqa: E402
567
+ template = get_template(template_id) if template_id else None
568
+ except Exception: # noqa: BLE001 - 取不到模板就不 compose,prompt 按 agent 写的原样用
569
+ return
570
+ if not template:
571
+ return
572
+ for line in compose_dsl_image_prompts(dsl, template):
573
+ LogPrint(f" {line}", file=sys.stderr)
574
+
575
+
553
576
  def _warn_step_sentence_mismatch(entry: dict, sentence_indices: list[int]) -> None:
554
577
  """动作条数与旁白句数不等时出声。
555
578
 
@@ -848,9 +871,13 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
848
871
  local_subs = split_subtitle_from_lines(lines, duration_frames, fps)
849
872
  else:
850
873
  local_subs = split_subtitle(narration_text, duration_frames, fps)
874
+ # ⚠️ 平移帧号时**必须保留原有字段**(`**s`),不要只挑三个键重建。
875
+ # 这里曾经只搬 text/startFrame/endFrame,于是 timeline_compiler 给每段打上的
876
+ # `sentenceIndex`(模板用它把动作对齐到**句**而不是段)在这一步被静默丢掉 ——
877
+ # 下游模板拿不到句边界,只能退回按段对齐,也就是那个错位 bug 本身。
851
878
  subtitle_segments = [
852
879
  {
853
- "text": s["text"],
880
+ **s,
854
881
  "startFrame": s["startFrame"] + current_frame,
855
882
  "endFrame": s["endFrame"] + current_frame,
856
883
  }
@@ -961,6 +988,13 @@ def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
961
988
  cmd.extend(["--size", payload["ratio"]])
962
989
  if payload.get("model"):
963
990
  cmd.extend(["--model", payload["model"]])
991
+ # 模板声明的负向词 / 引导强度 / 种子以前在这里被丢掉,gen-image 从没收到过。
992
+ if payload.get("negativePrompt"):
993
+ cmd.extend(["--negative-prompt", str(payload["negativePrompt"])])
994
+ if payload.get("guidanceScale") is not None:
995
+ cmd.extend(["--guidance-scale", str(payload["guidanceScale"])])
996
+ if payload.get("seed") is not None:
997
+ cmd.extend(["--seed", str(int(payload["seed"]))])
964
998
  if private_token:
965
999
  cmd.extend(["--priv-token", private_token])
966
1000
 
@@ -1519,6 +1553,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1519
1553
  # 起点为终点。
1520
1554
  end_f = scene_dur if i == len(old_subs) - 1 else starts[i + 1]
1521
1555
  new_subs.append({
1556
+ **s,
1522
1557
  "text": text,
1523
1558
  "startFrame": current_frame + start_f,
1524
1559
  "endFrame": current_frame + end_f,
@@ -1538,6 +1573,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1538
1573
  ratio = _plain(text) / total_chars
1539
1574
  seg_frames = max(int(sub_span * ratio), fps // 2)
1540
1575
  new_subs.append({
1576
+ **s,
1541
1577
  "text": text,
1542
1578
  "startFrame": current_frame + cursor,
1543
1579
  "endFrame": current_frame + cursor + seg_frames,
@@ -1560,7 +1596,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1560
1596
  rel_start = s["startFrame"] - old_scene_start
1561
1597
  rel_end = s["endFrame"] - old_scene_start
1562
1598
  new_subs.append({
1563
- "text": s["text"],
1599
+ **s,
1564
1600
  "startFrame": current_frame + rel_start,
1565
1601
  "endFrame": current_frame + rel_end,
1566
1602
  })
@@ -2471,6 +2507,10 @@ Examples:
2471
2507
  # conv_Sesw2g8oM81 的表现就是 scenes[0..19].customPayload.slide (required))。
2472
2508
  _hydrate_dsl_payload(dsl, args.template_id, args.source_document_url)
2473
2509
 
2510
+ # compose 模式模板:agent 只写了画面主体,这里拼上模板画风(旁白此刻已定稿)。
2511
+ # --job-id / --render-plan 不走这里:计划已落库,不再改 prompt。
2512
+ _compose_image_prompts(dsl, args.template_id)
2513
+
2474
2514
  errors = validate_dsl(dsl)
2475
2515
  if errors:
2476
2516
  LogPrint("❌ DSL validation failed:", 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": "V27",
5
+ "version": "V28",
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
  }
@@ -56,6 +56,44 @@ SUPPORTED_SOURCES = ("doc-extract",)
56
56
  DEFAULT_INDEX_FIELD = "slideIndex"
57
57
 
58
58
 
59
+ def declared_subfields(template_config: Optional[dict], payload_field: str) -> Optional[set[str]]:
60
+ """模板为 ``customPayload.<payload_field>`` 声明的子字段名集合。
61
+
62
+ 注入必须按这个集合**投影**,不能把抽取器返回的整个对象原样塞进去 ——
63
+ 两边的字段集根本不是一回事:
64
+
65
+ 抽取器返回(每页) slideIndex / title / text / rawNotes / verdict / coverage /
66
+ flags / nodes / beats / mode / modeReason / ambientIds /
67
+ background / recommendedSec / canvas …
68
+ 模板声明(slide) nodes / beats / mode / modeReason / ambientIds /
69
+ background / recommendedSec / canvas
70
+
71
+ 前者多出来的那些是**内容层与抽取器自己的记账字段**(备注原文、质检结论、覆盖率),
72
+ 渲染端一个都不读。而 ``dsl_validator._check_custom_payload_fields`` 会逐字段核对
73
+ 声明表,遇到没声明的就报「the slide does not read 'x' — the value is silently
74
+ dropped」并整单拒绝。2026-09-23 线上 conv_StrnauS7TaZNUQV4BfLX 就是这样:
75
+ 注入明明成功(23/23 页),却因为多了 5 个字段被校验整单拒掉,而且**重试必然复现**。
76
+
77
+ 为什么用投影而不是写个黑名单:抽取器以后再加字段(verdict / coverage 就是后加的)
78
+ 黑名单就会再漏一次。声明表是渲染端真正读的那一份,按它投影才不会二次失效。
79
+
80
+ 声明表读不到时返回 None = 不投影。这与校验层的行为一致:没有
81
+ ``customPayloadSchema`` 时它也不查字段名,所以不投影不会引入新的失败。
82
+ """
83
+ if not template_config:
84
+ return None
85
+ cps = template_config.get("customPayloadSchema")
86
+ if not isinstance(cps, dict):
87
+ return None
88
+ node = cps.get(payload_field)
89
+ if not isinstance(node, dict):
90
+ return None
91
+ props = node.get("properties")
92
+ if not isinstance(props, dict) or not props:
93
+ return None
94
+ return set(props.keys())
95
+
96
+
59
97
  class HydrationError(RuntimeError):
60
98
  """注入失败。message 必须直接告诉调用方下一步做什么。
61
99
 
@@ -206,6 +244,18 @@ def hydrate_dsl(
206
244
  filled = 0
207
245
  missing: list[str] = []
208
246
 
247
+ # 按模板声明投影。抽取结果里混着内容层与抽取器记账字段,渲染端一个都不读,
248
+ # 而校验层见到未声明字段会整单拒绝(见 declared_subfields 的说明)。
249
+ allowed = declared_subfields(template_config, field)
250
+ dropped: set[str] = set()
251
+
252
+ # 页号只有在模板**声明**了它的时候才写回去。没声明就是未知字段,写了会被校验
253
+ # 拒掉 —— 这正是上一版的第二个错:既投影不足,又多写了一个 customPayload.slideIndex。
254
+ index_is_declared = index_field in (declared_subfields(template_config, index_field) or ())
255
+ if not index_is_declared:
256
+ cps = (template_config or {}).get("customPayloadSchema")
257
+ index_is_declared = isinstance(cps, dict) and index_field in cps
258
+
209
259
  for position, scene in enumerate(scenes, start=1):
210
260
  payload = _scene_payload(scene)
211
261
  if payload.get(field) is not None:
@@ -216,9 +266,14 @@ def hydrate_dsl(
216
266
  if page is None:
217
267
  missing.append(f"scene {scene.get('id', position)} → page {page_no}")
218
268
  continue
219
- payload[field] = page
220
- # 把页号写回去,让 RenderPlan 里留下"这一场用的是第几页"的痕迹。
221
- payload.setdefault(index_field, page_no)
269
+ if allowed is None:
270
+ payload[field] = page
271
+ else:
272
+ dropped |= {k for k in page if k not in allowed}
273
+ payload[field] = {k: v for k, v in page.items() if k in allowed}
274
+ # 留下"这一场用的是第几页"的痕迹 —— 仅当模板声明了这个字段。
275
+ if index_is_declared:
276
+ payload.setdefault(index_field, page_no)
222
277
  filled += 1
223
278
 
224
279
  source_meta = result.get("source") or {}
@@ -227,6 +282,13 @@ def hydrate_dsl(
227
282
  f"{source_meta.get('slideCount', len(pages))} page(s) of "
228
283
  f"{source_meta.get('name', 'the source document')}"
229
284
  )
285
+ if dropped:
286
+ # 说清丢了什么:这些是内容层/记账字段,渲染端本来就不读。写出来是为了
287
+ # 抽取器哪天把一个**该读**的字段也归到这里时,有人看得见。
288
+ report.append(
289
+ f" (已按模板声明投影,忽略 {len(dropped)} 个渲染端不读的字段:"
290
+ f"{', '.join(sorted(dropped)[:8])})"
291
+ )
230
292
 
231
293
  if missing:
232
294
  # 页号对不上不是警告而是错误:少一页 = 成片里一个空白场景,而它渲得出来。
@@ -2,6 +2,6 @@
2
2
  "skillName": "template-registry",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "475",
5
- "version": "V20",
5
+ "version": "V21",
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
  }
@@ -4,6 +4,9 @@
4
4
  纯函数模块,无外部依赖。主要入口:
5
5
  - build_enhanced_prompt() : 根据 purpose/topic/style/narration/styleGuide 构建增强 prompt
6
6
  - enrich_asset_payload() : 一站式入口,自动判断并增强 asset payload
7
+ - compose_image_payload() : imageStyleGuide.mode == "compose" 的模板专用 —— agent 只写
8
+ 画面主体,这里在旁白定稿后确定地、只一次地套上模板画风
9
+ (见 docs/picture-book-image-prompt-design.md)
7
10
  """
8
11
 
9
12
  import re
@@ -39,19 +42,54 @@ DEFAULT_PURPOSE_PATTERNS: dict[str, str] = {
39
42
  # prompt 长度阈值:超过此字符数的 prompt 视为已精心编写
40
43
  WELL_CRAFTED_THRESHOLD = 80
41
44
 
45
+ # gen_script 骨架占位的前缀。旁白与 compose 模式的图片主体共用这一组:以它开头说明
46
+ # agent 还没把内容填进来。gen_script 从这里导入,两边不各写一份。
47
+ SKELETON_PREFIXES = ("[骨架待填充]", "[skeleton placeholder]")
48
+
49
+ # compose 模式下 gen_script 写进 payload.prompt 的占位:告诉 agent 这里只填「画什么」。
50
+ COMPOSE_SUBJECT_PLACEHOLDER = (
51
+ f"{SKELETON_PREFIXES[0]} 本页画面主体:谁、在哪、在做什么、什么表情、什么光线"
52
+ )
53
+
54
+ # compose 模式下,模板没给本 purpose 写 promptTemplate 时的兜底
55
+ COMPOSE_DEFAULT_TEMPLATE = "{subject}。{character_sheet}"
56
+
57
+ _CJK_CHAR = re.compile(r"[\u4e00-\u9fff\u3400-\u4dbf]")
58
+
42
59
  # ---------------------------------------------------------------------------
43
60
  # 工具函数
44
61
  # ---------------------------------------------------------------------------
45
62
 
46
63
 
64
+ def _is_mostly_latin(text: str) -> bool:
65
+ """CJK 字符占非空白字符不足 10% —— 按英文等拼音文字处理。"""
66
+ chars = [c for c in text if not c.isspace()]
67
+ if not chars:
68
+ return False
69
+ return sum(1 for c in chars if _CJK_CHAR.match(c)) / len(chars) < 0.1
70
+
71
+
47
72
  def summarize_narration(text: str, max_chars: int = 60) -> str:
48
73
  """从旁白文本提取视觉关键信息,去除口语化填充词。
49
74
 
75
+ 英文旁白不套中文填充词正则("了""的话"之类对英文无意义,反而可能误删),
76
+ 按单词边界截断,上限放宽到 200 字符 —— 60 字符的英文常常只剩半句。
77
+
50
78
  >>> summarize_narration("那么接下来我们来看一下AI如何帮你高效背单词")
51
79
  'AI如何帮你高效背单词'
80
+ >>> summarize_narration("A tiny seed slept in the warm, dark soil.")
81
+ 'A tiny seed slept in the warm, dark soil'
52
82
  """
53
83
  if not text:
54
84
  return ""
85
+ if _is_mostly_latin(text):
86
+ limit = max(max_chars, 200)
87
+ cleaned = _PUNCT.sub(" ", text).strip().rstrip(".!?;:,")
88
+ if len(cleaned) > limit:
89
+ cut = cleaned[:limit]
90
+ space = cut.rfind(" ")
91
+ cleaned = (cut[:space] if space > 0 else cut).rstrip(".!?;:,")
92
+ return cleaned or text[:limit]
55
93
  # 去除填充词
56
94
  cleaned = _FILLER_PATTERNS.sub("", text)
57
95
  # 归一化标点 → 空格
@@ -69,7 +107,7 @@ def extract_scene_context(scene: dict) -> dict:
69
107
  """从 DSL scene 提取视觉相关上下文。
70
108
 
71
109
  Returns:
72
- dict with keys: narration_text, title, badge, purpose
110
+ dict with keys: narration_text, title, badge, headline, subheadline, purpose
73
111
  """
74
112
  purpose = scene.get("purpose", "default")
75
113
  narration_text = ""
@@ -78,19 +116,19 @@ def extract_scene_context(scene: dict) -> dict:
78
116
  if isinstance(narration, dict):
79
117
  narration_text = narration.get("text", "")
80
118
 
81
- title = ""
82
- badge = ""
83
- for layer in scene.get("textLayers", []):
119
+ layers_by_role: dict[str, str] = {}
120
+ for layer in scene.get("textLayers", []) or []:
84
121
  role = layer.get("role", "")
85
- if role == "title" and not title:
86
- title = layer.get("content", "")
87
- elif role == "badge" and not badge:
88
- badge = layer.get("content", "")
122
+ content = layer.get("content", "")
123
+ if role and isinstance(content, str) and role not in layers_by_role:
124
+ layers_by_role[role] = content
89
125
 
90
126
  return {
91
- "narration_text": narration_text,
92
- "title": title,
93
- "badge": badge,
127
+ "narration_text": narration_text or "",
128
+ "title": layers_by_role.get("title", ""),
129
+ "badge": layers_by_role.get("badge", ""),
130
+ "headline": layers_by_role.get("headline", ""),
131
+ "subheadline": layers_by_role.get("subheadline", ""),
94
132
  "purpose": purpose,
95
133
  }
96
134
 
@@ -170,7 +208,7 @@ def build_enhanced_prompt(
170
208
  )
171
209
 
172
210
  # 格式化模板(安全替换)
173
- prompt = _safe_format(prompt_template, vars_)
211
+ prompt = _tidy_punctuation(_safe_format(prompt_template, vars_))
174
212
 
175
213
  # 追加 styleModifier
176
214
  style_modifier = override.get("styleModifier", "")
@@ -213,6 +251,38 @@ def _safe_format(template: str, vars_: dict) -> str:
213
251
  return result
214
252
 
215
253
 
254
+ _EMPTY_BRACKETS = re.compile(r"[((]\s*[))]")
255
+ _PUNCT_BEFORE_CLOSE = re.compile(r"[,。、;,;.\s]+(?=[))])")
256
+ # 两个及以上相邻的分隔标点(中间可夹空白):空变量替换后留下的「,,」「。,」「, 。」等
257
+ _PUNCT_RUN = re.compile(r"[,。、;,;.]\s*(?:[,。、;,;.]\s*)+")
258
+ _LEADING_PUNCT = re.compile(r"^[\s,。、;,;.]+")
259
+ _TRAILING_SOFT_PUNCT = re.compile(r"[\s,、;,;]+$")
260
+ _MULTI_SPACE = re.compile(r"[ \t]{2,}")
261
+
262
+
263
+ def _collapse_punct_run(match: "re.Match[str]") -> str:
264
+ run = match.group(0)
265
+ return "。" if ("。" in run or "." in run) else ","
266
+
267
+
268
+ def _tidy_punctuation(text: str) -> str:
269
+ """清理模板变量为空时留下的标点噪声。
270
+
271
+ ``{style_suffix}`` / ``{character_sheet}`` 这类变量常为空,替换后会留下
272
+ 「,,」「。。」「()」和行首孤立的逗号。连续分隔符合并成一个(含句号就留句号),
273
+ 去掉空括号、行首标点、行尾逗号,多余空格压成一个。单词间的单个空格不动。
274
+ """
275
+ if not text:
276
+ return ""
277
+ out = _PUNCT_BEFORE_CLOSE.sub("", text)
278
+ out = _EMPTY_BRACKETS.sub("", out)
279
+ out = _PUNCT_RUN.sub(_collapse_punct_run, out)
280
+ out = _MULTI_SPACE.sub(" ", out)
281
+ out = _LEADING_PUNCT.sub("", out)
282
+ out = _TRAILING_SOFT_PUNCT.sub("", out)
283
+ return out.strip()
284
+
285
+
216
286
  # ---------------------------------------------------------------------------
217
287
  # 一站式入口
218
288
  # ---------------------------------------------------------------------------
@@ -272,3 +342,152 @@ def enrich_asset_payload(
272
342
  payload["guidanceScale"] = enhanced["guidanceScale"]
273
343
 
274
344
  return payload
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # compose 模式:agent 写「画什么」,模板管「怎么画」
349
+ # ---------------------------------------------------------------------------
350
+
351
+
352
+ def is_compose_mode(style_guide: Optional[dict]) -> bool:
353
+ return bool(style_guide) and style_guide.get("mode") == "compose"
354
+
355
+
356
+ def _style_guide_stamp(template: dict) -> str:
357
+ return f"{template.get('templateId', '')}@{template.get('version', '')}"
358
+
359
+
360
+ def _is_placeholder_subject(text: str) -> bool:
361
+ return not text or text.startswith(SKELETON_PREFIXES)
362
+
363
+
364
+ def compose_image_payload(
365
+ payload: dict,
366
+ scene: Optional[dict],
367
+ template: Optional[dict],
368
+ dsl_meta: Optional[dict] = None,
369
+ variant_id: str = "",
370
+ ) -> dict:
371
+ """把模板的 imageStyleGuide 套到一个 gen-image 素材的 payload 上(compose 模式专用)。
372
+
373
+ ``payload.prompt`` 在 compose 模式下的语义是**画面主体**(这一页画什么),不含画风。
374
+ 这里拼出最终 prompt = promptTemplate(主体 / 角色设定 / 文字层…) + styleModifier +
375
+ baseStyle + colorDirective,并补上 negativePrompt / ratio / guidanceScale / seed。
376
+
377
+ - 非 compose 模板:原样返回(不改变 legacy 模板的行为)。
378
+ - 幂等:``styleGuideApplied`` 等于当前模板版本时原样返回;版本变了则从
379
+ ``promptSubject``(上次 compose 前的主体)重新拼,画风不会叠两遍。
380
+ - 主体为空或仍是骨架占位时退回本场景的旁白全文 / 文字层。
381
+
382
+ 返回新 dict,不修改入参。
383
+ """
384
+ guide = resolve_style_guide(template, variant_id)
385
+ if not is_compose_mode(guide):
386
+ return payload
387
+ stamp = _style_guide_stamp(template or {})
388
+ if payload.get("styleGuideApplied") == stamp:
389
+ return payload
390
+
391
+ meta = dsl_meta or {}
392
+ ctx = extract_scene_context(scene) if scene else {}
393
+ purpose = ctx.get("purpose") or "point"
394
+
395
+ # 上次 compose 过(模板升版后重跑)→ 主体在 promptSubject 里,prompt 已是成品
396
+ subject = str(payload.get("promptSubject") or payload.get("prompt") or "").strip()
397
+ if _is_placeholder_subject(subject):
398
+ subject = (
399
+ ctx.get("narration_text", "").strip()
400
+ or ctx.get("subheadline", "").strip()
401
+ or ctx.get("headline", "").strip()
402
+ )
403
+ if _is_placeholder_subject(subject):
404
+ subject = str(meta.get("topic") or meta.get("title") or "").strip()
405
+
406
+ style = meta.get("style", "")
407
+ vars_ = {
408
+ "subject": subject,
409
+ "scene_text": ctx.get("narration_text", ""),
410
+ "scene_headline": ctx.get("headline", ""),
411
+ "scene_subheadline": ctx.get("subheadline", ""),
412
+ "scene_title": ctx.get("title", "") or ctx.get("headline", ""),
413
+ "character_sheet": str(meta.get("characterSheet") or "").strip(),
414
+ "topic": str(meta.get("topic") or meta.get("title") or ""),
415
+ "style_suffix": f"{style}风格" if style else "",
416
+ # 旧变量名兼容:legacy promptTemplate 里写的 {narration_summary} 在这里就是主体
417
+ "narration_summary": subject,
418
+ }
419
+
420
+ overrides = guide.get("purposeOverrides", {}) or {}
421
+ override = overrides.get(purpose) or {}
422
+ parts = [
423
+ # 没有针对本 purpose 的模板(如 cta / ending)时角色设定照样注入 —— 否则恰好
424
+ # 这一页的主角会换个样子。
425
+ _safe_format(override.get("promptTemplate") or COMPOSE_DEFAULT_TEMPLATE, vars_),
426
+ override.get("styleModifier", ""),
427
+ guide.get("baseStyle", ""),
428
+ guide.get("colorDirective", ""),
429
+ ]
430
+ prompt = _tidy_punctuation(",".join(p.strip() for p in parts if p and p.strip()))
431
+
432
+ out = dict(payload)
433
+ out["prompt"] = prompt
434
+ out["promptSubject"] = subject
435
+ if not out.get("negativePrompt"):
436
+ out["negativePrompt"] = override.get("negativePrompt") or guide.get("negativePrompt")
437
+ if guide.get("ratio"):
438
+ out["ratio"] = guide["ratio"]
439
+ if out.get("guidanceScale") is None and guide.get("guidanceScale") is not None:
440
+ out["guidanceScale"] = guide["guidanceScale"]
441
+ if out.get("seed") is None and guide.get("seed") is not None:
442
+ out["seed"] = guide["seed"]
443
+ out["styleGuideApplied"] = stamp
444
+ return {k: v for k, v in out.items() if v not in (None, "")}
445
+
446
+
447
+ def _scene_refs(node, asset_id: str) -> bool:
448
+ if isinstance(node, dict):
449
+ return any(
450
+ (k == "assetRef" and v == asset_id) or _scene_refs(v, asset_id)
451
+ for k, v in node.items()
452
+ )
453
+ if isinstance(node, list):
454
+ return any(_scene_refs(v, asset_id) for v in node)
455
+ return False
456
+
457
+
458
+ def compose_dsl_image_prompts(dsl: dict, template: Optional[dict]) -> list[str]:
459
+ """对整份 DSL 里待生成的图片素材逐个 compose(就地改 dsl),返回日志行。
460
+
461
+ 只处理 ``type=image, source=gen-image, status∈{planned, missing}``;已生成 / 自带的图
462
+ 不动。非 compose 模板是零成本 no-op(返回空列表)。
463
+ """
464
+ meta = dsl.get("meta") or {}
465
+ variant_id = str(meta.get("variantId") or "")
466
+ if not is_compose_mode(resolve_style_guide(template, variant_id)):
467
+ return []
468
+
469
+ scenes = dsl.get("scenes") or []
470
+ logs: list[str] = []
471
+ if not str(meta.get("characterSheet") or "").strip():
472
+ logs.append(
473
+ "⚠️ meta.characterSheet is empty — every image is generated independently, "
474
+ "so the main character may look different on each page"
475
+ )
476
+ for asset in dsl.get("assets") or []:
477
+ if not isinstance(asset, dict):
478
+ continue
479
+ if asset.get("type") != "image" or asset.get("source") != "gen-image":
480
+ continue
481
+ if asset.get("status", "planned") not in ("planned", "missing"):
482
+ continue
483
+ asset_id = asset.get("assetId", "")
484
+ scene = next((s for s in scenes if _scene_refs(s, asset_id)), None)
485
+ before = asset.get("payload") or {}
486
+ after = compose_image_payload(before, scene, template, meta, variant_id)
487
+ if after is before:
488
+ continue
489
+ asset["payload"] = after
490
+ subject = after.get("promptSubject", "")
491
+ short = subject if len(subject) <= 60 else subject[:60] + "…"
492
+ logs.append(f"🎨 {asset_id} composed (subject: {short})")
493
+ return logs