#!/usr/bin/env python3
"""
Video script generator — turns a topic into Video DSL v1alpha1 JSON.

Default behavior:
  Produces a structured Video DSL JSON to stdout based on the topic, duration,
  and other constraints.

Usage:
  python gen_script.py --topic "3 AI hacks that double study efficiency" --duration 30 --ratio "9:16"
  python gen_script.py --validate --input my-video.dsl.json

Environment variables:
  VIDEO_DSL_SCHEMA_PATH - DSL schema file path
    (default: skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json)
"""

import argparse
import json
import re
import math
import os
import sys
import uuid
from copy import deepcopy
from urllib.parse import urlsplit

SCHEMA_PATH = os.environ.get(
    "VIDEO_DSL_SCHEMA_PATH",
    "skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json",
)

# prompt_enhancer 路径动态添加
_TEMPLATE_DSL_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "template-registry", "video_dsl")
if os.path.isdir(_TEMPLATE_DSL_DIR):
    sys.path.insert(0, os.path.abspath(os.path.join(_TEMPLATE_DSL_DIR, "..")))

# Shared Python lib lives under skills/template-registry/scripts/ — the de-facto
# cross-skill module location (registry_loader, template_paths, etc.). See
# AGENTS.md for the convention.
_SHARED_SCRIPTS_DIR = os.path.join(
    os.path.dirname(__file__), "..", "..", "template-registry", "scripts"
)
if os.path.isdir(_SHARED_SCRIPTS_DIR):
    sys.path.insert(0, os.path.abspath(_SHARED_SCRIPTS_DIR))

# countdown 策略的声明解析。与下面几个共享依赖同样守护式导入：本文件全篇假定
# 共享目录**可能**不在（见 _SHARED_SCRIPTS_DIR 的 isdir 判断），硬导入会让共享目录
# 缺失时连非 countdown 模板一起起不来。缺失只影响 countdown 这一条路，并在真的
# 用到时明确报错。
try:
    from countdown_spec import (  # noqa: E402  (needs the sys.path setup above)
        opening_sec as countdown_opening_sec,
        resolve_count as resolve_countdown_count,
        resolve_countdown_spec,
        scene_payload as countdown_scene_payload,
    )
    _HAS_COUNTDOWN = True
except ImportError:
    _HAS_COUNTDOWN = False

try:
    from video_dsl.runtime.prompt_enhancer import build_enhanced_prompt
    _HAS_ENHANCER = True
except ImportError:
    _HAS_ENHANCER = False

try:
    from registry_loader import (
        load_registry_data as _load_registry_data,
        require_visible_template as _get_template,
        TemplateStatusGatedError,
    )
    _HAS_REGISTRY_LOADER = True
except ImportError:
    _HAS_REGISTRY_LOADER = False

    class TemplateStatusGatedError(RuntimeError):  # type: ignore[no-redef]
        """占位：共享模块不可导入时（单测 / 裁剪安装）永远不会被抛出。"""

VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
VALID_PURPOSES = ["opening", "point", "example", "explanation", "highlight", "cta", "ending"]

CHARS_PER_SECOND = 5.0  # 与 timeline_compiler.py 保持一致（中文约 5 字/秒）

# ── Language contract (mirrors apps/ab-skill/src/handlers/template-meta.ts) ──
# template.json may declare:
#   - outputLanguage: "en" | "zh"   default "zh" when missing/invalid
#   - defaultVoiceId: string        default None when missing/empty
# Read tolerantly: any failure returns the legacy zh defaults plus a stderr warning.

DEFAULT_LANGUAGE = "zh"
LANGUAGE_TO_META = {"en": "en-US", "zh": "zh-CN"}

# Fallback voices keyed by output language. Mirrors voice-resolver.ts FALLBACK_VOICE.
# zh keeps the legacy default that's been used everywhere; en is a placeholder
# until the gen-voice service confirms the right English voice id.
FALLBACK_VOICE = {
    "zh": "Chinese (Mandarin)_Male_Announcer",
    # TODO: confirm an English voice id against gen-voice service before shipping.
    "en": "english-male-friendly-01",
}

# ── Narration speed ───────────────────────────────────────────────────────────
# Written to global.narration.speed; render_video copies it down into the
# gen-voice asset payload (same route as narration text) and passes it to
# `gen-voice --speed`. The envelope mirrors gen-voice's own 0.5–2.0 contract;
# the real per-voice limits (capabilities speedMin/speedMax) are only known at
# synthesis time, which is several confirmation steps and a few paid image
# generations later — so an obviously-out-of-range value fails here instead.
DEFAULT_NARRATION_SPEED = 1.0
SPEED_MIN = 0.5
SPEED_MAX = 2.0


def _resolve_narration_speed(speed):
    """CLI speed → the value written to global.narration.speed. Total function."""
    if speed is None:
        return DEFAULT_NARRATION_SPEED
    return float(speed)


def _resolve_template_language_meta(template_config):
    """Tolerant reader for outputLanguage / defaultVoiceId on a template dict.

    Returns (language, default_voice_id). Never raises. Logs a single stderr
    warning when fields are present but malformed.
    """
    if not isinstance(template_config, dict):
        return DEFAULT_LANGUAGE, None
    raw_lang = template_config.get("outputLanguage")
    if raw_lang in ("en", "zh"):
        language = raw_lang
    else:
        if raw_lang is not None:
            print(
                f"⚠️  unrecognized outputLanguage={raw_lang!r} on template; defaulting to {DEFAULT_LANGUAGE}",
                file=sys.stderr,
            )
        language = DEFAULT_LANGUAGE
    raw_voice = template_config.get("defaultVoiceId")
    if isinstance(raw_voice, str) and raw_voice.strip():
        default_voice_id = raw_voice.strip()
    else:
        default_voice_id = None
    return language, default_voice_id


def _resolve_voice_id(cli_voice_id, template_default_voice_id, language):
    """Mirror of voice-resolver.ts resolveVoiceId. Total function.

    Order: explicit CLI override → template defaultVoiceId → language fallback.
    """
    if isinstance(cli_voice_id, str) and cli_voice_id.strip():
        return cli_voice_id.strip()
    if isinstance(template_default_voice_id, str) and template_default_voice_id.strip():
        return template_default_voice_id.strip()
    return FALLBACK_VOICE.get(language, FALLBACK_VOICE[DEFAULT_LANGUAGE])


def _check_narration_language(narrations, expected):
    """Non-blocking sanity check: warn when narration looks like the wrong language.

    Skips strings that start with NARRATION_SKELETON_MARKER or its English variant —
    those are gen_script placeholders the agent will replace with real narration
    before sending to prepare_video_assets, so a language mismatch on them is expected.
    """
    cjk_re = __import__("re").compile(r"[\u4e00-\u9fff]")
    latin_re = __import__("re").compile(r"[A-Za-z]")
    skeleton_prefixes = (NARRATION_SKELETON_MARKER, "[skeleton placeholder]")
    for text in narrations:
        if not isinstance(text, str) or not text.strip():
            continue
        if text.lstrip().startswith(skeleton_prefixes):
            continue
        has_cjk = bool(cjk_re.search(text))
        has_latin = bool(latin_re.search(text))
        if expected == "en" and has_cjk:
            print(
                "⚠️  narration contains CJK characters but template outputLanguage=en",
                file=sys.stderr,
            )
            break
        if expected == "zh" and has_latin and not has_cjk:
            print(
                "⚠️  narration looks non-Chinese but template outputLanguage=zh",
                file=sys.stderr,
            )
            break


def _localize_label(language, key, **fmt):
    """Tiny localization helper for the on-screen / narration default strings.

    Keeps the template-driven outputLanguage as the single source of truth: zh
    templates keep their legacy Chinese phrasing, en templates emit English. We
    deliberately keep the table small — anything more elaborate belongs in the
    LLM prompt, not in this fallback skeleton.
    """
    table = {
        "label_opening": {"zh": "开场 Hook", "en": "Opening hook"},
        "label_point":   {"zh": "内容要点 {n}", "en": "Point {n}"},
        "label_cta":     {"zh": "结尾 CTA", "en": "Closing CTA"},
        "narration_opening": {
            "zh": "[骨架待填充] 关于{topic}的开场 Hook({duration}s)",
            "en": "[skeleton placeholder] Opening hook about {topic} ({duration}s)",
        },
        "narration_point": {
            "zh": "[骨架待填充] {label}:关于{topic}的一个要点({duration}s)",
            "en": "[skeleton placeholder] {label}: a point about {topic} ({duration}s)",
        },
        "narration_cta": {
            "zh": "[骨架待填充] 关于{topic}的 CTA 结尾({duration}s)",
            "en": "[skeleton placeholder] Closing CTA about {topic} ({duration}s)",
        },
        "narration_default": {
            "zh": "[骨架待填充] 关于{topic}的内容({duration}s)",
            "en": "[skeleton placeholder] Content about {topic} ({duration}s)",
        },
        # NOTE: 这里曾有 "point_description"（"关于{topic}的要点内容"）与
        # "more_about"（"更多关于{topic}的内容"）两条，用来给 no-visual 场景拼画面
        # 文案。两条都已删除：topic 长度不受控，拼进画面必然溢出，而正确的文案来源
        # 是调用方给的 --headline / --subheadline。
        "follow_us": {"zh": "关注我们", "en": "Follow us"},
        "video_description": {
            "zh": "关于「{topic}」的{duration}秒短视频",
            "en": "A {duration}s short video about \"{topic}\"",
        },
    }
    entry = table.get(key, {})
    template = entry.get(language) or entry.get(DEFAULT_LANGUAGE) or ""
    return template.format(**fmt)


def load_schema():
    if not os.path.exists(SCHEMA_PATH):
        return None
    with open(SCHEMA_PATH, "r", encoding="utf-8") as f:
        return json.load(f)


def validate_dsl(dsl: dict) -> list:
    """Structural DSL validation — delegates to the unified validator.

    Historical rule set (preserved verbatim by ``validate_structural``):
      version + meta(title) + global presence + scene count 1–20 + each
      scene's ``id`` and ``purpose``. Asset integrity, enum membership and
      capabilities-driven checks (e.g. narrationLanguageStrict) are
      intentionally NOT enforced here — those live in ``validate_dsl`` /
      ``validate_integrity`` and only run at their respective historical
      call sites.
    """
    from video_dsl.runtime.dsl_validator import (  # noqa: E402
        validate_structural,
        errors_as_strings,
    )
    return errors_as_strings(validate_structural(dsl))


