Skill: zhaobod1/huo15-comic-dub (version 0.1.0)

--- name: huo15-comic-dub displayName: 火15 漫剧-TTS 配音 description: 读 script.json 对白，按角色分配音色，调火山方舟 Seed-TTS 生成每条对白的 wav，命名 {sceneId}_{charId}_{idx}.wav。触发词：对白配音、TTS 配音、角色配音。 version: 0.1.0 --- # 火15 漫剧-TTS 配音 Skill > 每条 dialogue → 一个 wav，角色音色固定。 --- ## 输入 / 输出 ```bash python scripts/dub.py \ --script output/demo/script.json \ --out-dir output/demo/audio ``` 输出： ``` audio/ ├── S01_C1_0.wav ├── S02_C1_0.wav ├── S02_C2_0.wav ├── ... └── manifest.json ``` ## 音色库（Seed-TTS 已内置） - 男：`zh_male_qingnian`（青年）/ `zh_male_shenchen`（沉稳） / `zh_male_wennuan`（温暖） - 女：`zh_female_qingxin`（清新）/ `zh_female_wenrou`（温柔） / `zh_female_ganxing`（感性） 若 script.json 的 character.voice 为空，按角色 age/personality 自动分配。 ## 成本 字数 × ¥0.0008/字。200 字对白 ≈ ¥0.16。

path: _meta.json
content: ownerId: kn7byevkn40d6z4p7ghdb097z983tj33
slug: huo15-comic-dub
version: 0.1.0

path: scripts/_shared/__init__.py
content: """huo15-ai-comics 共享库。

所有子 skill 通过 sys.path 注入此模块（dual-path fallback）：

    HERE = pathlib.Path(__file__).resolve()
    # 优先 bundled（clawhub 独立安装场景：scripts/_shared/）
    # fallback monorepo 根（本仓库 dev 场景：../../../_shared/）
    for _cand in (HERE.parent / "_shared", HERE.parents[2] / "_shared"):
        if (_cand / "config.py").exists():
            sys.path.insert(0, str(_cand))
            break

    from cost_guard import CostGuard
    from checkpoint import Checkpoint
    from ark_api import ArkClient
    from config import DEFAULTS, MODELS

publish.sh 会在发布前把本目录 cp 到每个 `<skill>/scripts/_shared/`，
让独立安装的 skill 仍能解析 import。
"""


path: scripts/_shared/ark_api.py
content: """火山方舟 API 薄封装：Seedream 4.0 图 / Seedance 2.0 视频 / 豆包大模型 TTS.

2026-04 核对。文档：
- Seedance: https://www.volcengine.com/docs/82379/1520757
- Seedream: doubao-seedream-4-0-250828
- TTS: https://www.volcengine.com/docs/6561/97465
"""
from __future__ import annotations

import base64
import os
import pathlib
import time

import requests

try:
    from config import ENDPOINTS, MODELS
except ImportError:  # 兼容直接运行
    ENDPOINTS = {
        "ark_base": "https://ark.cn-beijing.volces.com/api/v3",
    }
    MODELS = {
        "image": "doubao-seedream-4-0-250828",
        "video": "doubao-seedance-2-0-260128",
        "video_fast": "doubao-seedance-2-0-fast-260128",
        "tts": "doubao-tts-bigtts",
    }


