@remixmate/cli 0.9.46 → 0.9.48

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.46",
4
- "generatedAt": "2026-09-23T08:47:39.701Z",
3
+ "version": "0.9.48",
4
+ "generatedAt": "2026-09-24T03:37:24.431Z",
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.46",
3
+ "version": "0.9.48",
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,9 @@
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",
45
+ "test:picture-book-contract": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-picture-book-contract.py"
44
46
  },
45
47
  "keywords": [
46
48
  "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))
@@ -377,7 +383,7 @@ def estimate_narration_text(label: str, purpose: str, duration: int, topic: str,
377
383
 
378
384
 
379
385
  # 骨架标记:出现此前缀说明 narration 仍是 gen_script 原始骨架,未被 agent 填充真实内容
380
- NARRATION_SKELETON_MARKER = "[骨架待填充]"
386
+ NARRATION_SKELETON_MARKER = SKELETON_PREFIXES[0]
381
387
 
382
388
 
383
389
  def generate_image_prompt(
@@ -622,9 +628,58 @@ def _resolve_contract(template_config: dict | None) -> dict:
622
628
  "primary_visual_type": primary_visual_type,
623
629
  "default_layout": caps.get("defaultLayout"),
624
630
  "authored_scenes": caps.get("authoredScenes") is True,
631
+ # 画面文字契约(dsl_validator 按 error 执行);骨架据此为每个必填 role 留占位
632
+ "text_layer_rules": caps.get("textLayerRules") if isinstance(caps.get("textLayerRules"), dict) else None,
633
+ # arc 叙事弧的收尾场景用途(缺省 cta)。绘本收尾是 ending(寓意页),不是「关注我们」
634
+ "arc_closing_purpose": caps.get("arcClosingPurpose") if isinstance(caps.get("arcClosingPurpose"), str) else None,
635
+ # 骨架里 transitions.default 的取值;缺省 fade(历史行为)
636
+ "default_transition": caps.get("defaultTransition") if isinstance(caps.get("defaultTransition"), str) else None,
625
637
  }
626
638
 
627
639
 
640
+ def _slot_reads_visual(template_config: dict | None, purpose: str) -> bool:
641
+ """这个用途的版式会不会显示背景素材(slotMapping 里有没有读 visuals.background.assetRef)。
642
+
643
+ 不显示的版式(如绘本的 ending 寓意页)不该规划图片:素材照样会被生成、扣积分,
644
+ 却永远不上屏。模板没声明 slotMapping 时按「会显示」处理,保持历史行为。
645
+ """
646
+ mapping = (template_config or {}).get("slotMapping")
647
+ if not isinstance(mapping, dict) or not mapping:
648
+ return True
649
+ slot = mapping.get(purpose) or mapping.get("default") or {}
650
+ seen: set = set()
651
+ while isinstance(slot, dict) and "$ref" in slot and slot["$ref"] not in seen:
652
+ seen.add(slot["$ref"])
653
+ slot = mapping.get(slot["$ref"]) or {}
654
+ extractors = (slot or {}).get("propExtractors") if isinstance(slot, dict) else None
655
+ if not isinstance(extractors, dict):
656
+ return True
657
+ return any(
658
+ isinstance(ex, dict) and str(ex.get("from", "")).endswith("visuals.background.assetRef")
659
+ for ex in extractors.values()
660
+ )
661
+
662
+
663
+ def _placeholder_text_layers(rules: dict) -> list[dict]:
664
+ """模板声明了 capabilities.textLayerRules 时,骨架的文字层 = 每个必填 role 一条占位。
665
+
666
+ 通用骨架给的是「内容要点 N」「关注我们」这类文案,对有文字契约的模板全是错的,
667
+ 而且留着一个没声明的 role(badge)只会让模型以为那里要填东西。占位带骨架前缀,
668
+ dsl_validator 会拒绝原样提交;带上语言要求,模型不用再去 llmHint 里找。
669
+ """
670
+ language = rules.get("language") if isinstance(rules.get("language"), dict) else {}
671
+ hints = {"en": "English text for this page", "zh": "本页中文译文"}
672
+ layers = []
673
+ for role in rules.get("required") or []:
674
+ if not isinstance(role, str):
675
+ continue
676
+ lang = language.get(role)
677
+ prefix = SKELETON_PREFIXES[1] if lang == "en" else SKELETON_PREFIXES[0]
678
+ hint = hints.get(lang, role)
679
+ layers.append({"role": role, "content": f"{prefix} {role}: {hint}"})
680
+ return layers
681
+
682
+
628
683
  def _normalize_linebreaks(value: str | None) -> str:
629
684
  """把画面文字里的**字面量** ``\\n`` 折成真换行。
630
685
 
@@ -788,11 +843,15 @@ def _plan_contract_scenes(
788
843
  这类模板由此能产出真正的单场景,而不再被强行补到 3 段。``--scenes`` 只对 arc 生效。
789
844
  """
790
845
  if contract["scene_strategy"] == "arc":
791
- return plan_scenes(
846
+ plans = plan_scenes(
792
847
  topic, duration, scene_count, False,
793
848
  language=language,
794
849
  max_scenes=contract.get("max_scenes", DEFAULT_MAX_SCENES),
795
850
  )
851
+ closing = contract.get("arc_closing_purpose")
852
+ if closing and plans and plans[-1]["purpose"] == "cta":
853
+ plans[-1] = {**plans[-1], "purpose": closing}
854
+ return plans
796
855
 
797
856
  fixed = contract["fixed_scenes"] or [{"purpose": "opening"}]
798
857
  n = len(fixed)
@@ -1456,6 +1515,13 @@ def build_dsl(
1456
1515
  assets = []
1457
1516
  scenes = []
1458
1517
 
1518
+ # 模板的生图指南。compose 模式(imageStyleGuide.mode == "compose")下骨架里的
1519
+ # payload.prompt 只是「画面主体」占位 —— 画风 / 配色 / 负向词由 render_video 在旁白
1520
+ # 定稿后统一拼上,这里若先拼一份完整 prompt,agent 会以为写好了,或在它上面叠写。
1521
+ image_style_guide = resolve_style_guide(template_config) if _HAS_ENHANCER else {}
1522
+ image_compose = _HAS_ENHANCER and is_compose_mode(image_style_guide)
1523
+ image_ratio = image_style_guide.get("ratio") or ratio
1524
+
1459
1525
  for idx, plan in enumerate(scene_plans):
1460
1526
  scene_id = f"scene-{idx + 1:02d}"
1461
1527
  # 视觉素材的 assetId 命名遵循类型前缀:image → img-,video → video-
@@ -1469,8 +1535,11 @@ def build_dsl(
1469
1535
  if needs_narration else ""
1470
1536
  )
1471
1537
 
1538
+ # 本场景的版式不显示背景素材(如绘本 ending)→ 不规划视觉素材,免得白生成
1539
+ scene_has_visual = has_visual and _slot_reads_visual(template_config, plan["purpose"])
1540
+
1472
1541
  # 仅 visual-overlay 模板生成视觉素材(slide 无视觉素材,carousel-caption 已提前返回)
1473
- if has_visual and primary_visual_type == "image":
1542
+ if scene_has_visual and primary_visual_type == "image":
1474
1543
  slot = provided_slots[idx] if idx < len(provided_slots) else ""
1475
1544
  slot_is_ai, slot_prompt = _parse_ai_slot(slot)
1476
1545
  if slot and not slot_is_ai:
@@ -1495,7 +1564,13 @@ def build_dsl(
1495
1564
  "url": stub_image_url,
1496
1565
  })
1497
1566
  else:
1498
- image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)
1567
+ if image_compose:
1568
+ image_result = {"prompt": COMPOSE_SUBJECT_PLACEHOLDER}
1569
+ else:
1570
+ image_result = generate_image_prompt(
1571
+ plan["purpose"], topic, style,
1572
+ narration_text=narration_text, style_guide=image_style_guide,
1573
+ )
1499
1574
 
1500
1575
  # 用户在 AI 段里写了提示词就用他的。**只替换主体,不动 negativePrompt /
1501
1576
  # guidanceScale**:那两项是模板的护栏("不要文字水印、不要变形"),把它们
@@ -1507,7 +1582,7 @@ def build_dsl(
1507
1582
  img_payload = {
1508
1583
  "prompt": image_result["prompt"],
1509
1584
  "model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
1510
- "ratio": ratio,
1585
+ "ratio": image_ratio,
1511
1586
  }
1512
1587
  if "negativePrompt" in image_result:
1513
1588
  img_payload["negativePrompt"] = image_result["negativePrompt"]
@@ -1521,7 +1596,7 @@ def build_dsl(
1521
1596
  "status": "planned",
1522
1597
  "payload": img_payload,
1523
1598
  })
1524
- elif has_visual and primary_visual_type == "video":
1599
+ elif scene_has_visual and primary_visual_type == "video":
1525
1600
  if stub_video_url:
1526
1601
  assets.append({
1527
1602
  "assetId": visual_asset_id,
@@ -1568,7 +1643,7 @@ def build_dsl(
1568
1643
  },
1569
1644
  })
1570
1645
 
1571
- if has_visual:
1646
+ if scene_has_visual:
1572
1647
  layout = "text-overlay" if plan["purpose"] in ("opening", "cta") else "full-visual"
1573
1648
  text_layers = []
1574
1649
  if plan["purpose"] == "opening":
@@ -1593,6 +1668,8 @@ def build_dsl(
1593
1668
  ]
1594
1669
  if effective_subheadline:
1595
1670
  text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
1671
+ if contract["text_layer_rules"]:
1672
+ text_layers = _placeholder_text_layers(contract["text_layer_rules"])
1596
1673
 
1597
1674
  scene = {
1598
1675
  "id": scene_id,
@@ -1660,6 +1737,9 @@ def build_dsl(
1660
1737
  if effective_subheadline:
1661
1738
  text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
1662
1739
 
1740
+ if contract["text_layer_rules"]:
1741
+ text_layers = _placeholder_text_layers(contract["text_layer_rules"])
1742
+
1663
1743
  custom_payload = _build_custom_payload(template_config)
1664
1744
  if beat_count is not None:
1665
1745
  # Field names come from the template's countdown declaration — this
@@ -1702,6 +1782,8 @@ def build_dsl(
1702
1782
  "outputLanguage": output_language,
1703
1783
  "style": style or "",
1704
1784
  "description": _localize_label(output_language, "video_description", topic=topic, duration=duration),
1785
+ # compose 模式:留一个空位让 agent 写全书共用的角色外观设定,每张图都会逐字注入
1786
+ **({"characterSheet": ""} if image_compose and has_visual else {}),
1705
1787
  },
1706
1788
  "global": {
1707
1789
  "aspectRatio": ratio,
@@ -1716,7 +1798,8 @@ def build_dsl(
1716
1798
  },
1717
1799
  "assets": assets,
1718
1800
  "scenes": scenes,
1719
- "transitions": {"default": "fade", "duration": 0.5},
1801
+ # 模板声明了 capabilities.defaultTransition 就用它(绘本 = none:淡入会露出黑底)
1802
+ "transitions": {"default": contract["default_transition"] or "fade", "duration": 0.5},
1720
1803
  }
1721
1804
 
1722
1805
  if template_id:
@@ -553,6 +553,26 @@ def _hydrate_dsl_payload(
553
553
  LogPrint(f" {line}", file=sys.stderr)
554
554
 
555
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
+
556
576
  def _warn_step_sentence_mismatch(entry: dict, sentence_indices: list[int]) -> None:
557
577
  """动作条数与旁白句数不等时出声。
558
578
 
@@ -968,6 +988,13 @@ def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
968
988
  cmd.extend(["--size", payload["ratio"]])
969
989
  if payload.get("model"):
970
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"]))])
971
998
  if private_token:
972
999
  cmd.extend(["--priv-token", private_token])
973
1000
 
@@ -1413,6 +1440,26 @@ def _auto_highlight_map(entry: dict, narration_asset: Optional[dict], render_pla
1413
1440
  })
1414
1441
 
1415
1442
 
1443
+ def _template_default_tail_pad_frames(template_id: str, fps: int) -> int | None:
1444
+ """模板声明的旁白尾巴默认值(``capabilities.defaultTailPadSec``),换算成帧。
1445
+
1446
+ 场景自己的 ``customPayload.tailPadSec`` 仍然优先;这里只替换全局 1.5s 兜底。
1447
+ 为什么要模板级:逐场景设置靠模型照着 llmHint 抄,实际经常漏 —— 2026-09-24
1448
+ 线上《狼来了》绘本每页结尾 1.9s 空白,86s 里 15s 没有声音。
1449
+ """
1450
+ if not template_id:
1451
+ return None
1452
+ try:
1453
+ from registry_loader import get_template # type: ignore
1454
+ tpl = get_template(template_id, include_all_statuses=True)
1455
+ except Exception: # noqa: BLE001 - 取不到模板就走全局默认,不因为这个挡渲染
1456
+ return None
1457
+ value = ((tpl or {}).get("capabilities") or {}).get("defaultTailPadSec")
1458
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
1459
+ return None
1460
+ return int(round(float(value) * fps))
1461
+
1462
+
1416
1463
  def adjust_timeline_to_audio(render_plan: dict) -> dict:
1417
1464
  """Post-process: use real TTS audio durations and timestamps to fix
1418
1465
  scene durations and subtitle segments."""
@@ -1422,6 +1469,9 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1422
1469
  assets_map = {a["assetId"]: a for a in render_plan["resolvedAssets"]}
1423
1470
  current_frame = 0
1424
1471
  adjusted = 0
1472
+ default_tail_pad = _template_default_tail_pad_frames(render_plan.get("templateId", ""), fps)
1473
+ if default_tail_pad is None:
1474
+ default_tail_pad = int(fps * 1.5)
1425
1475
 
1426
1476
  for entry in render_plan["timeline"]:
1427
1477
  # narration assetId 现在直接由 propExtractors 注入到 props.narrationAssetId,
@@ -1445,13 +1495,14 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1445
1495
  })
1446
1496
  else:
1447
1497
  audio_frames = math.ceil(audio_ms / 1000.0 * fps)
1448
- # DSL 里 scene.customPayload.tailPadSec 可覆盖默认 1.5s 尾巴
1449
- # (比如开场卡片要紧接下一场,设 0 直接贴音频结束)。
1498
+ # DSL 里 scene.customPayload.tailPadSec 可覆盖默认尾巴
1499
+ # (比如开场卡片要紧接下一场,设 0 直接贴音频结束);默认值是模板的
1500
+ # capabilities.defaultTailPadSec,没声明时 1.5s。
1450
1501
  tail_pad_override = entry.get("tailPadFrames")
1451
1502
  tail_pad = (
1452
1503
  int(tail_pad_override)
1453
1504
  if isinstance(tail_pad_override, int) and tail_pad_override >= 0
1454
- else int(fps * 1.5)
1505
+ else default_tail_pad
1455
1506
  )
1456
1507
  min_dur = int(fps * 2) # minimum 2 seconds per scene
1457
1508
  # 用户在 DSL 里指定的 scene 下限(minDurationSec → minDurationFrames),
@@ -2480,6 +2531,10 @@ Examples:
2480
2531
  # conv_Sesw2g8oM81 的表现就是 scenes[0..19].customPayload.slide (required))。
2481
2532
  _hydrate_dsl_payload(dsl, args.template_id, args.source_document_url)
2482
2533
 
2534
+ # compose 模式模板:agent 只写了画面主体,这里拼上模板画风(旁白此刻已定稿)。
2535
+ # --job-id / --render-plan 不走这里:计划已落库,不再改 prompt。
2536
+ _compose_image_prompts(dsl, args.template_id)
2537
+
2483
2538
  errors = validate_dsl(dsl)
2484
2539
  if errors:
2485
2540
  LogPrint("❌ DSL validation failed:", file=sys.stderr)
@@ -1132,6 +1132,77 @@ def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
1132
1132
  return errors
1133
1133
 
1134
1134
 
1135
+ _TEXT_LAYER_SKELETON_PREFIXES = ("[骨架待填充]", "[skeleton placeholder]")
1136
+
1137
+
1138
+ def _check_text_layer_rules(dsl: dict) -> list[ValidationError]:
1139
+ """模板声明 ``capabilities.textLayerRules`` 时,逐场景检查画面文字层。
1140
+
1141
+ 声明形状::
1142
+
1143
+ "textLayerRules": {
1144
+ "required": ["headline", "subheadline"], # 每个场景都必须有且非空
1145
+ "language": {"headline": "en", "subheadline": "zh"} # en = 不含中文;zh = 须含中文
1146
+ }
1147
+
1148
+ 为什么是 error 而不是 llmHint:2026-09-24 线上《狼来了》绘本整本没有一行中文译文,
1149
+ 封面把中英文都塞进了 headline —— llmHint 写得清清楚楚,模型没照做,而当时没有
1150
+ 任何一道门拦它,成片照出,书页上留着一大块给译文的空白。
1151
+ """
1152
+ tpl = _get_template_cfg(_pick_template_id(dsl))
1153
+ if not tpl:
1154
+ return []
1155
+ rules = (tpl.get("capabilities") or {}).get("textLayerRules")
1156
+ if not isinstance(rules, dict):
1157
+ return []
1158
+ required = [r for r in (rules.get("required") or []) if isinstance(r, str)]
1159
+ language = rules.get("language") if isinstance(rules.get("language"), dict) else {}
1160
+ tid = tpl.get("templateId", "<unknown>")
1161
+
1162
+ errors: list[ValidationError] = []
1163
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
1164
+ sid = scene.get("id", f"#{idx}")
1165
+ layers: dict[str, tuple[int, str]] = {}
1166
+ for li, layer in enumerate(scene.get("textLayers") or []):
1167
+ if not isinstance(layer, dict):
1168
+ continue
1169
+ role = layer.get("role")
1170
+ content = layer.get("content")
1171
+ if isinstance(role, str) and role not in layers:
1172
+ layers[role] = (li, content.strip() if isinstance(content, str) else "")
1173
+
1174
+ for role in required:
1175
+ li, content = layers.get(role, (None, ""))
1176
+ path = f"scenes[{idx}].textLayers" + (f"[{li}].content" if li is not None else "")
1177
+ if not content:
1178
+ errors.append(ValidationError(
1179
+ path,
1180
+ f"scene '{sid}': template '{tid}' requires a non-empty textLayers role '{role}'"
1181
+ + (f" (language: {language[role]})" if role in language else ""),
1182
+ ))
1183
+ elif content.startswith(_TEXT_LAYER_SKELETON_PREFIXES):
1184
+ errors.append(ValidationError(
1185
+ path, f"scene '{sid}': textLayers role '{role}' is still the gen_script placeholder",
1186
+ ))
1187
+
1188
+ for role, lang in language.items():
1189
+ li, content = layers.get(role, (None, ""))
1190
+ if li is None or not content or content.startswith(_TEXT_LAYER_SKELETON_PREFIXES):
1191
+ continue
1192
+ path = f"scenes[{idx}].textLayers[{li}].content"
1193
+ if lang == "en" and _contains_cjk(content):
1194
+ others = [r for r, l in language.items() if l == "zh"]
1195
+ hint = f"; put the Chinese in role '{others[0]}'" if others else ""
1196
+ errors.append(ValidationError(
1197
+ path, f"scene '{sid}': role '{role}' must be English only (no CJK characters){hint}",
1198
+ ))
1199
+ elif lang == "zh" and not _contains_cjk(content):
1200
+ errors.append(ValidationError(
1201
+ path, f"scene '{sid}': role '{role}' must be Chinese (the translation of this page)",
1202
+ ))
1203
+ return errors
1204
+
1205
+
1135
1206
  # ════════════════════════════════════════════════════════════════════════════
1136
1207
  # Aggregator presets
1137
1208
  # ════════════════════════════════════════════════════════════════════════════
@@ -1188,6 +1259,7 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
1188
1259
  # 是**渲得出来但错位**,只有在真要渲之前说才来得及。
1189
1260
  errors += _check_step_narration_alignment(dsl)
1190
1261
  errors += _check_slide_cross_field(dsl)
1262
+ errors += _check_text_layer_rules(dsl)
1191
1263
  return errors
1192
1264
 
1193
1265
 
@@ -1222,4 +1294,5 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
1222
1294
  errors += _check_line_sync_narration(dsl)
1223
1295
  errors += _check_step_narration_alignment(dsl)
1224
1296
  errors += _check_slide_cross_field(dsl)
1297
+ errors += _check_text_layer_rules(dsl)
1225
1298
  return errors
@@ -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
@@ -19,13 +19,23 @@ from typing import Optional
19
19
 
20
20
  CHARS_PER_SECOND = 5.0
21
21
 
22
- _PRIMARY_BREAK = re.compile(r"(?<=[。!?;\n])")
22
+ # 英文句末:`.!?`(可跟一个收尾引号/括号)且后面是空白。以前只认中文标点,英文旁白
23
+ # 从来不在句号处断,只能落到逗号 / 30 字空格硬切,切出 `went home. A few days later,`
24
+ # 这种跨句的行,所有段的 sentenceIndex 也全是 0。常见缩写后的句点不算句末。
25
+ _EN_SENTENCE_END = (
26
+ r"(?:(?<=[.!?])|(?<=[.!?][\"'\u201d\u2019)]))"
27
+ r"(?<!\bMr\.)(?<!\bMs\.)(?<!\bDr\.)(?<!\bSt\.)(?<!\bMrs\.)(?<!\be\.g\.)(?<!\bi\.e\.)(?<!\bvs\.)"
28
+ r"(?=\s)"
29
+ )
30
+ _PRIMARY_BREAK = re.compile(r"(?<=[。!?;\n])|" + _EN_SENTENCE_END)
23
31
  # 次级断点**不含顿号 `、`**。顿号是并列项之间的分隔,在它上面下刀就是把一串并列
24
32
  # 拦腰切开(`调研、策划、` / `开发、分析,…`)。这一层只数字符、分不出标点的语义
25
33
  # 强度,所以干脆不碰它 —— 真需要再切时交给渲染端,那边是分级的(句末 > 冒号 >
26
34
  # 逗号 > 顿号 > 空格),只有前面几级都切不动才会降到顿号。
27
35
  _SECONDARY_BREAK = re.compile(r"(?<=[,,::])")
28
- _PRIMARY_TAIL = re.compile(r"[。!?;\n]\s*$")
36
+ _PRIMARY_TAIL = re.compile(
37
+ r"(?:[。!?;\n]|(?<!\bMr)(?<!\bMs)(?<!\bDr)(?<!\bSt)(?<!\bMrs)(?<!\bvs)[.!?][\"'\u201d\u2019)]?)\s*$"
38
+ )
29
39
 
30
40
  # ─── 这两个上限管的是「喂给 TTS 的行」,不再是「一屏字幕」 ────────────────
31
41
  #