def plan_scenes(topic: str, duration: int, scene_count: int | None, allow_digital_human: bool, language: str = DEFAULT_LANGUAGE) -> list:
    """Plan scene structure based on topic and constraints."""
    if scene_count is None:
        if duration <= 15:
            scene_count = 3
        elif duration <= 30:
            scene_count = 5
        elif duration <= 60:
            scene_count = 7
        else:
            scene_count = min(10, duration // 8)

    scene_count = max(3, min(scene_count, 20))

    scenes = []
    content_scenes = scene_count - 2

    opening_duration = max(3, round(duration * 0.12))
    ending_duration = max(3, round(duration * 0.12))
    content_total = duration - opening_duration - ending_duration
    per_content = max(3, round(content_total / content_scenes))

    scenes.append({
        "purpose": "opening",
        "duration": opening_duration,
        "label": _localize_label(language, "label_opening"),
    })

    for i in range(content_scenes):
        scenes.append({
            "purpose": "point",
            "duration": per_content,
            "label": _localize_label(language, "label_point", n=i + 1),
        })

    scenes.append({
        "purpose": "cta",
        "duration": ending_duration,
        "label": _localize_label(language, "label_cta"),
    })

    return scenes


def estimate_narration_text(label: str, purpose: str, duration: int, topic: str, language: str = DEFAULT_LANGUAGE) -> str:
    """Generate a short skeleton narration hint for a scene.

    This returns a *skeleton only* — the agent is expected to replace this
    text with real narration before submitting DSL to prepare_video_assets. We
    deliberately return a short, clearly-placeholder-looking string rather
    than padding to duration × CHARS_PER_SECOND, because padding with a
    repeating filler sentence historically caused the skeleton to be
    submitted verbatim to TTS (audio narration mismatch bug).
    """
    fmt = {"label": label, "topic": topic, "duration": duration}
    if purpose == "opening":
        return _localize_label(language, "narration_opening", **fmt)
    if purpose == "point":
        return _localize_label(language, "narration_point", **fmt)
    if purpose == "cta":
        return _localize_label(language, "narration_cta", **fmt)
    return _localize_label(language, "narration_default", **fmt)


# 骨架标记：出现此前缀说明 narration 仍是 gen_script 原始骨架，未被 agent 填充真实内容
NARRATION_SKELETON_MARKER = "[骨架待填充]"


def generate_image_prompt(
        purpose: str, topic: str, style: str, narration_text: str = "", style_guide: dict | None = None
) -> dict:
    """Generate image prompt for a scene.

    Returns dict with keys: prompt, and optionally negativePrompt, guidanceScale.
    """
    if _HAS_ENHANCER:
        return build_enhanced_prompt(
            purpose=purpose,
            topic=topic,
            style=style,
            narration_text=narration_text,
            style_guide=style_guide,
        )

    # 降级：无 enhancer 时使用原逻辑
    style_suffix = f"，{style}风格" if style else ""
    prompts = {
        "opening": f"{topic}主题概念图，吸引眼球的视觉效果{style_suffix}，高清大图",
        "point": f"与{topic}相关的场景插画{style_suffix}，信息图表风格",
        "cta": f"关注点赞互动图标{style_suffix}，简洁现代设计",
    }
    return {"prompt": prompts.get(purpose, f"{topic}相关配图{style_suffix}")}


def _parse_ai_slot(slot: str) -> tuple[bool, str]:
    """解析 --scene-images 的一项：是不是 AI 段，以及用户给的提示词。

    返回 ``(is_ai, prompt)``：

      - ``"https://…/a.png"`` → ``(False, "")``  用已有素材
      - ``"ai"``              → ``(True, "")``   这一段交给 gen-image，提示词由模板推
      - ``"ai: 深色调机房"``   → ``(True, "深色调机房")``

    大小写不敏感。判定刻意收得很紧（整项等于 ``ai``，或以 ``ai:`` 开头）：URL 才是这个
    参数的常态，把 ``ai`` 判宽了会让 ``https://ai.example.com/x.png`` 这种正常地址被
    误当成"生成一段"，用户的图就此消失。
    """
    text = (slot or "").strip()
    if text.lower() == "ai":
        return True, ""
    if text[:3].lower() == "ai:":
        return True, text[3:].strip()
    return False, ""


def _load_template_config(template_id: str) -> dict | None:
    """Load template definition for the given template ID.

    解析顺序：
      1. 通过 registry_loader 从 ab-api（单一数据源，URL 取
         VIDEO_TEMPLATE_REGISTRY_URL 或按 MM_API_BASE_URL 推导）加载，按 templateId 匹配。
      2. 兜底：旧路径 template-registry/video_dsl/templates/<id>/template.json
         （仅在仓库还残留旧目录时使用）。
    返回完整 template dict（含 supportedAspectRatios / assetRequirements / slotMapping 等），
    找不到时返回 None。

    ⚠️ 一个例外**不走**兜底：模板存在、但被状态门控挡在可见集合外（``beta`` 而本进程
    没开 ENABLE_BETA_TEMPLATES）时 ``TemplateStatusGatedError`` 原样上抛。下面那些
    ``_template_*`` / ``_resolve_contract`` 读到 None 会一路退回通用默认值 —— arc 三段式、
    默认 layout、空 customPayload，而且 llmHint 根本不会打给模型。对「id 不存在」这是
    合理容错，对「模板在线但本进程看不见」则是把配置问题伪装成一条能跑通的废片。
    """
    # 优先使用 registry_loader（这是模板元数据的唯一权威来源）
    if _HAS_REGISTRY_LOADER:
        try:
            return _get_template(template_id)
        except TemplateStatusGatedError:
            raise
        except Exception as exc:
            print(
                f"⚠️  registry_loader failed, falling back to local template.json: {exc}",
                file=sys.stderr,
            )

    # 兜底：旧的本地目录布局
    legacy_dir = os.path.join(
        os.path.dirname(__file__), "..", "..", "template-registry", "video_dsl", "templates"
    )
    legacy_file = os.path.join(legacy_dir, template_id, "template.json")
    if os.path.isfile(legacy_file):
        with open(legacy_file, "r", encoding="utf-8") as f:
            return json.load(f)
    return None


def _template_supported_ratio(template_config: dict | None) -> str | None:
    """Return template 的首选宽高比。

    如果模板声明了 supportedAspectRatios，返回数组首项作为推荐值；
    否则返回 None，由调用方使用全局默认（16:9）。
    """
    if not template_config:
        return None
    ratios = template_config.get("supportedAspectRatios") or []
    for r in ratios:
        if isinstance(r, str) and r in VALID_RATIOS:
            return r
    return None


def _template_primary_visual_type(template_config: dict | None) -> str:
    """从模板的 assetRequirements 推导每个 scene 的主要视觉素材类型。

    返回值之一：
      - "image" : 模板要求图片素材（默认通用 DSL 也是图片）
      - "video" : 模板要求视频素材（assetRequirements 含 video 但不含 image）
      - "none"  : 模板不需要任何视觉素材（assetRequirements 只有 audio）
    """
    if not template_config:
        return "image"  # 默认：通用 DSL 走图片
    requirements = template_config.get("assetRequirements") or []
    if "image" in requirements:
        return "image"
    if "video" in requirements:
        return "video"
    return "none"


def _template_needs_narration(template_config: dict | None) -> bool:
    """模板是否需要旁白（TTS）。

    默认 True —— 保持历史行为：每个场景挂一个 gen-voice 素材 + 写入
    ``audio.narration`` 骨架，由 agent 后续填真实文案。

    模板可通过 ``capabilities.needsNarration=false`` 声明"纯视觉 / 无旁白"
    （如单图 Ken Burns、BGM-only 展示）。此时 gen_script：
      - 不生成 gen-voice 素材；
      - 场景不写 ``audio.narration``。
    于是骨架的 narrationSceneCount=0，ab-agent 的 prepare_video_assets 预校验
    会直接放行，不再强制用户为每个场景填旁白 —— 所有 needsNarration=false 的模板
    走的是同一条 pass-through 路径。

    兼容历史脏数据：部分模板的 ``capabilities`` 是空列表 ``[]``（而非 dict），
    统一按"未声明"处理 → 返回 True。仅当显式为 JSON ``false`` 时才关闭旁白。
    """
    caps = (template_config or {}).get("capabilities")
    if not isinstance(caps, dict):
        return True
    return caps.get("needsNarration", True) is not False


def _template_needs_image(template_config: dict | None) -> bool:
    """Check if a template requires image assets.

    If assetRequirements is defined and does NOT include 'image', images are not needed.
    """
    if template_config is None:
        return True  # default: generate images for generic DSL
    requirements = template_config.get("assetRequirements", [])
    return "image" in requirements


# ═══════════════════════════════════════════════════════════════════════════════
# Template-as-Contract: capabilities-driven DSL assembly
# ═══════════════════════════════════════════════════════════════════════════════
#
# 模板的所有差异都通过 template.json 的 ``capabilities`` 声明（registry 是单一数据源，
# 内置模板与个人 OSS 模板同源可达）。gen_script 把 capabilities 归一化成一份 contract，
# 再用同一条流水线组装 DSL —— 不再有 input-schema.json，也不再有 if template_id==X 分支。
#
# 归一化 contract 字段（全部可缺省，缺省值保证"未声明能力"的模板走通用图文旁白）：
#   - needs_narration   bool         capabilities.needsNarration，默认 True
#   - scene_strategy    str          capabilities.sceneStrategy ∈ {arc, single, fixed, countdown}，默认 arc
#   - fixed_scenes      list[dict]   capabilities.fixedScenes（single/fixed 用；缺省单 opening）
#   - countdown         dict|None    countdown 策略的解析结果（见 countdown_spec 模块；
#                                    非 countdown 模板为 None）
#   - payload_style     str          capabilities.payloadStyle ∈ {visual-overlay, slide, carousel-caption}
#   - duration_strategy str          capabilities.durationStrategy ∈ {explicit, fit-caption, fit-images}
#   - payload_defaults  dict         capabilities.payloadDefaults（carousel-caption 的轮播/字幕默认值）
#   - duration_model    dict|None    capabilities.durationModel（fit-caption 估时用的节奏数值，
#                                    由模板声明；缺省则不估时，见 _estimate_fit_caption_duration）
#   - primary_visual_type / default_layout 由 assetRequirements / capabilities 推导


def _resolve_countdown(template_config: dict | None) -> dict:
    """countdown 声明解析；共享模块缺失时明确报错而不是静默换一种形状。

    退回 ``arc`` 会是最坏的处理：一个倒数模板会拿到「至少 3 段 + 末尾 cta + 升序编号」
    的场景表，既没有数量也没有倒数索引，然后一路通过到渲染。
    """
    if not _HAS_COUNTDOWN:
        raise ValueError(
            "capabilities.sceneStrategy=countdown needs the shared countdown_spec module "
            "from skills/template-registry/scripts/, which is not importable here."
        )
    return resolve_countdown_spec(template_config)


def _resolve_contract(template_config: dict | None) -> dict:
    """把 template.json 的 capabilities 归一化成一份装配 contract。

    宽容读取：capabilities 缺失 / 非 dict（历史脏数据可能是 ``[]``）一律按"未声明"
    处理，回退到通用图文旁白（arc + visual-overlay + 旁白开），保证没有声明能力的
    模板与无模板场景行为一致。
    """
    caps = (template_config or {}).get("capabilities")
    caps = caps if isinstance(caps, dict) else {}

    needs_narration = caps.get("needsNarration", True) is not False

    scene_strategy = caps.get("sceneStrategy")
    if scene_strategy not in ("arc", "single", "fixed", "countdown"):
        scene_strategy = "arc"

    primary_visual_type = _template_primary_visual_type(template_config)

    payload_style = caps.get("payloadStyle")
    if payload_style not in ("visual-overlay", "slide", "carousel-caption"):
        payload_style = "slide" if primary_visual_type == "none" else "visual-overlay"

    duration_strategy = caps.get("durationStrategy")
    if duration_strategy not in ("explicit", "fit-caption", "fit-images"):
        duration_strategy = "explicit"

    fixed_scenes = caps.get("fixedScenes")
    if not isinstance(fixed_scenes, list) or not fixed_scenes:
        # single / fixed 未显式给 fixedScenes 时退化为单 opening 场景
        fixed_scenes = [{"purpose": "opening"}] if scene_strategy in ("single", "fixed") else []

    payload_defaults = caps.get("payloadDefaults")
    payload_defaults = payload_defaults if isinstance(payload_defaults, dict) else {}

    return {
        "needs_narration": needs_narration,
        "scene_strategy": scene_strategy,
        "fixed_scenes": fixed_scenes,
        # 声明解析放共享模块：render-video 的计划校验要读同一份声明，
        # 两边各抄一遍正是这个功能上一版栽过的跟头。
        "countdown": _resolve_countdown(template_config) if scene_strategy == "countdown" else None,
        "payload_style": payload_style,
        "duration_strategy": duration_strategy,
        "duration_model": caps.get("durationModel"),
        "payload_defaults": payload_defaults,
        "primary_visual_type": primary_visual_type,
        "default_layout": caps.get("defaultLayout"),
    }


def _normalize_linebreaks(value: str | None) -> str:
    """把画面文字里的**字面量** ``\\n`` 折成真换行。

    ``textLayers[].content`` 用真换行分行，但调用方是在拼 shell 命令行的模型：
    ``--subheadline 'A\\nB'`` 在 POSIX 单引号里传进来的是**反斜杠 + n 两个字符**，
    不是换行。不折的话它会原样渲进画面（副标题上真的印着一个 ``\\n``），而这既不
    报错也不出现在确认摘要里 —— 属于"只有渲完看片才发现"的那类坑。

    反过来说，画面文案里没有任何理由需要一个字面 ``\\n``，所以这个折叠是无损的。
    真换行（``$'a\\nb'`` 或参数里直接带换行）本来就能用，不受影响。
    """
    return (value or "").replace("\\n", "\n").strip()


def _echo_template_llm_hint(template_config: dict | None, template_id: str | None) -> None:
    """把模板自己声明的 ``llmHint`` 原样打到 stderr。

    **本函数刻意不认识任何一个具体模板。** 每个模板的画面文字该怎么写（标题写钩子
    还是写产品名、副标题几行、支持不支持 ``**强调**``）是模板的事实，真源在它自己的
    ``template.json`` —— 与 ``imageStyleGuide`` 由 ``prompt_enhancer`` 通用消费、
    ``capabilities`` 由 ``_resolve_contract`` 通用归一化是同一条纪律：**CLI 出机制，
    模板出内容**。把某个模板的版式规则抄进 skill 文档或这里的分支，等于在 registry
    之外开第二份会漂移的事实。

    之所以要在这里再打一遍：``list_templates`` 的表格视图把 llmHint 截到 200 字
    （便于浏览），而真正的写作规范往往长得多（现网最长的已有 1400+ 字）。调用方
    多半是照着那份被截断的摘要选完模板就直接来调 gen_script 的，规则的后半截它从没
    见过。这里在装配 DSL 之前把全文补给它，让它能在确认摘要之前自己纠偏。
    """
    hint = (template_config or {}).get("llmHint")
    if isinstance(hint, str) and hint.strip():
        print(
            f"ℹ️  template '{template_id}' llmHint (authoring rules declared by the "
            f"template itself — follow them):\n   {hint.strip()}",
            file=sys.stderr,
        )


def _warn_on_url_text_layer(headline: str, subheadline: str) -> None:
    """画面文字整条是一个裸 URL 时出声提醒（不阻断）。

    这是 DSL 层的事实、不是某个模板的版式：``textLayers`` 是**画在画面上**的字，
    而观众没法点视频里的链接、也很少有人会照着念一串 URL 抄下来。链接的正确去处
    因模板而异（打字机行、结尾 CTA、简介），所以这里只说"这不该出现在画面标题里"，
    不替模板规定它该去哪 —— 那句话由模板的 llmHint 自己讲。

    只 warn 不 exit：这是文风问题，照样渲得出一条能看的片子，与"caption 为空渲出
    黑屏"那种结构性失败不同级。
    """
    for flag, value in (("--headline", headline), ("--subheadline", subheadline)):
        lines = [ln.strip() for ln in (value or "").split("\n") if ln.strip()]
        if lines and all(
            ln.startswith(("http://", "https://", "www.", "github.com/")) for ln in lines
        ):
            print(
                f"⚠️  {flag} is nothing but a URL ({value.strip()!r}). "
                "textLayers are drawn on screen — a link there is unreadable and "
                "unclickable, and it spends the frame's most-read text on something "
                "the viewer cannot act on.\n"
                "   Write what the thing is / why it matters instead, and see the "
                "template's llmHint above for where the link belongs.",
                file=sys.stderr,
            )


def _enforce_supported_duration(
    dsl: dict, template_config: dict | None, template_id: str | None
) -> None:
    """校验成片总时长落在模板自己声明的 ``supportedDurations`` 区间内。

    这条约束此前只在 template-library 的 CI（``check-dsl-examples.mjs``）对仓库里的
    示例 DSL 生效，运行时链路（gen_script → prepare_video_assets → render_video）没有
    任何一环校验它。于是一个声明了 ``supportedDurations.min`` 的模板照样可以静默产出
    低于下限的成片 —— 模板自己说了"我不是为这么短设计的"，却没人拦。这里在 DSL 出厂前补上同一道栅栏。

    越界即报错退出，而不是静默出片：时长不足通常意味着内容（打字机文案 / 旁白 / 场景）
    根本没填够，继续往下走只会烧掉渲染积分换一条废片。
    """
    sd = (template_config or {}).get("supportedDurations")
    if not isinstance(sd, dict):
        return

    total = sum(s.get("duration", 0) for s in dsl.get("scenes", []))
    lo = sd.get("min")
    hi = sd.get("max")

    if isinstance(lo, (int, float)) and total < lo:
        print(
            f"❌ duration {total}s is below template '{template_id}' declared minimum {lo}s "
            f"(supportedDurations: [{lo}, {hi}]).\n"
            "   The template declares it is not designed for clips this short; rendering "
            "anyway burns credits on a degenerate video.\n"
            "   Fix: add content until the estimated duration reaches the minimum — more "
            "--caption-lines for typewriter-driven templates, longer narration / more scenes "
            "for narration-driven ones.",
            file=sys.stderr,
        )
        sys.exit(1)

    if isinstance(hi, (int, float)) and total > hi:
        print(
            f"❌ duration {total}s exceeds template '{template_id}' declared maximum {hi}s "
            f"(supportedDurations: [{lo}, {hi}]).\n"
            "   Fix: shorten the content (fewer --caption-lines / scenes) or lower --duration, "
            "or pick a template that supports longer videos.",
            file=sys.stderr,
        )
        sys.exit(1)


def _plan_contract_scenes(
    contract: dict, topic: str, duration: int, scene_count: int | None, language: str
) -> list:
    """按 contract.scene_strategy 规划场景列表。

      - arc    : 沿用 plan_scenes 叙事弧（opening → point* → cta，最少 3 段）。
      - single : 单场景（默认 opening），整段时长归一个场景。
      - fixed  : 按 capabilities.fixedScenes 顺序展开，时长均分。

    single / fixed 完全绕开 arc 的"最少 3 段"下限 —— 单图 Ken Burns / 打字机卡片
    这类模板由此能产出真正的单场景，而不再被强行补到 3 段。``--scenes`` 只对 arc 生效。
    """
    if contract["scene_strategy"] == "arc":
        return plan_scenes(topic, duration, scene_count, False, language=language)

    fixed = contract["fixed_scenes"] or [{"purpose": "opening"}]
    n = len(fixed)
    per = max(1, round(duration / n))
    plans = []
    for i, fs in enumerate(fixed):
        purpose = fs.get("purpose", "opening") if isinstance(fs, dict) else "opening"
        if purpose == "opening":
            label = _localize_label(language, "label_opening")
        elif purpose == "cta":
            label = _localize_label(language, "label_cta")
        else:
            label = _localize_label(language, "label_point", n=i)
        plans.append({"purpose": purpose, "duration": per, "label": label})
    return plans


def _plain_len(text: str) -> int:
    """剥离 **强调** 标记后的纯字符数（与渲染端 emphasis.plainLength 等价）。"""
    return len((text or "").replace("**", ""))


# ── fit-caption 估时 ───────────────────────────────────────────────────────
#
# 这里以前钉着一组常量（打字收尾 1.5s、标题入场 1.0s、每张图最少 2.0s …），注释写
# 明"与 template-library 里那个模板组件的 timing.ts 保持同一套数值"。也就是说
# **一个具体模板的组件内部节奏被手抄进了 CLI**：模板调一次 tailHold，这边不跟着改
# 就开始漂，而漂移的表现是"估时和组件实际耗时对不上"，没有任何门禁看得见。
#
# 现在数值由模板在 ``capabilities.durationModel`` 里自己声明，本文件只实现算法：
# 各分量并行、取 max、加收尾留白。没声明的模板不估时（见 _estimate_fit_caption_duration）。
# 与 ``payloadDefaults`` / ``imageStyleGuide`` 同一条纪律：模板出数值，CLI 出机制。


def _estimate_caption_natural_sec(caption: dict, model: dict) -> float:
    """打字机自然节奏耗时（startDelay + 打字 + 行间停顿 + 收尾），秒。"""
    max_lines = model.get("maxLines")
    lines = caption.get("lines") or []
    if isinstance(max_lines, int) and max_lines > 0:
        lines = lines[:max_lines]
    if not lines:
        return 0.0
    cps = caption.get("charsPerSec", model.get("charsPerSec"))
    if not cps:
        return 0.0
    lo, hi = model.get("charsPerSecMin"), model.get("charsPerSecMax")
    if lo is not None:
        cps = max(lo, cps)
    if hi is not None:
        cps = min(hi, cps)
    line_gap_sec = caption.get("lineGapMs", model.get("lineGapMs", 0)) / 1000.0
    start_delay_sec = caption.get("startDelayMs", model.get("startDelayMs", 0)) / 1000.0
    total_chars = sum(_plain_len(t) for t in lines)
    inter_line_sec = line_gap_sec * max(0, len(lines) - 1)
    fixed_overhead = start_delay_sec + inter_line_sec + model.get("tailHoldSec", 0)
    return fixed_overhead + total_chars / cps


def _estimate_carousel_min_sec(carousel: dict, model: dict) -> float:
    """轮播至少需要的秒数（每张图/视频的最小停留，减去重叠的过渡）。"""
    items = carousel.get("items") or []
    n = len(items)
    if n == 0:
        return 0.0
    transition_sec = carousel.get("transitionMs", model.get("transitionMs", 0)) / 1000.0
    explicit_pacing = carousel.get("pacing")
    any_explicit = any(it.get("holdSec") is not None for it in items)
    has_default_hold = carousel.get("defaultHoldSec") is not None
    pacing = explicit_pacing or ("fixed" if (any_explicit or has_default_hold) else "auto")
    if pacing == "fixed":
        default_hold = carousel.get("defaultHoldSec", model.get("defaultHoldSec", 0))
        total = sum(it.get("holdSec", default_hold) for it in items)
        return total - (n - 1) * transition_sec
    per_image = model.get("minPerImageSec", 0)
    per_video = model.get("minPerVideoSec", per_image)
    total = sum(per_video if it.get("kind") == "video" else per_image for it in items)
    return total - (n - 1) * transition_sec


def _estimate_fit_caption_duration(
    custom_payload: dict, duration_model: dict | None, template_id: str | None
) -> float:
    """fit-caption 策略：按打字机 + 轮播 + 标题入场推算建议时长（秒）。

    算法与模板导出的 ``capabilities.durationEstimator`` 等价（各分量并行、取 max、
    加收尾留白），但**每一个数值都来自模板声明的 ``capabilities.durationModel``**。
    模板没声明就不估 —— 返回 0，调用方沿用传入的 targetDuration 并收到一条 warning。
    宁可退回调用方的时长，也不拿一组从别的模板抄来的数字去替这个模板做决定。
    """
    if not isinstance(duration_model, dict) or not duration_model:
        print(
            f"⚠️  template '{template_id}' declares durationStrategy=fit-caption but no "
            "capabilities.durationModel, so gen_script cannot compute the fitted "
            "duration and falls back to the requested --duration.\n"
            "   Fix in the template (not here): add capabilities.durationModel with the "
            "component's own pacing numbers (headlineIntroSec / tailPaddingSec / "
            "caption.{charsPerSec,lineGapMs,startDelayMs,tailHoldSec,maxLines} / "
            "carousel.{transitionMs,defaultHoldSec,minPerImageSec,minPerVideoSec}).",
            file=sys.stderr,
        )
        return 0.0
    caption_sec = _estimate_caption_natural_sec(
        custom_payload.get("caption") or {}, duration_model.get("caption") or {}
    )
    carousel_sec = _estimate_carousel_min_sec(
        custom_payload.get("carousel") or {}, duration_model.get("carousel") or {}
    )
    headline_intro = duration_model.get("headlineIntroSec", 0)
    return max(headline_intro, caption_sec, carousel_sec) + duration_model.get("tailPaddingSec", 0)


def _estimate_fit_images_duration(sequence: dict, model: dict | None, fps: int = 30) -> float:
    """Apply template-declared image timing, in frames just like the renderer.

    Hold tiers and transition margins belong to durationModel.sequence; never
    infer them from a template id or silently use the caller's target duration.
    """
    timing = (model or {}).get("sequence", {})
    tiers = timing.get("holdSecByCount", [])
    count = len(sequence["items"])
    rule_hold = next((tier["holdSec"] for tier in tiers if count <= tier["maxItems"]), None)
    if rule_hold is None:
        raise ValueError("fit-images requires durationModel.sequence.holdSecByCount covering the image count")
    base_hold = sequence.get("defaultHoldSec", rule_hold)
    transition_ms = sequence.get("transitionMs", timing.get("transitionMs"))
    margin = timing.get("minHoldMarginFrames")
    if transition_ms is None or margin is None:
        raise ValueError("fit-images requires sequence transitionMs and durationModel.sequence.minHoldMarginFrames")
    # Python round uses ties-to-even; JS Math.round uses floor(x + 0.5).
    min_frames = max(2, math.floor(transition_ms / 1000 * fps + 0.5)) + margin
    return sum(max(min_frames, math.floor(item.get("holdSec", base_hold) * fps + 0.5))
               for item in sequence["items"]) / fps


def _build_carousel_caption_dsl(
    *,
    template_id: str,
    topic: str,
    headline: str,
    subheadline: str,
    carousel_items: list[str],
    caption_lines: list[str],
    duration: int,
    style: str,
    ratio: str,
    resolution: str,
    output_language: str,
    resolved_voice_id: str,
    narration_speed: float,
    font_id: str | None,
    font_name: str | None,
    narration_enabled: bool,
    payload_defaults: dict,
    duration_strategy: str | None,
    duration_model: dict | None,
) -> dict:
    """carousel-caption 装配（单场景、图/视频轮播 + 打字机文字）。

    所有差异来自归一化 contract（capabilities.payloadDefaults / durationStrategy /
    needsNarration），不再读 input-schema.json，也没有 if template_id==X 分支。
    轮播素材由调用方在 carousel_items 里直接给 URL（existing 资产），不触发 AI 生图。
    """
    defaults = deepcopy(payload_defaults or {})
    sequence_mode = isinstance(defaults.get("sequence"), dict)
    if sequence_mode and not carousel_items:
        raise ValueError("Image sequence needs --carousel-items; caption text alone cannot supply images")
    if sequence_mode and len(caption_lines) > len(carousel_items):
        raise ValueError("Image sequence has more --caption-lines than images; use at most one caption per image")

    # ── Build assets from carousel_items ──────────────────────────────────
    assets = []
    carousel_asset_ids = []
    for idx, url in enumerate(carousel_items):
        asset_id = f"carousel-img-{idx + 1:02d}"
        # Determine if URL is a video (basic heuristic on extension)
        is_video = urlsplit(url).path.lower().endswith((".mp4", ".mov", ".webm", ".avi"))
        if sequence_mode and is_video:
            raise ValueError("Image sequence requires image URLs in --carousel-items, not video URLs")
        assets.append({
            "assetId": asset_id,
            "type": "video" if is_video else "image",
            "source": "existing",
            "status": "generated",
            "url": url,
        })
        carousel_asset_ids.append((asset_id, "video" if is_video else "image"))

    # ── Narration asset (可选；仅在模板声明 narration.enabled 时产出) ───────
    narration_asset_id = "narration-scene-01"
    if narration_enabled:
        assets.append({
            "assetId": narration_asset_id,
            "type": "audio",
            "source": "gen-voice",
            "status": "planned",
            "payload": {
                "voiceId": resolved_voice_id,
            },
        })

    # ── Build carousel config ─────────────────────────────────────────────
    carousel_defaults = defaults.get("carousel", {})
    carousel_config = {
        "items": [],
        **carousel_defaults,
    }
    for asset_id, kind in carousel_asset_ids:
        item = {
            "assetRef": asset_id,
            "entrance": carousel_defaults.get("defaultEntrance", "random"),
            "exit": carousel_defaults.get("defaultExit", "random"),
        }
        if kind == "video":
            item["kind"] = "video"
            item["muted"] = True
        carousel_config["items"].append(item)

    # ── Build caption config ──────────────────────────────────────────────
    caption_defaults = defaults.get("caption", {})
    caption_config = {
        "lines": caption_lines if caption_lines else [],
        **caption_defaults,
    }
    # Override lines explicitly (defaults shouldn't clobber user-provided lines)
    if caption_lines:
        caption_config["lines"] = caption_lines

    # ── Build customPayload ───────────────────────────────────────────────
    #
    # background / headlineStyle 只在模板自己声明了 payloadDefaults 时才写进去。
    # 这里原本硬编码着 ``{"preset": "grid-particles"}`` / ``{"pill": True}`` 作为
    # 兜底 —— 那两个值是**某一个**模板的词汇（一个背景 preset 枚举成员 + 一个它
    # 顶部装饰的开关）。同为 carousel-caption 但没声明这两个键的模板既不认识也不读
    # 它们，等于往它的 DSL 里塞两坨死配置（payload 契约门禁只查"声明了却没人消费"，
    # 查不到"没声明却被硬塞进来"）；而下一个模板如果恰好也有 background、枚举却不同，
    # 拿到的就是一个它不认识的值。模板的默认值归模板声明，CLI 只负责透传。
    custom_payload = {
        "carousel": carousel_config,
        "caption": caption_config,
    }
    for key in ("background", "headlineStyle"):
        if defaults.get(key):
            custom_payload[key] = defaults[key]

    # The carousel-caption capability also covers full-frame image sequences.
    # Its declared sequence defaults select that payload shape; do not send
    # carousel entrance/exit or a typewriter caption block to such components.
    if sequence_mode:
        sequence_config = defaults["sequence"]
        sequence_config["items"] = [
            {"assetRef": asset_id,
             **({"caption": caption_lines[index]} if index < len(caption_lines) else {})}
            for index, (asset_id, _) in enumerate(carousel_asset_ids)
        ]
        custom_payload = {"sequence": sequence_config}

    # ── Narration skeleton ────────────────────────────────────────────────
    narration_text = _localize_label(output_language, "narration_opening",
                                     label="", topic=topic, duration=duration)

    # ── Effective scene duration ──────────────────────────────────────────
    # fit-caption 策略：时长由打字机自然节奏决定，而不是盲取调用方传入的 targetDuration。
    # 这是打字机/画面驱动模板的核心——没有旁白来"撑"时长，
    # 必须由 caption 自己定，否则文字打完后画面会静止到 targetDuration。
    effective_duration = duration
    if duration_strategy == "fit-images":
        if not sequence_mode:
            raise ValueError("fit-images requires capabilities.payloadDefaults.sequence")
        effective_duration = _estimate_fit_images_duration(custom_payload["sequence"], duration_model)
    if duration_strategy == "fit-caption":
        est = _estimate_fit_caption_duration(custom_payload, duration_model, template_id)
        if est > 0:
            effective_duration = int(math.ceil(est))
            print(
                f"ℹ️  fit-caption: scene duration {effective_duration}s "
                f"(estimated from caption/carousel; ignoring target {duration}s)",
                file=sys.stderr,
            )

    # ── Build scene (sceneStrategy=single: one scene carries everything) ──
    text_layers = [
        {"role": "headline", "content": headline, "animation": "slide-up"},
    ]
    if subheadline:
        text_layers.append({"role": "subheadline", "content": subheadline, "animation": "fade-in"})

    scene = {
        "id": "scene-01",
        "purpose": "opening",
        "duration": effective_duration,
        "layout": template_id,
        "textLayers": text_layers,
        "customPayload": custom_payload,
    }
    if narration_enabled:
        scene["audio"] = {
            "narration": {
                "text": narration_text,
                "assetRef": narration_asset_id,
                "needsFill": True,
            },
        }

    # ── Assemble full DSL ─────────────────────────────────────────────────
    effective_headline = headline or topic
    effective_subheadline = subheadline or ""

    dsl = {
        "version": "v1alpha1",
        "meta": {
            "title": topic,
            "topic": topic,
            "headline": effective_headline,
            "subheadline": effective_subheadline,
            "targetDuration": effective_duration,
            "language": LANGUAGE_TO_META.get(output_language, "zh-CN"),
            "outputLanguage": output_language,
            "style": style or "",
            "description": _localize_label(output_language, "video_description", topic=topic, duration=duration),
            "templateId": template_id,
        },
        "global": {
            "aspectRatio": ratio,
            "resolution": resolution,
            "fps": 30,
            # 无配音模板：关闭 CC 字幕（字幕段是 narration→TTS 的派生物），
            # 并省略 global.narration，避免下游误判存在旁白。
            "subtitle": {"enabled": narration_enabled, "style": "bottom"},
            **({"narration": {"voiceId": resolved_voice_id, "speed": narration_speed}} if narration_enabled else {}),
            **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
            "bgm": {"enabled": True, "volume": 0.12},
        },
        "assets": assets,
        "scenes": [scene],
        "transitions": {"default": "fade", "duration": 0.5},
    }

    return dsl


# 「作者还没选版式」的显式标记。
#
# 为什么是哨兵而不是留空:slideId 缺失会静默回落到 DefaultSlide —— 渲染成功、零日志、
# 退出码 0，画面只剩一行居中标题，正是要消灭的失效形态。哨兵让下游能区分「作者没选」
# 与「作者选了但拼错」，两者的修法不同。dsl_validator 见到它会硬拒绝（同名常量在
# video_dsl/runtime/dsl_validator.py，两处字面量必须一致）。
SLIDE_ID_SENTINEL = "__CHOOSE_SLIDE__"


def _is_multi_slide_template(template_config: dict | None) -> bool:
    """模板是否注册了多个可选版式 —— 由模板自己声明，不看模板 id。

    判据是 ``customPayloadSchema.slideId.enum`` 有没有超过一个取值：有得挑才谈得上
    「该挑哪个」。dsl_validator 的 ``_check_slide_id_chosen`` 用的是同一条判据，两侧
    因此不会对同一个模板给出相反的结论。

    **刻意不写死模板 id**：哪个模板有几种版式是模板自己的事实，真源在它的
    ``template.json``；把名单抄进 CLI，模板改名 / 新增多版式模板都要跟着发一次 npm，
    而漏发的表现是"这个模板又开始只出一种版式了"——正是本次要修的那个形态。
    """
    schema = (template_config or {}).get("customPayloadSchema")
    if not isinstance(schema, dict):
        return False
    slide_schema = schema.get("slideId")
    enum = slide_schema.get("enum") if isinstance(slide_schema, dict) else None
    return isinstance(enum, list) and len(enum) > 1


def _build_custom_payload(template_config: dict | None) -> dict:
    """骨架阶段的 customPayload —— **不猜版式、不拼画面文案**。

    这个函数曾经按 purpose 写死两种 slideId（opening / cta → demo-concept-overview，
    其余 → demo-single-concept），于是 16 种注册版式里只有 2 种会被用到，且与内容
    形态完全无关 —— 线上「不论选题版式永远一样」就是这么来的。它还往
    templateData.description 里拼 "关于{topic}的要点内容"，topic 长度不受控，画面
    溢出是必然的。

    现在分两档：

      - 多版式模板 → 只放哨兵，等 agent 按内容挑版式并填 templateData；
      - 其余 no-visual 模板 → 空载荷。这些模板只有一种版式，没有可挑的东西，而注入
        它们 schema 不认识的字段只会把真正要的 slide / statement / item 挤掉。
    """
    if _is_multi_slide_template(template_config):
        return {"slideId": SLIDE_ID_SENTINEL, "templateData": {}}
    return {}


def _plan_countdown_scenes(spec: dict, count: int, duration: float, headline: str) -> list:
    """``sceneStrategy=countdown``：一个开场 + N 个同类节拍，倒数编号，没有 cta。

    为什么不能复用 ``arc``：``plan_scenes`` 写死了「至少 3 个场景」（Top 1 只需要 2 个）、
    无条件在末尾 append 一个 ``cta``（榜单类模板往往不要片尾），而且 ``content_scenes
    = scene_count - 2``、编号从 #1 升序 —— 倒数要的正好相反。``fixed`` 则要一份静态
    场景表，表达不了动态的 N。所以 countdown 是 sceneStrategy 轴上的第四种形状，
    而不是某个模板的特例。
    """
    head_sec = countdown_opening_sec(spec, count, duration)
    beat_sec = (duration - head_sec) / count
    return [{"purpose": "opening", "duration": head_sec, "label": headline}] + [
        {"purpose": "point", "duration": beat_sec, "label": f"#{index}", "countdownIndex": index}
        for index in range(count, 0, -1)
    ]


def _warn_on_count_mismatch(headline: str, subheadline: str, count: int) -> None:
    """画面文字里写的数量与实际节拍数不一致时出声提醒（不阻断、不改写）。

    **刻意只 warn 不改写。** 这里曾经有一对 ``_rank_copy`` / ``_rank_title``，是
    github-repo-rank 的 ``payload.ts`` 里 ``formatRankCopy`` / ``formatRankTitle``
    的逐行 Python 移植 —— 同样的三个正则，在两个仓库、两种语言里各存一份。那是本文件
    在 ``_echo_template_llm_hint`` 里写明要避免的事：**CLI 出机制，模板出内容**。标题
    该怎么写、数量该以什么措辞出现（``Top 5`` / ``前 5 名`` / ``5 个项目``）是模板的
    版式事实，真源是模板自己。

    去掉那份移植不会让成片出错：模板在渲染时本来就会同步（github-repo-rank 在
    ``Opening.tsx`` / ``Cover.tsx`` 里做），而且它**必须**保留这个能力 —— 手写 DSL、
    历史计划、ab-web 改过的标题都不经过 gen_script。CLI 这一份自始至终只影响用户在
    确认脚本那一步看到的文字，为它复制一套会漂移的正则不值得。

    换成告警的另一个好处：静默改写调用方给的文案本身就不妥（同 ``_warn_on_url_text_layer``
    的分寸）。把数量告诉它，让它自己把文案写对，比替它改更不容易出意外。

    这里认的措辞（``Top N`` / ``N 个项目`` / ``前 N 名``）只用来**发现不一致**，
    不用来生成文案，所以它不构成一份需要跟模板同步的版式规则。
    """
    pattern = r"(?<![a-zA-Z0-9_])top\s*([0-9]+)(?![a-zA-Z0-9_])|(?:^|[^0-9])([0-9]+)\s*(?:个(?=\s*(?:开源项目|项目|仓库|工具))|(?=\s*名))"
    for flag, value in (("--headline", headline), ("--subheadline", subheadline)):
        stated = {int(g) for match in re.finditer(pattern, value or "", flags=re.I)
                  for g in match.groups() if g is not None}
        if stated and count not in stated:
            print(
                f"⚠️  {flag} says {sorted(stated)} item(s) but --item-count is {count}. "
                "The rendered beat count follows --item-count, so the on-screen text is the "
                "part that is wrong.\n"
                "   Rewrite it to state the count you asked for (or leave the count out "
                "and let the template append it). gen_script deliberately does not "
                "rewrite your copy: how this template words a count is declared by the "
                "template, not by the CLI.",
                file=sys.stderr,
            )


def build_dsl(
        topic: str,
        duration: int,
        style: str,
        ratio: str,
        resolution: str,
        voice_id: str,
        font_id: str | None,
        font_name: str | None,
        scene_count: int | None,
        allow_digital_human: bool,
        allow_ai_video: bool,
        speed: float | None = None,
        template_id: str | None = None,
        stub_image_url: str = "",
        stub_video_url: str = "",
        headline: str | None = None,
        subheadline: str | None = None,
        carousel_items: list[str] | None = None,
        caption_lines: list[str] | None = None,
        scene_images: list[str] | None = None,
        item_count: int | None = None,
) -> dict:
    """Build a complete Video DSL JSON.

    When template_id is provided, loads the template config and adapts the DSL
    structure to match the template's assetRequirements and scene patterns.

    模板差异全部来自归一化 contract（capabilities）：payloadStyle=carousel-caption
    走单场景轮播+打字机装配；其余按 sceneStrategy（arc/single/fixed）+ visual-overlay/
    slide 走统一场景循环。无 input-schema.json、无 if template_id==X 分支。

    headline / subheadline 让作者显式提供画面上的"短主标题 + 副标题"。
    DSL 元字段命名约定：
      - meta.headline    画面上的主标题（建议 4-12 字）。默认退回 topic
      - meta.subheadline 画面上的副标题（项目名 / 一句标语 / 来源）。默认 ""
    模板的 textLayers[role=headline|subheadline] 会取这两个字段，propExtractors
    通过 role=headline / role=subheadline 抽出对应的 props（如 titleText / projectName）。
    注意：subtitle（CC 字幕）由 global.subtitle 与 render-plan.subtitleSegments
    单独承载，与 subheadline 完全是两个东西，命名上刻意分开避免歧义。

    scene_images 是用户**自带的配图**（visual-overlay 模板专用），按顺序占位：第 i 张
    图给第 i 个场景，没被占到的场景照常走 gen-image 补图。这条规则不是随便定的——
    见 docs/asset-annotation-design.md §6：另外两种立场（模型自由挑选 / 严格一一对应）
    都会让"只传两张图"要么失去可预期性，要么直接跑不通。

    speed 是旁白语速倍率，落在 global.narration.speed（不给 = 1.0）。它**不写进
    gen-voice 资产的 payload**——与旁白文本同一条规矩：唯一来源在 DSL 上，
    render_video 在调 TTS 前按 assetRef 回查注入，免得两处存一个值各自漂移。
    """
    # Load template config（registry 单一数据源）→ 归一化 contract
    template_config = _load_template_config(template_id) if template_id else None
    contract = _resolve_contract(template_config)
    countdown = contract["countdown"]
    if countdown is None and item_count is not None:
        raise ValueError(
            "--item-count requires a template declaring capabilities.sceneStrategy=countdown"
        )
    beat_count = resolve_countdown_count(countdown, item_count, scene_count) if countdown else None

    primary_visual_type = contract["primary_visual_type"]
    payload_style = contract["payload_style"]
    # visual-overlay 模板才生成视觉素材并用 visuals.background；slide / carousel-caption 不走。
    has_visual = payload_style == "visual-overlay"
    # 旁白是模板能力（capabilities.needsNarration）。声明为 false 的模板（纯视觉 /
    # BGM-only，如单图 Ken Burns）不分配 gen-voice 素材、场景不写 audio.narration。
    needs_narration = contract["needs_narration"]

    # ── Resolve template-driven output language + voice ────────────────────
    # outputLanguage is owned by the template (template.json). Missing/invalid
    # values fall back to "zh" so old templates keep their current behavior.
    output_language, template_default_voice_id = _resolve_template_language_meta(template_config)
    resolved_voice_id = _resolve_voice_id(voice_id, template_default_voice_id, output_language)
    narration_speed = _resolve_narration_speed(speed)
    if resolved_voice_id != voice_id:
        print(
            f"ℹ️  voice resolution: cli={voice_id!r} → resolved={resolved_voice_id!r} "
            f"(templateDefault={template_default_voice_id!r}, language={output_language})",
            file=sys.stderr,
        )

    # 缺省值：headline 退回 topic，subheadline 默认空字符串
    effective_headline = _normalize_linebreaks(headline) or topic
    effective_subheadline = _normalize_linebreaks(subheadline)
    _echo_template_llm_hint(template_config, template_id)
    _warn_on_url_text_layer(effective_headline, effective_subheadline)
    if beat_count is not None:
        _warn_on_count_mismatch(effective_headline, effective_subheadline, beat_count)

    # ── carousel-caption 模板（单场景、图/视频轮播 + 打字机）单独装配并直接返回 ──
    if payload_style == "carousel-caption":
        print(
            f"ℹ️  contract: payloadStyle=carousel-caption for template {template_id}",
            file=sys.stderr,
        )
        # 兜底校验：carousel-caption 模板的画面由 carousel_items（图片/视频 URL）驱动，
        # 文字由 caption_lines 驱动。两者皆空时会生成一个空轮播 + 空字幕的退化场景——
        # 渲染出来就是「黑屏 + fit-caption 退化成最短 2s」。这是调用方（agent）忘了
        # 把用户提供的图片塞进 --carousel-items 的典型表现，必须显式报错而不是静默产出。
        if not (carousel_items or []) and not (caption_lines or []):
            print(
                "❌ carousel-caption template "
                f"'{template_id}' needs visual or text content, but received neither "
                "--carousel-items nor --caption-lines.\n"
                "   This template renders a carousel of user-supplied media; with no items "
                "it produces a black, minimum-length (2s) clip.\n"
                "   Fix: pass the user's image/video URLs via --carousel-items "
                "(repeat the flag per item), e.g.\n"
                "     gen_script.py --topic <topic> --template-id "
                f"{template_id} --carousel-items <url1> --carousel-items <url2> ...\n"
                "   Optionally add --caption-lines '<text>' for on-screen typewriter captions.",
                file=sys.stderr,
            )
            sys.exit(1)
        # 兜底校验 ②：durationStrategy=fit-caption 的模板由打字机
        # 文案驱动节奏 —— caption 就是内容本体，不是可选装饰。caption_lines 为空时上面的
        # "两者皆空" 检查放行，产出的却是「顶部标题 + 轮播、底部一个字都没有」的退化片：
        # 估时掉到轮播地板值（2 张图 ≈ 5s），而调用方往往还在确认摘要里描述了一段
        # 根本没进 DSL 的文案，用户在确认环节也看不出来。所以这里必须硬失败。
        # 注意：只卡 fit-caption。carousel-caption 里 durationStrategy=fit-images 的
        # 纯视觉模板本来就允许无文案，不受影响。
        if contract["duration_strategy"] == "fit-caption" and not (caption_lines or []):
            print(
                "❌ template "
                f"'{template_id}' is typewriter-driven (capabilities.durationStrategy="
                "fit-caption), but --caption-lines is empty.\n"
                "   For this template the bottom typewriter copy IS the content: it carries "
                "the message and it decides the video length. With no lines the render "
                "collapses to the carousel floor (~5s for 2 images) and shows no text at all.\n"
                "   Fix: pass one --caption-lines '<text>' per line. If the user did not "
                "supply the copy, WRITE IT YOURSELF from the material you researched "
                "(repo README, page screenshots, the user's topic) and pass it — do not leave "
                "it empty, and do not describe lines you never passed.\n"
                "     gen_script.py --topic <topic> --template-id "
                f"{template_id} --carousel-items <url> "
                "--caption-lines '<line 1>' --caption-lines '<line 2>' ...",
                file=sys.stderr,
            )
            sys.exit(1)
        carousel_dsl = _build_carousel_caption_dsl(
            template_id=template_id,
            topic=topic,
            headline=effective_headline,
            subheadline=effective_subheadline,
            carousel_items=carousel_items or [],
            caption_lines=caption_lines or [],
            duration=duration,
            style=style,
            ratio=ratio,
            resolution=resolution,
            output_language=output_language,
            resolved_voice_id=resolved_voice_id,
            narration_speed=narration_speed,
            font_id=font_id,
            font_name=font_name,
            narration_enabled=needs_narration,
            payload_defaults=contract["payload_defaults"],
            duration_strategy=contract["duration_strategy"],
            duration_model=contract["duration_model"],
        )
        _enforce_supported_duration(carousel_dsl, template_config, template_id)
        return carousel_dsl

    # ── 其余模板：统一场景规划（arc 叙事弧 / single / fixed）+ 统一装配循环 ──────
    #
    # scene_images 的每一项要么是一条 URL（用已有素材），要么是 `ai` / `ai:<提示词>`
    # （这一段留给 gen-image）。两者共用同一个位置序列 —— 用户在界面上排出来的顺序
    # 就是它，AI 段能插在任意位置，而不是只能挂在末尾。
    provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]

    if beat_count is not None:
        scene_plans = _plan_countdown_scenes(countdown, beat_count, duration, effective_headline)
    else:
        scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)

    # 条目比场景多时**抬高**场景数，让每一条都有地方放。
    #
    # 只抬高、不压低：给 2 张图不该把一条 30s 的片子从 5 段压成 3 段。用户给图表达的是
    # "这几张都要用上"，不是"整条片子改成这么多段"——按条目数直接改写场景数，会让
    # "多传了两张图"变成"视频结构被换掉了"，而他根本没要求这个。
    #
    # 反过来条目多于场景时必须抬：不抬的话第 N+1 条之后会被静默丢掉，而用户在成片里是
    # 看不出"我的图去哪了"的。arc 之外的策略场景数由模板钉死，抬不动（下面警告兜底）。
    if (
        provided_slots
        and scene_count is None
        and contract["scene_strategy"] == "arc"
        and primary_visual_type == "image"
        and has_visual
        and len(provided_slots) > len(scene_plans)
    ):
        scene_plans = _plan_contract_scenes(
            contract, topic, duration, len(provided_slots), output_language
        )

    # 排完之后仍然装不下的（single / fixed 模板，或用户显式指定了更少的场景数）就明说。
    # 静默丢弃用户自带的素材是这条链上最难被发现的一类错。
    if provided_slots and len(provided_slots) > len(scene_plans):
        print(
            f"\u26a0\ufe0f  {len(provided_slots)} scene-image entries provided but the template "
            f"plans only {len(scene_plans)} scene(s); the extra "
            f"{len(provided_slots) - len(scene_plans)} will not be used. "
            f"Raise --scenes or pick a template with more scenes.",
            file=sys.stderr,
        )

    assets = []
    scenes = []

    for idx, plan in enumerate(scene_plans):
        scene_id = f"scene-{idx + 1:02d}"
        # 视觉素材的 assetId 命名遵循类型前缀：image → img-，video → video-
        if primary_visual_type == "video":
            visual_asset_id = f"video-{scene_id}"
        else:
            visual_asset_id = f"img-{scene_id}"
        narration_asset_id = f"narration-{scene_id}" if needs_narration else None
        narration_text = (
            estimate_narration_text(plan["label"], plan["purpose"], plan["duration"], topic, language=output_language)
            if needs_narration else ""
        )

        # 仅 visual-overlay 模板生成视觉素材（slide 无视觉素材，carousel-caption 已提前返回）
        if has_visual and primary_visual_type == "image":
            slot = provided_slots[idx] if idx < len(provided_slots) else ""
            slot_is_ai, slot_prompt = _parse_ai_slot(slot)
            if slot and not slot_is_ai:
                # 用户自带的图排在 stub 之前：stub 是"别烧配额"的测试开关，而用户给的
                # 图同样一分钱不花，没有理由拿占位图把真素材盖掉。
                assets.append({
                    "assetId": visual_asset_id,
                    "type": "image",
                    "source": "existing",
                    "status": "generated",
                    "url": slot,
                })
            # stub 盖得住 AI 段，盖不住用户自带的图。差别在于花不花钱：--stub-image-url
            # 的全部意义就是"这一趟别调 gen-image"，而 AI 段恰恰是要调的那种；反过来
            # 用户自带的 URL 一分钱不花，拿占位图把真素材盖掉纯属损失。
            elif stub_image_url:
                assets.append({
                    "assetId": visual_asset_id,
                    "type": "image",
                    "source": "existing",
                    "status": "generated",
                    "url": stub_image_url,
                })
            else:
                image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)

                # 用户在 AI 段里写了提示词就用他的。**只替换主体，不动 negativePrompt /
                # guidanceScale**：那两项是模板的护栏（"不要文字水印、不要变形"），把它们
                # 一起丢掉会让用户随手写的一句话换来一张带乱码文字的图，而他并没有要求
                # 关掉护栏——他只是想说这一段画什么。
                if slot_prompt:
                    image_result = {**image_result, "prompt": slot_prompt}

                img_payload = {
                    "prompt": image_result["prompt"],
                    "model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
                    "ratio": ratio,
                }
                if "negativePrompt" in image_result:
                    img_payload["negativePrompt"] = image_result["negativePrompt"]
                if "guidanceScale" in image_result:
                    img_payload["guidanceScale"] = image_result["guidanceScale"]

                assets.append({
                    "assetId": visual_asset_id,
                    "type": "image",
                    "source": "gen-image",
                    "status": "planned",
                    "payload": img_payload,
                })
        elif has_visual and primary_visual_type == "video":
            if stub_video_url:
                assets.append({
                    "assetId": visual_asset_id,
                    "type": "video",
                    "source": "existing",
                    "status": "generated",
                    "url": stub_video_url,
                })
            else:
                # 视频素材：复用 image prompt 生成器作为兜底，再追加 "视频/动态" 关键词
                # 模型 / 时长 / 比例都遵循 gen-video 校验规则（Seedance 2.0：4-15s）
                image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)
                video_prompt = image_result["prompt"]
                # 视频时长按 gen-video 的 4-15s 区间截断
                video_duration = max(4, min(int(plan["duration"]), 15))
                vid_payload = {
                    "prompt": video_prompt,
                    # 写别名而不是带日期的模型 ID：真源是 ab-api 的能力目录，
                    # 版本号换代时这里不该跟着改（seedance → 当前的 Seedance 2.0）
                    "model": os.environ.get("DEFAULT_VIDEO_MODEL", "seedance"),
                    "ratio": ratio,
                    "duration": video_duration,
                }
                assets.append({
                    "assetId": visual_asset_id,
                    "type": "video",
                    "source": "gen-video",
                    "status": "planned",
                    "payload": vid_payload,
                })

        # narration text 不再在 audio asset 的 payload 里冗余存放——
        # 唯一来源是下游 scenes[].audio.narration.text，render_video 在
        # 调用 TTS skill 前会按 assetRef 回查 scene 文本注入。
        # 模板声明 needsNarration=false 时，完全不生成 gen-voice 素材。
        if needs_narration:
            assets.append({
                "assetId": narration_asset_id,
                "type": "audio",
                "source": "gen-voice",
                "status": "planned",
                "payload": {
                    "voiceId": resolved_voice_id,
                },
            })

        if has_visual:
            layout = "text-overlay" if plan["purpose"] in ("opening", "cta") else "full-visual"
            text_layers = []
            if plan["purpose"] == "opening":
                text_layers = [
                    {"role": "headline", "content": effective_headline, "animation": "slide-up"},
                ]
                if effective_subheadline:
                    text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
            elif plan["purpose"] == "point":
                text_layers = [
                    {"role": "badge", "content": plan["label"], "animation": "slide-up"},
                ]
                # 给所有 point 场景补一个 headline textLayer：凡是把 titleText 从
                # textLayers[role=headline] 抽出来的 propExtractors 都能直接拿到主标题，
                # 不再依赖 binding 的兜底逻辑。
                text_layers.insert(0, {"role": "headline", "content": effective_headline, "animation": "fade-in"})
                if effective_subheadline:
                    text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
            elif plan["purpose"] == "cta":
                text_layers = [
                    {"role": "headline", "content": _localize_label(output_language, "follow_us"), "animation": "fade-in"},
                ]
                if effective_subheadline:
                    text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})

            scene = {
                "id": scene_id,
                "purpose": plan["purpose"],
                "duration": plan["duration"],
                "layout": layout,
                "visuals": {"background": {"assetRef": visual_asset_id}},
                "textLayers": text_layers,
                "animationHints": {
                    "entrance": "fade",
                    "motion": "kenburns-in" if idx % 2 == 0 else "kenburns-out",
                },
            }
            if needs_narration:
                scene["audio"] = {
                    "narration": {
                        "text": narration_text,
                        "assetRef": narration_asset_id,
                        # 骨架标记：scenes[].audio.narration.text 同样需要被真实旁白替换
                        "needsFill": True,
                    },
                }
            scenes.append(scene)
        else:
            # The "no-visual" branch labels each scene with a layout hint:
            #   1. template.capabilities.defaultLayout   ⇐ the template tells
            #      remixmate what layout name it wants on its no-visual scenes
            #   2. template_id                            ⇐ legacy: pass id as
            #      layout name (existing behavior when no capability declared)
            #   3. "text-overlay"                         ⇐ no-template case
            #
            # ⚠️ 第 3 档以前硬编码着模板库里某个具体模板的 id —— 没选模板时，每个纯
            # 音频场景都被贴上那个名字。那是这份脚本唯一一处"认识某个模板"的可执行代码：
            # 没有模板参与的通用 DSL 不该知道模板库里有谁，模板换名 / 下架都会让这个
            # 字面量变成谎话。改成 schema 自己的枚举值（Scene.layout 的合法取值是
            # full-visual / text-overlay / … 这一组，模板 id 从来就不在里面，写进去反而
            # 会让 dsl_validator 报一条 layout 枚举 warning）。
            #
            # 这个字段是纯描述性的：match_template 只把它抄进 binding 的 layoutVariant，
            # 而 layoutVariant 在 ab-render 与 template-library 里都没有任何读取方
            # （已核对），渲染走的是 slotMapping / compositionId。所以换值不改成片。
            _layout_cfg = (template_config or {}).get("capabilities") or {}
            layout = _layout_cfg.get("defaultLayout") or template_id or "text-overlay"
            # ⚠️ 画面文字用 effective_headline / effective_subheadline，与上面的
            # has_visual 分支同源。这两条分支长期分叉：visual 分支一直用的是
            # --headline，no-visual 分支却渲染裸 topic —— SKILL.md 写着"用户给了标题
            # 就传 --headline，否则 headline 会回落成长 topic 并撑破顶部文字层"，
            # agent 照做了，这条分支没兑现。调用方把整篇提纲当 topic 传进来时，成片
            # 必然是一屏文字墙，而 meta.headline 里存的又是对的，只看 meta 查不出来。
            text_layers = []
            if plan["purpose"] == "opening":
                text_layers = [
                    {"role": "headline", "content": effective_headline, "animation": "fade-in"},
                ]
                if effective_subheadline:
                    text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
            elif plan["purpose"] == "point":
                text_layers = [
                    {"role": "headline", "content": plan["label"], "animation": "slide-up"},
                ]
            elif plan["purpose"] == "cta":
                text_layers = [
                    {"role": "headline", "content": _localize_label(output_language, "follow_us"), "animation": "zoom-in"},
                ]
                if effective_subheadline:
                    text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})

            custom_payload = _build_custom_payload(template_config)
            if beat_count is not None:
                # Field names come from the template's countdown declaration — this
                # function does not know what a "rank" or a "topN" is.
                custom_payload.update(
                    countdown_scene_payload(countdown, beat_count, plan.get("countdownIndex"))
                )

            scene = {
                "id": scene_id,
                "purpose": plan["purpose"],
                "duration": plan["duration"],
                "layout": layout,
                "textLayers": text_layers,
            }
            # 单版式模板的载荷是空 dict —— 写一个空 customPayload 只会让 agent 以为
            # 这里有个需要填的结构，干脆不写这个键。
            if custom_payload:
                scene["customPayload"] = custom_payload
            if needs_narration:
                scene["audio"] = {
                    "narration": {
                        "text": narration_text,
                        "assetRef": narration_asset_id,
                        # 骨架标记：scenes[].audio.narration.text 同样需要被真实旁白替换
                        "needsFill": True,
                    },
                }
            scenes.append(scene)

    dsl = {
        "version": "v1alpha1",
        "meta": {
            "title": topic,
            "topic": topic,
            "headline": effective_headline,
            "subheadline": effective_subheadline,
            "targetDuration": duration,
            "language": LANGUAGE_TO_META[output_language],
            "outputLanguage": output_language,
            "style": style or "",
            "description": _localize_label(output_language, "video_description", topic=topic, duration=duration),
        },
        "global": {
            "aspectRatio": ratio,
            "resolution": resolution,
            "fps": 30,
            # 无配音模板：关闭 CC 字幕（字幕段是 narration→TTS 的派生物），并省略
            # global.narration，避免下游误判存在旁白。
            "subtitle": {"enabled": needs_narration, "style": "bottom"},
            **({"narration": {"voiceId": resolved_voice_id, "speed": narration_speed}} if needs_narration else {}),
            **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
            "bgm": {"enabled": True, "volume": 0.12},
        },
        "assets": assets,
        "scenes": scenes,
        "transitions": {"default": "fade", "duration": 0.5},
    }

    if template_id:
        dsl["meta"]["templateId"] = template_id

    _enforce_supported_duration(dsl, template_config, template_id)

    return dsl


