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

--- name: huo15-comic-lipsync displayName: 火15 漫剧-对口型 description: 给视频镜头+对白音频做口型同步（Kling 2.5 Lip Sync）。没有对白的镜头跳过。触发词：对口型、lipsync、口型同步。 version: 0.1.0 --- # 火15 漫剧-对口型 Skill > 视频 + 音频 → 口型同步后的视频。 --- ## 输入 / 输出 ```bash python scripts/lipsync.py \ --video-dir output/demo/videos \ --audio-dir output/demo/audio \ --out-dir output/demo/lipsync ``` 每个镜头取该镜第一条对白的音频做口型同步；无对白直接复制原视频。 ## API ``` POST https://api.kling.com/v1/videos/lip-sync Headers: Authorization: Bearer {KLING_API_KEY} Body: { "video_url": "...", "audio_url": "...", "mode": "kling-v2.5" } ``` ## 注意 - 视频最短 3s，如果对白音频 <3s 自动补静默 - 单镜成本 ¥3，48 镜 = ¥144（可通过 `--no-lipsync` 关闭省钱）

path: _meta.json
content: ownerId: kn7byevkn40d6z4p7ghdb097z983tj33
slug: huo15-comic-lipsync
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/lipsync.py
content: """对口型（Kling 2.5 Lip Sync）."""
from __future__ import annotations

import argparse
import json
import os
import pathlib
import shutil
import sys
import time

import requests

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 config import PRICING, ENDPOINTS, MODELS
from cost_guard import CostGuard
from checkpoint import Checkpoint


KLING_API = ENDPOINTS["kling_base"]  # https://api.klingai.com/v1


def submit_lipsync(video_path: pathlib.Path, audio_path: pathlib.Path) -> str:
    key = os.environ.get("KLING_API_KEY", "")
    if not key:
        raise RuntimeError("缺少 KLING_API_KEY")
    headers = {"Authorization": f"Bearer {key}"}
    files = {
        "video": open(video_path, "rb"),
        "audio": open(audio_path, "rb"),
    }
    data = {"model": MODELS["lipsync"]}  # kling-v2.6
    r = requests.post(f"{KLING_API}/videos/lip-sync", headers=headers, files=files, data=data)
    r.raise_for_status()
    return r.json()["task_id"]


def poll_lipsync(task_id: str, timeout_s: int = 600) -> str:
    key = os.environ.get("KLING_API_KEY", "")
    headers = {"Authorization": f"Bearer {key}"}
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        time.sleep(8)
        r = requests.get(f"{KLING_API}/videos/lip-sync/{task_id}", headers=headers)
        data = r.json()
        if data.get("status") == "succeeded":
            return data["video_url"]
        if data.get("status") == "failed":
            raise RuntimeError(f"lipsync 失败: {data}")
    raise TimeoutError(f"lipsync 超时 {task_id}")


def first_dialogue_audio(sid: str, audio_dir: pathlib.Path) -> pathlib.Path | None:
    cands = sorted(audio_dir.glob(f"{sid}_*.wav"))
    return cands[0] if cands else None


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

    video_dir = pathlib.Path(args.video_dir)
    audio_dir = pathlib.Path(args.audio_dir)
    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)

    for video in sorted(video_dir.glob("S*.mp4")):
        sid = video.stem
        out = out_dir / f"{sid}.mp4"
        if out.exists() or cp.sub_done("lipsync", sid):
            print(f"  ⏭️  {sid}")
            continue

        audio = first_dialogue_audio(sid, audio_dir)
        if not audio:
            print(f"  🤫 {sid} 无对白，复制原视频")
            shutil.copy(video, out)
            cp.sub_mark("lipsync", sid)
            continue

        try:
            print(f"  👄 {sid} → lipsync")
            task_id = submit_lipsync(video, audio)
            url = poll_lipsync(task_id)
            r = requests.get(url, stream=True)
            with open(out, "wb") as f:
                for chunk in r.iter_content(8192):
                    f.write(chunk)
            guard.charge("lipsync", sid, PRICING["lipsync_per_5s"])
            cp.sub_mark("lipsync", sid)
        except Exception as e:
            print(f"  ❌ {sid}: {e}, 降级为原视频")
            shutil.copy(video, out)
            cp.sub_mark("lipsync", sid, "fallback")

    print(f"✅ 口型同步完成")
    return 0


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


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