class ArkClient:
    def __init__(self, api_key: str | None = None):
        self.[REDACTED_SECRET] or os.environ.get("ARK_API_KEY", "")
        if not self.api_key:
            raise RuntimeError("缺少 ARK_API_KEY 环境变量")
        self.base_url = ENDPOINTS["ark_base"]
        self.headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }

    # ------------ 图像（Seedream 4.0）------------
    def generate_image(
        self,
        prompt: str,
        reference_images: list[str] | None = None,
        size: str = "1024x1792",
        model: str | None = None,
    ) -> str:
        """文生图 / 图生图。返回第一张图片 URL.

        reference_images 传文件路径或 URL，会自动转 data URI.
        """
        content = [{"type": "text", "text": prompt}]
        for img in reference_images or []:
            content.append({
                "type": "image_url",
                "image_url": {"url": self._image_to_data_uri(img)},
            })
        body = {
            "model": model or MODELS["image"],
            "content": content,
            "size": size,
            "watermark": False,
        }
        resp = requests.post(
            f"{self.base_url}/images/generations",
            headers=self.headers,
            json=body,
            timeout=120,
        )
        resp.raise_for_status()
        data = resp.json()
        # 兼容两种响应结构
        if "data" in data and data["data"]:
            return data["data"][0].get("url", "")
        if "url" in data:
            return data["url"]
        raise RuntimeError(f"image response no url: {data}")

    # ------------ 视频（Seedance 2.0）------------
    def submit_video(
        self,
        prompt: str,
        first_frame: str | None = None,
        last_frame: str | None = None,
        reference_image: str | None = None,
        reference_video: str | None = None,
        reference_audio: str | None = None,
        duration: int = 5,
        ratio: str = "9:16",
        resolution: str = "720p",
        generate_audio: bool = False,
        return_last_frame: bool = False,
        fast: bool = False,
        seed: int | None = None,
    ) -> str:
        """提交视频任务，返回 task_id.

        支持多种输入模态：first_frame / last_frame（首尾帧模式）/ reference_image /
        reference_video / reference_audio。同一请求最多 9 图、3 视频、3 音频。
        """
        content: list[dict] = [{"type": "text", "text": prompt}]

        def _add_img(path: str, role: str):
            content.append({
                "type": "image_url",
                "image_url": {"url": self._image_to_data_uri(path)},
                "role": role,
            })

        if first_frame:
            _add_img(first_frame, "first_frame")
        if last_frame:
            _add_img(last_frame, "last_frame")
        if reference_image:
            _add_img(reference_image, "reference_image")
        if reference_video:
            content.append({
                "type": "video_url",
                "video_url": {"url": reference_video if reference_video.startswith("http") else reference_video},
                "role": "reference_video",
            })
        if reference_audio:
            content.append({
                "type": "audio_url",
                "audio_url": {"url": reference_audio},
                "role": "reference_audio",
            })

        body: dict = {
            "model": MODELS["video_fast" if fast else "video"],
            "content": content,
            "ratio": ratio,
            "resolution": resolution,
            "duration": duration,
            "watermark": False,
            "return_last_frame": return_last_frame,
            "generate_audio": generate_audio,
        }
        if seed is not None:
            body["seed"] = seed

        resp = requests.post(
            f"{self.base_url}/contents/generations/tasks",
            headers=self.headers,
            json=body,
            timeout=60,
        )
        resp.raise_for_status()
        return resp.json()["id"]

    def poll_video(self, task_id: str, timeout_s: int = 900, interval_s: int = 10) -> dict:
        """轮询视频任务，完成返回完整 dict（含 content.video_url / usage.total_tokens）."""
        deadline = time.time() + timeout_s
        while time.time() < deadline:
            time.sleep(interval_s)
            resp = requests.get(
                f"{self.base_url}/contents/generations/tasks/{task_id}",
                headers=self.headers,
                timeout=30,
            )
            data = resp.json()
            status = data.get("status")
            if status == "succeeded":
                return data
            if status == "failed":
                raise RuntimeError(f"视频任务失败: {data}")
        raise TimeoutError(f"视频任务 {task_id} 超时（{timeout_s}s）")

    def download_video(self, url: str, out_path: pathlib.Path) -> pathlib.Path:
        out_path = pathlib.Path(out_path)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        r = requests.get(url, stream=True, timeout=300)
        with open(out_path, "wb") as f:
            for chunk in r.iter_content(8192):
                f.write(chunk)
        return out_path

    # ------------ 语音（豆包大模型 TTS）------------
    def tts(
        self,
        text: str,
        voice: str = "zh_female_sinong_conversation_wvae_bigtts",
        out_path: pathlib.Path | str = "out.wav",
        speed: float = 1.0,
        emotion: str | None = None,
    ) -> pathlib.Path:
        """豆包 TTS 合成，写入 out_path.

        voice 是音色 ID，格式 zh_{gender}_{name}_conversation_wvae_bigtts.
        完整音色见 https://www.volcengine.com/docs/6561/97465.

        注意：TTS 可能需要单独的 appid/cluster 凭证（走 openspeech.bytedance.com），
        此处用 ark OpenAI 兼容接口作为简化，实际项目请核对授权方式。
        """
        body = {
            "model": MODELS["tts"],
            "input": text,
            "voice": voice,
            "speed": speed,
            "response_format": "wav",
        }
        if emotion:
            body["emotion"] = emotion

        resp = requests.post(
            f"{self.base_url}/audio/speech",
            headers=self.headers,
            json=body,
            timeout=60,
        )
        resp.raise_for_status()
        out = pathlib.Path(out_path)
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_bytes(resp.content)
        return out

    # ------------ 工具 ------------
    @staticmethod
    def _image_to_data_uri(path_or_url: str) -> str:
        if path_or_url.startswith(("http://", "https://", "data:")):
            return path_or_url
        p = pathlib.Path(path_or_url)
        ext = p.suffix.lower().lstrip(".") or "jpeg"
        if ext == "jpg":
            ext = "jpeg"
        b64 = base64.b64encode(p.read_bytes()).decode()
        return f"data:image/{ext};base64,{b64}"