def main():
    parser = argparse.ArgumentParser(
        description="Video script generator — produces Video DSL v1alpha1 JSON",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python gen_script.py --topic "3 AI study hacks" --duration 30 --ratio "9:16"
  python gen_script.py --validate --input my-video.dsl.json
        """,
    )
    parser.add_argument("--topic", help="Video topic (required for generation mode)")
    parser.add_argument("--duration", type=int, default=30, help="Target duration in seconds (default: 30)")
    parser.add_argument("--style", default="", help="Style tag")
    parser.add_argument("--ratio", default=None, choices=VALID_RATIOS, help="Aspect ratio. When omitted: if --template-id is set, use the template's supportedAspectRatios[0]; otherwise default 16:9.")
    parser.add_argument("--resolution", default="1080p", help="Resolution (default: 1080p)")
    parser.add_argument("--item-count", type=int, default=None, help="Number of content beats, for templates declaring capabilities.sceneStrategy=countdown (Top-N lists, '5 个技巧', '7 个常见错误'). Produces one opening plus N scenes counting down from #N to #1. Bounds and the default are declared by the template — this flag has no range of its own. Pass the count the user asked for, NOT the total scene count; --scenes is the total and the two are cross-checked.")
    parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned)")
    parser.add_argument("--voice-id", default=None, help="Narration voice id. When omitted, the resolver picks template.defaultVoiceId, then the language-keyed fallback (zh→Chinese (Mandarin)_Male_Announcer, en→TBD English voice). gen_voice --list-voices prints the live catalog.")
    parser.add_argument(
        "--speed",
        type=float,
        default=None,
        help=(
            f"Narration speech rate ({SPEED_MIN}-{SPEED_MAX}, default {DEFAULT_NARRATION_SPEED}). "
            "Stored at global.narration.speed and applied by render_video when it calls gen-voice. "
            "Above ~1.3 subtitle alignment starts to drift and the delivery turns mechanical — "
            "shorten the script instead."
        ),
    )
    parser.add_argument(
        "--font-id",
        default=None,
        help=(
            "Font family key from the font library (font.uniq_id, e.g. zzgf-xi-mai). "
            "Omit to follow the template's own font pairing. "
            "Pass the opaque key, NOT a CSS family name: the family name is a rendering "
            "detail resolved by ab-render; a mistyped family name silently falls back to "
            "the default font with no error."
        ),
    )
    parser.add_argument("--font-name", default=None, help="Human-readable font name. Logged and stored for display only; never used for rendering.")
    parser.add_argument("--allow-digital-human", action="store_true", help="Allow digital-human assets")
    parser.add_argument("--allow-ai-video", action="store_true", help="Allow AI-generated video assets")
    parser.add_argument("--template-id", default=None, help="Template id. The template owns outputLanguage and may declare a defaultVoiceId.")
    parser.add_argument("--validate", action="store_true", help="Validate-only mode: only validate the input DSL")
    parser.add_argument("--input", "-i", help="Input DSL file path (required for validate mode)")
    parser.add_argument(
        "--headline",
        default=None,
        help="On-screen headline (recommended 4-12 chars / ~3 words). Stored at meta.headline and pushed into textLayers[role=headline]. Falls back to topic when not provided.",
    )
    parser.add_argument(
        "--subheadline",
        default=None,
        help="On-screen subheadline / project name (e.g. 'Pixelle-Video'). Stored at meta.subheadline and pushed into textLayers[role=subheadline]. Independent from CC subtitles (global.subtitle).",
    )
    parser.add_argument(
        "--skip-asset-generation",
        action="store_true",
        help="Skill-creator / template-creator helper: when set, every produced AssetRef is marked as already generated with a stub URL — no gen-image / gen-voice / gen-video calls are needed. Implies --stub-image-url + --stub-video-url with default sentinels (https://placeholder.local/stub.png|.mp4) when those flags are absent, and additionally rewrites every gen-voice asset to source=existing + status=generated + a placeholder audio URL. Useful when a downstream agent only wants the DSL shape (e.g. to feed into try_render with all assets pre-stubbed).",
    )
    parser.add_argument(
        "--stub-image-url",
        default=None,
        help="Test mode: every image AssetRef is written as source=existing, status=generated, url=<this URL> (no prompt; no gen-image call). STUB_IMAGE_URL env var also works but the CLI flag is preferred to avoid cross-session leakage.",
    )
    parser.add_argument(
        "--stub-video-url",
        default=None,
        help="Test mode: every video AssetRef is written as source=existing, status=generated, url=<this URL> (no prompt; no gen-video call). STUB_VIDEO_URL env var also works but the CLI flag is preferred to avoid cross-session leakage.",
    )
    parser.add_argument(
        "--carousel-items",
        action="append",
        default=None,
        help="Media URL for the template's carousel/gallery. Can be repeated: "
             "--carousel-items url1 --carousel-items url2. "
             "For templates declaring capabilities.payloadStyle=carousel-caption, "
             "these URLs are placed directly into "
             "customPayload.sequence.items when payloadDefaults.sequence is declared, otherwise "
             "customPayload.carousel.items, as existing assets without AI image generation.",
    )
    parser.add_argument(
        "--scene-images",
        action="append",
        default=None,
        help="One scene's visual for a visual-overlay template (image-slide etc.). "
             "Can be repeated: --scene-images url1 --scene-images ai --scene-images url2. "
             "Each entry is either a URL (use that existing asset) or the literal 'ai' / "
             "'ai:<prompt>' (leave this scene to gen-image, optionally with the user's prompt). "
             "Mapping is POSITIONAL: the i-th entry is the i-th scene's background, so an AI "
             "scene can sit anywhere in the order, not just at the end; scenes past the last "
             "entry still get an AI-generated image. When --scenes is not given the scene count "
             "is raised (never lowered) to fit the entries. "
             "Not for carousel-caption templates — those take --carousel-items instead.",
    )
    parser.add_argument(
        "--caption-lines",
        action="append",
        default=None,
        help="On-screen caption line. Can be repeated: "
             "--caption-lines 'line1' --caption-lines 'line2'. "
             "For templates declaring capabilities.payloadStyle=carousel-caption, "
             "these are placed into customPayload.caption.lines, or sequence.items[].caption "
             "in image order when payloadDefaults.sequence is declared. "
             "Line count limits and whether **emphasis** is parsed are declared per "
             "template — see the chosen template's llmHint.",
    )

    args = parser.parse_args()

    if args.validate:
        if not args.input:
            print("❌ validate mode requires --input", file=sys.stderr)
            sys.exit(1)
        if not os.path.exists(args.input):
            print(f"❌ file not found: {args.input}", file=sys.stderr)
            sys.exit(1)
        with open(args.input, "r", encoding="utf-8") as f:
            dsl = json.load(f)
        errors = validate_dsl(dsl)
        if errors:
            print("❌ DSL validation failed:", file=sys.stderr)
            for err in errors:
                print(f"   - {err}", file=sys.stderr)
            sys.exit(1)
        else:
            print("✅ DSL validation passed")
            return

    if not args.topic:
        print("❌ please provide --topic", file=sys.stderr)
        parser.print_help()
        sys.exit(1)

    if args.speed is not None and not (SPEED_MIN <= args.speed <= SPEED_MAX):
        print(
            f"❌ --speed must be in the range {SPEED_MIN}~{SPEED_MAX} (got {args.speed}).\n"
            "   Speech rate is a multiplier, not a percentage: 1.0 is the voice's own pace, "
            "1.1 is slightly brisker.",
            file=sys.stderr,
        )
        sys.exit(1)

    # ── 模板感知的 ratio 自动推导 ────────────────────────────────────────────
    # 用户没显式传 --ratio 时：
    #   1. 若指定了 --template-id，取模板 supportedAspectRatios[0]（避免横竖屏不匹配）
    #   2. 否则回退默认 16:9
    resolved_ratio = args.ratio
    if resolved_ratio is None:
        if args.template_id:
            tpl_cfg = _load_template_config(args.template_id)
            preferred = _template_supported_ratio(tpl_cfg)
            if preferred:
                resolved_ratio = preferred
                print(
                    f"ℹ️  using aspect ratio {preferred} from template {args.template_id}",
                    file=sys.stderr,
                )
            else:
                resolved_ratio = "16:9"
                if tpl_cfg is None:
                    print(
                        f"⚠️  template {args.template_id} not found; falling back to 16:9",
                        file=sys.stderr,
                    )
        else:
            resolved_ratio = "16:9"
    args.ratio = resolved_ratio

    print(f"📝 generating video script...", file=sys.stderr)
    print(f"   topic: {args.topic}", file=sys.stderr)
    print(f"   duration: {args.duration}s", file=sys.stderr)
    print(f"   ratio: {args.ratio}", file=sys.stderr)
    if args.speed is not None:
        print(f"   narration speed: {args.speed}x", file=sys.stderr)

    # CLI flag takes precedence; env vars act as fallback with a visible warning
    # so silent cross-session leakage is always observable.
    stub_image_url = args.stub_image_url
    stub_video_url = args.stub_video_url
    if stub_image_url is None:
        env_v = os.environ.get("STUB_IMAGE_URL", "")
        if env_v:
            print(f"⚠️  STUB_IMAGE_URL env var detected ({env_v}); using it as the image stub. Prefer passing --stub-image-url explicitly, or unset the env var.", file=sys.stderr)
        stub_image_url = env_v
    if stub_video_url is None:
        env_v = os.environ.get("STUB_VIDEO_URL", "")
        if env_v:
            print(f"⚠️  STUB_VIDEO_URL env var detected ({env_v}); using it as the video stub. Prefer passing --stub-video-url explicitly, or unset the env var.", file=sys.stderr)
        stub_video_url = env_v

    # --skip-asset-generation 是为下游"只想要 DSL shape"的 agent 设计的
    # 一键开关：等价于 --stub-image-url + --stub-video-url + 把 gen-voice 资产
    # 也写成 source=existing + status=generated。当用户没显式提供 stub URL 时
    # 用一组 sentinel 占位（https://placeholder.local/...），模板创作 / 调试场景
    # 不会真的去 fetch 这些 URL。
    if args.skip_asset_generation:
        if not stub_image_url:
            stub_image_url = "https://placeholder.local/stub.png"
        if not stub_video_url:
            stub_video_url = "https://placeholder.local/stub.mp4"
        print(
            "ℹ️  --skip-asset-generation: forcing all assets to source=existing/status=generated "
            f"(image={stub_image_url}, video={stub_video_url}, audio=https://placeholder.local/stub.mp3)",
            file=sys.stderr,
        )

    try:
        dsl = build_dsl(
            topic=args.topic,
            duration=args.duration,
            style=args.style,
            ratio=args.ratio,
            resolution=args.resolution,
            voice_id=args.voice_id,
            speed=args.speed,
            font_id=args.font_id,
            font_name=args.font_name,
            scene_count=args.scenes,
            allow_digital_human=args.allow_digital_human,
            allow_ai_video=args.allow_ai_video,
            template_id=args.template_id,
            stub_image_url=stub_image_url,
            stub_video_url=stub_video_url,
            headline=args.headline,
            subheadline=args.subheadline,
            carousel_items=args.carousel_items,
            caption_lines=args.caption_lines,
            scene_images=args.scene_images,
            item_count=args.item_count,
        )
    except ValueError as exc:
        # Covers both a bad --item-count and an unusable countdown declaration
        # (CountdownError subclasses ValueError), so an authoring mistake surfaces
        # as a message instead of a traceback.
        parser.error(str(exc))

    # Post-process for --skip-asset-generation: rewrite all gen-voice / gen-digital-human
    # assets to be already-generated stubs. Image / video are already covered by the
    # stub_image_url / stub_video_url params threaded through build_dsl above.
    if args.skip_asset_generation:
        _AUDIO_STUB = "https://placeholder.local/stub.mp3"
        _VIDEO_STUB = stub_video_url  # same sentinel for digital-human placeholder
        for asset in dsl.get("assets", []):
            src = asset.get("source")
            if src in ("gen-voice",):
                asset["source"] = "existing"
                asset["status"] = "generated"
                asset["url"] = _AUDIO_STUB
                # payload 留作 reference（renderer 不会再读它，因为 status=generated）
            elif src in ("gen-digital-human",):
                asset["source"] = "existing"
                asset["status"] = "generated"
                asset["url"] = _VIDEO_STUB

    errors = validate_dsl(dsl)
    if errors:
        print("❌ generated DSL failed validation:", file=sys.stderr)
        for err in errors:
            print(f"   - {err}", file=sys.stderr)
        sys.exit(1)

    # Non-blocking language sanity check — only narrations from scenes[].
    expected_language = dsl.get("meta", {}).get("outputLanguage", DEFAULT_LANGUAGE)
    if expected_language in ("en", "zh"):
        narrations_for_check = [
            ((scene.get("audio") or {}).get("narration") or {}).get("text", "")
            for scene in dsl.get("scenes", [])
        ]
        _check_narration_language(narrations_for_check, expected_language)

    output_json = json.dumps(dsl, ensure_ascii=False, indent=2)
    print(output_json)

    scene_count = len(dsl["scenes"])
    asset_count = len(dsl["assets"])
    total_duration = sum(s.get("duration", 0) for s in dsl["scenes"])
    print(f"\n📊 script summary: {scene_count} scenes, {asset_count} assets, total {total_duration}s", file=sys.stderr)
    print("🎬 script generation complete!", file=sys.stderr)


if __name__ == "__main__":
    try:
        main()
    except TemplateStatusGatedError as exc:
        # 状态门控挡住了一个确实在线的模板。这是配置问题，不是调用方参数错误，
        # 所以给一条可执行的提示而不是 traceback —— 并且**必须**非零退出：静默
        # 退回通用骨架正是这次要消灭的失败形态。
        print(f"❌ {exc}", file=sys.stderr)
        sys.exit(1)
