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

--- name: huo15-comic-bgm displayName: 火15 漫剧-背景音乐 description: 根据剧本整体 mood 生成一首 BGM（Suno v5），时长匹配总时长，国风优先（古筝/琵琶/笛子）。触发词：BGM 生成、背景音乐、配乐。 version: 0.1.0 --- # 火15 漫剧-背景音乐 Skill > 整片一首 BGM，FFmpeg 混音时压到 -20dB。 --- ## 输入 / 输出 ```bash python scripts/bgm.py \ --script output/demo/script.json \ --duration 240 \ --out output/demo/bgm.mp3 ``` ## Suno prompt 模板 ```python # 从 scenes 抽取 mood 词频，取 top 3 moods = ["苍凉", "壮阔", "激昂"] # e.g. prompt = f"国风古风纯音乐，{', '.join(moods)}氛围，古筝为主旋律，点缀琵琶和笛子，时长 {duration}秒" ``` ## 降级 Suno 不可用时，fallback 到 `templates/bgm_library/国风/{mood}.mp3` 本地素材。 ## 成本 ¥3/首，整片固定。

path: _meta.json
content: ownerId: kn7byevkn40d6z4p7ghdb097z983tj33
slug: huo15-comic-bgm
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/bgm.py
content: """背景音乐（Suno v5）."""
from __future__ import annotations

import argparse
import collections
import json
import os
import pathlib
import sys
import time

import requests

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

from config import PRICING, ENDPOINTS, MODELS
from cost_guard import CostGuard


SUNO_API = ENDPOINTS["suno_base"]  # https://api.sunoapi.org（Suno 无公开官方 API，走第三方）


def top_moods(script: dict, n: int = 3) -> list[str]:
    counter = collections.Counter()
    for s in script.get("scenes", []):
        m = s.get("mood", "")
        if m:
            counter[m] += 1
    return [m for m, _ in counter.most_common(n)]


def build_prompt(moods: list[str], duration: int) -> str:
    mood_str = "、".join(moods) if moods else "苍凉壮阔"
    return (
        f"国风古风纯音乐，{mood_str}氛围，"
        f"古筝为主旋律，点缀琵琶和笛子，"
        f"时长约 {duration} 秒，纯音乐无人声，"
        f"适合仙侠/国风动画背景。"
    )


def suno_generate(prompt: str, duration: int) -> str:
    key = os.environ.get("SUNO_API_KEY", "")
    if not key:
        raise RuntimeError("缺少 SUNO_API_KEY")
    headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
    body = {
        "prompt": prompt,
        "duration": duration,
        "instrumental": True,
        "model": MODELS["music"],  # suno-v5.5
    }
    r = requests.post(f"{SUNO_API}/v1/generate", headers=headers, json=body, timeout=60)
    r.raise_for_status()
    task_id = r.json().get("id") or r.json().get("task_id")

    deadline = time.time() + 600
    while time.time() < deadline:
        time.sleep(10)
        g = requests.get(f"{SUNO_API}/v1/tasks/{task_id}", headers=headers, timeout=30)
        d = g.json()
        status = d.get("status")
        if status in ("succeeded", "complete"):
            return d.get("audio_url") or d.get("output", [{}])[0].get("audio_url")
        if status in ("failed", "error"):
            raise RuntimeError(f"suno 失败: {d}")
    raise TimeoutError("suno 超时")


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

    out = pathlib.Path(args.out)
    if out.exists():
        print(f"  ⏭️  {out.name} 已存在")
        return 0

    script = json.loads(pathlib.Path(args.script).read_text())
    moods = top_moods(script)
    prompt = build_prompt(moods, args.duration)
    print(f"🎵 BGM prompt: {prompt}")

    project_dir = out.parent
    guard = CostGuard.load(project_dir)

    try:
        url = suno_generate(prompt, args.duration)
        r = requests.get(url, stream=True)
        out.parent.mkdir(parents=True, exist_ok=True)
        with open(out, "wb") as f:
            for chunk in r.iter_content(8192):
                f.write(chunk)
        guard.charge("bgm", "main", PRICING["bgm_per_track"])
        print(f"✅ {out}")
    except Exception as e:
        # Fallback：找本地素材
        fallback = REPO_ROOT / "templates" / "bgm_library" / "国风" / f"{moods[0] if moods else 'default'}.mp3"
        if fallback.exists():
            print(f"⚠️  Suno 失败 ({e})，fallback → {fallback.name}")
            import shutil
            shutil.copy(fallback, out)
        else:
            print(f"❌ BGM 生成失败且无 fallback: {e}")
            return 1
    return 0


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


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