def cost_from_video_response(data: dict, resolution: str = "720p") -> float:
    """从视频响应中精确计算成本（按 token）."""
    from config import PRICING
    tokens = data.get("usage", {}).get("total_tokens", 0)
    return round(tokens * PRICING["video_per_mtoken"] / 1_000_000, 3)


path: scripts/_shared/checkpoint.py
content: """阶段 checkpoint，支持失败续跑."""
from __future__ import annotations

import json
import pathlib
from dataclasses import dataclass, field, asdict


STEPS = [
    "script",
    "characters",
    "storyboard",
    "videos",
    "dubs",
    "lipsync",
    "bgm",
    "subtitle",
    "edit",
]


@dataclass
class Checkpoint:
    project_dir: pathlib.Path
    status: dict = field(default_factory=dict)

    def __post_init__(self):
        self.project_dir = pathlib.Path(self.project_dir)
        self._load()

    def _path(self) -> pathlib.Path:
        return self.project_dir / ".checkpoint.json"

    def _load(self) -> None:
        if self._path().exists():
            self.status = json.loads(self._path().read_text())
        else:
            self.status = {s: "pending" for s in STEPS}

    def save(self) -> None:
        self.project_dir.mkdir(parents=True, exist_ok=True)
        self._path().write_text(json.dumps(self.status, ensure_ascii=False, indent=2))

    def is_done(self, step: str) -> bool:
        return self.status.get(step) == "done"

    def mark_done(self, step: str) -> None:
        self.status[step] = "done"
        self.save()

    def mark_running(self, step: str) -> None:
        self.status[step] = "running"
        self.save()

    def mark_failed(self, step: str, reason: str = "") -> None:
        self.status[step] = f"failed: {reason}" if reason else "failed"
        self.save()

    def next_pending(self) -> str | None:
        for s in STEPS:
            v = self.status.get(s, "pending")
            if v != "done":
                return s
        return None

    def sub_mark(self, step: str, sub_id: str, value: str = "done") -> None:
        """用于镜头级别的细粒度 checkpoint，如 videos.S01=done."""
        key = f"{step}.{sub_id}"
        self.status[key] = value
        self.save()

    def sub_done(self, step: str, sub_id: str) -> bool:
        return self.status.get(f"{step}.{sub_id}") == "done"


path: scripts/_shared/config.py
content: """家族共享默认参数与模型 ID.

基于 2026-04 实测火山方舟/Kling/Suno 文档核对。
"""

DEFAULTS = {
    "duration_total": 240,          # 秒，3-5 分钟区间默认 4 分钟
    "scene_duration": 5,            # 单镜头秒数（Seedance 2.0 支持 4-15）
    "ratio": "9:16",                # 竖屏；Seedance 2.0 支持 9:16/16:9/4:3/3:4/21:9/1:1/adaptive
    "resolution": "720p",           # 默认 720p 省钱；可选 480p/720p/1080p/2K
    "style": "三渲二国风",
    "genre": "仙侠",
    "cost_cap": 600.0,
    "cost_warn_ratio": 0.7,
    "cost_hard_ratio": 1.0,
    "watermark": False,
    "concurrency": 3,
    "scene_retry": 2,
    "fast_mode": False,             # True 用 seedance-fast（便宜但质量低）
}

MODELS = {
    "script":  "claude-opus-4-7",
    "image":   "doubao-seedream-4-0-250828",                # 方舟 Seedream 4.0
    "video":   "doubao-seedance-2-0-260128",                # 方舟 Seedance 2.0 标准版
    "video_fast": "doubao-seedance-2-0-fast-260128",        # 方舟 Seedance 2.0 fast
    "tts":     "doubao-tts-bigtts",                         # 方舟豆包大模型 TTS
    "lipsync": "kling-v2.6",                                # Kling 2.6 原生对口型
    "music":   "suno-v5.5",                                 # Suno v5.5（via sunoapi.org）
}

# API 端点（2026-04 核对）
ENDPOINTS = {
    "ark_base":      "https://ark.cn-beijing.volces.com/api/v3",
    "kling_base":    "https://api.klingai.com/v1",         # 官方；第三方 piapi.ai/fal.ai 亦可
    "suno_base":     "https://api.sunoapi.org",             # 第三方（Suno 无公开官方 API）
}

# 单位价（元），2026-04 核对
# Seedance 2.0: token-based 46¥/M tokens（1080p 文生/图生视频），28¥/M tokens（视频参考）
# 按秒估算的便利值：[REDACTED_SECRET] × width × height × fps / 1024，用 token × 46 / 1e6 得元价
PRICING = {
    "image_per_pic":        0.08,    # Seedream 4.0 每张
    "video_480p_per_sec":   0.50,    # 480p 9:16 ≈ 5s ¥2.5
    "video_720p_per_sec":   0.994,   # 720p 9:16 ≈ 5s ¥5
    "video_1080p_per_sec":  2.26,    # 1080p 9:16 ≈ 5s ¥11.3
    "video_fast_discount":  0.5,     # fast 模型约 5 折
    "video_per_mtoken":     46.0,    # 官方 token 单价（M tokens）
    "tts_per_char":         0.0008,  # 豆包大模型 TTS
    "lipsync_per_5s":       0.72,    # Kling 官方 $0.1/5s ≈ ¥0.72
    "bgm_per_track":        3.0,     # sunoapi.org 第三方均价
}


def video_unit_price(resolution: str = "720p", fast: bool = False) -> float:
    """返回每秒元价，用于预估."""
    base = {
        "480p":  PRICING["video_480p_per_sec"],
        "720p":  PRICING["video_720p_per_sec"],
        "1080p": PRICING["video_1080p_per_sec"],
        "2K":    PRICING["video_1080p_per_sec"] * 1.5,
    }.get(resolution, PRICING["video_720p_per_sec"])
    if fast:
        base *= PRICING["video_fast_discount"]
    return base


# 国风专用提示词片段
STYLE_PRESETS = {
    "三渲二国风": {
        "prefix": "三渲二国风动画风格，工笔线条，中国传统审美",
        "lighting": "柔和自然光，水墨晕染",
        "palette": "青绿山水色调，朱砂点缀",
    },
    "水墨": {
        "prefix": "中国水墨画风格，留白意境",
        "lighting": "墨色浓淡",
        "palette": "黑白灰为主，偶尔赭石",
    },
    "古风赛璐璐": {
        "prefix": "日式赛璐璐+中国古风融合，清新明亮",
        "lighting": "柔光晴天",
        "palette": "淡雅国风配色",
    },
    "工笔": {
        "prefix": "中国工笔画风格，细腻线条，重彩渲染",
        "lighting": "平光",
        "palette": "矿物颜料，金线勾勒",
    },
}

GENRE_PRESETS = {
    "仙侠": "门派纷争 / 修仙问道 / 御剑飞行 / 法宝秘术",
    "宫斗": "宫廷权谋 / 妃嫔博弈 / 皇家礼仪 / 雕梁画栋",
    "江湖": "快意恩仇 / 武林纷争 / 客栈酒肆 / 刀光剑影",
    "志怪": "山海异兽 / 妖怪传说 / 古籍志异 / 诡谲氛围",
}

# 豆包大模型 TTS 音色（2026-04 核对，_conversation_wvae_bigtts 后缀=豆包大模型版）
# 实际音色 ID 较多，这里列常用。完整列表见 https://www.volcengine.com/docs/6561/97465
VOICE_PRESETS = {
    # 男声
    "male_young":    "zh_male_ahu_conversation_wvae_bigtts",       # 温暖阿虎（青年温润）
    "male_mature":   [REDACTED_SECRET],      # 京腔侃爷（成熟磁性）
    "male_elder":    "zh_male_yanqing_conversation_wvae_bigtts",   # 沉稳长者
    # 女声
    "female_young":  "zh_female_sinong_conversation_wvae_bigtts",  # 爽快思思（清新少女）
    "female_mature": "zh_female_xiaohe_conversation_wvae_bigtts",  # 湾湾小何（温柔熟女）
    "female_elder":  "zh_female_guniang_conversation_wvae_bigtts", # 端庄长辈
}


path: scripts/_shared/cost_guard.py
content: """三级成本熔断.

- 硬熔断（hard）：开工前估算超 cap 立即阻止
- 软预警（warn）：运行时累计超 warn_ratio × cap 打印告警
- 降级建议（suggest）：超 cap 时给出具体降级方案
"""
from __future__ import annotations

import json
import pathlib
import time
from dataclasses import dataclass, field, asdict


class BudgetExceeded(RuntimeError):
    """熔断触发异常；上层 catch 后走降级逻辑."""


@dataclass
class CostRecord:
    step: str
    item: str
    cost: float
    ts: float = field(default_factory=time.time)


@dataclass
class CostGuard:
    cap: float = 600.0
    warn_ratio: float = 0.7
    project_dir: pathlib.Path | None = None
    spent: float = 0.0
    records: list[CostRecord] = field(default_factory=list)
    _warned: bool = False

    def preflight(self, estimated_total: float) -> None:
        """开工前硬熔断.

        Raises:
            BudgetExceeded: 估算超 cap 时抛出，附带降级建议字符串。
        """
        if estimated_total > self.cap:
            suggestions = self.downgrade_suggestions(estimated_total)
            raise BudgetExceeded(
                f"预估成本 ¥{estimated_total:.2f} 超过熔断上限 ¥{self.cap:.2f}。\n"
                f"降级建议：\n{suggestions}\n"
                f"如需继续，请提升 cost_cap 或采纳上述任一降级方案。"
            )

    def charge(self, step: str, item: str, cost: float) -> None:
        """扣费，触发软预警/硬熔断."""
        self.spent += cost
        self.records.append(CostRecord(step=step, item=item, cost=cost))
        ratio = self.spent / self.cap if self.cap else 0

        if ratio >= 1.0:
            self._persist()
            raise BudgetExceeded(
                f"累计成本 ¥{self.spent:.2f} 达到熔断上限 ¥{self.cap:.2f}（{step}/{item}）。\n"
                f"已完成步骤可从 checkpoint 续跑。"
            )
        if ratio >= self.warn_ratio and not self._warned:
            self._warned = True
            print(
                f"⚠️ 成本预警：已花费 ¥{self.spent:.2f}（{ratio:.0%} of ¥{self.cap:.2f}），"
                f"即将触发熔断。"
            )
        self._persist()

    def downgrade_suggestions(self, estimated_total: float) -> str:
        """生成降级方案文本."""
        over = estimated_total - self.cap
        lines = [
            f"  1. 缩短总时长：每减 1 分钟约省 ¥{estimated_total / 5:.0f}",
            f"  2. 减少镜头数：每删 1 镜约省 ¥{estimated_total / 48:.1f}",
            f"  3. 关闭对口型：整片省 ¥{estimated_total * 0.37:.0f}（约 37%）",
            f"  4. 视频降到 4 秒/镜：整片省 ¥{estimated_total * 0.2:.0f}（约 20%）",
            f"  5. 提升 cost_cap 至 ¥{estimated_total * 1.1:.0f}（当前 ¥{self.cap:.0f}，需多付 ¥{over:.2f}）",
        ]
        return "\n".join(lines)

    def report(self) -> dict:
        """返回成本报告（可序列化）."""
        by_step: dict[str, float] = {}
        for r in self.records:
            by_step[r.step] = by_step.get(r.step, 0.0) + r.cost
        return {
            "spent": round(self.spent, 2),
            "cap": self.cap,
            "ratio": round(self.spent / self.cap, 3) if self.cap else 0,
            "by_step": {k: round(v, 2) for k, v in by_step.items()},
            "record_count": len(self.records),
        }

    def _persist(self) -> None:
        if not self.project_dir:
            return
        self.project_dir.mkdir(parents=True, exist_ok=True)
        path = self.project_dir / ".cost.json"
        data = {
            "cap": self.cap,
            "warn_ratio": self.warn_ratio,
            "spent": self.spent,
            "records": [asdict(r) for r in self.records],
        }
        path.write_text(json.dumps(data, ensure_ascii=False, indent=2))

    @classmethod
    def load(cls, project_dir: pathlib.Path, cap: float = 600.0) -> "CostGuard":
        """从 project_dir/.cost.json 恢复."""
        path = project_dir / ".cost.json"
        guard = cls(cap=cap, project_dir=project_dir)
        if path.exists():
            data = json.loads(path.read_text())
            guard.cap = data.get("cap", cap)
            guard.warn_ratio = data.get("warn_ratio", 0.7)
            guard.spent = data.get("spent", 0.0)
            guard.records = [CostRecord(**r) for r in data.get("records", [])]
        return guard


def estimate_total(
    n_scenes: int,
    n_characters: int,
    total_chars: int,
    scene_duration: int = 5,
    resolution: str = "720p",
    fast: bool = False,
    enable_lipsync: bool = True,
    enable_bgm: bool = True,
) -> dict:
    """估算全流程成本，返回明细 dict.

    video 按 resolution 和 fast 标志换算每秒元价；lipsync 按 scene_duration/5s 换算。
    """
    from config import PRICING, video_unit_price

    image_cost = (n_scenes + n_characters * 3) * PRICING["image_per_pic"]
    video_cost = n_scenes * scene_duration * video_unit_price(resolution, fast)
    tts_cost = total_chars * PRICING["tts_per_char"]
    lipsync_cost = (
        n_scenes * (scene_duration / 5.0) * PRICING["lipsync_per_5s"]
        if enable_lipsync else 0
    )
    bgm_cost = PRICING["bgm_per_track"] if enable_bgm else 0
    total = image_cost + video_cost + tts_cost + lipsync_cost + bgm_cost

    return {
        "image": round(image_cost, 2),
        "video": round(video_cost, 2),
        "tts": round(tts_cost, 2),
        "lipsync": round(lipsync_cost, 2),
        "bgm": round(bgm_cost, 2),
        "total": round(total, 2),
        "resolution": resolution,
        "fast": fast,
    }


path: scripts/dub.py
content: """TTS 配音（Seed-TTS）."""
from __future__ import annotations

import argparse
import json
import pathlib
import sys

HERE = pathlib.Path(__file__).resolve()
# _shared/ 定位：优先 bundled（scripts/_shared/），fallback monorepo 根
for _cand in (HERE.parent / "_shared", HERE.parents[2] / "_shared"):
    if (_cand / "config.py").exists():
        sys.path.insert(0, str(_cand))
        break

from ark_api import ArkClient
from config import PRICING, VOICE_PRESETS
from cost_guard import CostGuard
from checkpoint import Checkpoint


def auto_voice(char: dict) -> str:
    """根据 name/age 启发式选音色，用豆包大模型（_conversation_wvae_bigtts）."""
    if char.get("voice"):
        return char["voice"]
    name = char.get("name", "")
    age = str(char.get("age", "18"))
    is_female = any(k in name for k in ["妃", "娘", "女", "姬", "仙子", "娥", "媛"])
    is_elder = age.isdigit() and int(age) > 45
    is_mature = age.isdigit() and 30 < int(age) <= 45
    if is_female:
        if is_elder: return VOICE_PRESETS["female_elder"]
        if is_mature: return VOICE_PRESETS["female_mature"]
        return VOICE_PRESETS["female_young"]
    if is_elder: return VOICE_PRESETS["male_elder"]
    if is_mature: return VOICE_PRESETS["male_mature"]
    return VOICE_PRESETS["male_young"]


def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--script", required=True)
    p.add_argument("--out-dir", required=True)
    args = p.parse_args()

    script = json.loads(pathlib.Path(args.script).read_text())
    out_dir = pathlib.Path(args.out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    project_dir = out_dir.parent
    guard = CostGuard.load(project_dir)
    cp = Checkpoint(project_dir)

    char_voice = {c["id"]: auto_voice(c) for c in script.get("characters", [])}

    client = ArkClient()
    manifest: dict = {}

    for scene in script.get("scenes", []):
        sid = scene["id"]
        scene_manifest = []
        for idx, d in enumerate(scene.get("dialogue", [])):
            cid = d.get("char")
            text = d.get("text", "").strip()
            if not text:
                continue
            out_path = out_dir / f"{sid}_{cid}_{idx}.wav"
            key = f"{sid}_{cid}_{idx}"
            if out_path.exists() or cp.sub_done("dubs", key):
                print(f"  ⏭️  {key} 已存在")
                scene_manifest.append({"path": str(out_path), "char": cid, "text": text})
                continue

            voice = char_voice.get(cid, VOICE_PRESETS["male_young"])
            print(f"  🎤 {key} [{voice}]: {text[:30]}")
            client.tts(text=text, voice=voice, out_path=out_path)
            guard.charge("dubs", key, len(text) * PRICING["tts_per_char"])
            cp.sub_mark("dubs", key)
            scene_manifest.append({
                "path": str(out_path), "char": cid, "text": text, "voice": voice,
            })
        if scene_manifest:
            manifest[sid] = scene_manifest

    (out_dir / "manifest.json").write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2)
    )
    print(f"✅ 配音: {sum(len(v) for v in manifest.values())} 条")
    return 0


if __name__ == "__main__":
    sys.exit(main())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)