Skill: volcengine-skills/byted-kickart-marketing-material-generator (version 1.1.5)

--- name: [REDACTED_SECRET] description: "基于火山引擎Kickart服务生成抖店/抖音商品营销短视频，支持用户上传自定义图片/视频素材、素材分析、创意分析、故事板创作、成片生成、抖音发布全流程。当用户发送抖店/抖音商品链接、提及「上传素材」「添加图片」「添加视频」「素材分析」「创意分析」「故事板」「生成短视频」「营销视频」「发布视频」「抖音发布」时触发使用。" version: "1.1.5" tags: ["抖店", "抖音", "营销短视频", "Kickart", "AIGC创作", "抖音发布"] --- # 营销视频SKILL ## 🚨 强制前置校验流程（必须按顺序执行，任意不通过直接终止流程） 所有用户请求必须先完成以下4步校验，不得跳过： 1. **模型输入能力校验** - 读取openclaw.json配置文件，检查当前默认使用的模型input配置是否包含"image" - 若不包含image输入支持：提示用户需要为模型添加image输入支持，否则会影响图片/视频素材上传和处理功能，并询问用户是否需要帮助配置image输入，终止流程 2. **火山鉴权校验** - 读取`./references/火山鉴权指南.md`，检查本地是否已配置环境变量 - 未配置：引导用户按指南完成密钥配置，终止流程 3. **套餐有效性校验** - 读取`./references/套餐开通指南.md`，调用套餐查询接口检查用户套餐是否在有效期内、创点余额充足 - 套餐失效/余额不足：引导用户按指南升级/充值套餐，终止流程 4. **链接有效性校验** - 检查用户输入的链接是否为合法抖店/抖音商品链接（域名包含`haohuo.jinritemai.com`或`v.douyin.com`） - 无效链接：引导用户输入正确的抖店商品链接，终止流程 5. **自定义素材场景适配** - 若用户需求为上传自定义图片/视频素材，跳过抖店/抖音链接有效性校验，直接进入素材上传流程 ## 🚫 任务提交与重试铁则（强制遵守，零例外） 1. **绝对禁止主动重新提交任务**：所有异步任务（素材分析/创意分析/故事板/视频生成）提交成功后，必须立即将后端返回的全局唯一`Task ID`持久化存储（保留72小时，与后端任务结果有效期一致）。无论本地exec会话/状态是否丢失、异常中断，都只会通过已持久化的`Task ID`查询后端真实任务状态，永远不会自行发起重复提交。 2. **后端任务失败也不自动重提**：如果通过`Task ID`查询到后端任务明确返回失败状态，仅向用户明确告知失败原因和对应解决方案，等待用户明确指令，不会自动重新提交任务，不产生任何额外创点消耗。 3. **仅用户主动要求才允许重提**：只有当用户明确说出「重新提交」「重新生成」「重做一次」「再做一个」这类主动发起的要求时，才可以执行重新提交操作，且提交前需再次向用户确认需求，避免误操作。 ## 🎯 意图判断与依赖处理规则（基于对话历史自动识别） 结合当前用户输入和历史记录，按以下优先级判断意图： 1. **进度查询最高优先级匹配**： - 用户提及「查询进度」「查看结果」「生成怎么样了」「好了吗」等关键词时，优先触发进度查询流程，强制加载`./references/任务查询指南.md`，无需依赖其他任务上下文判断，直接执行标准化查询流程 2. **明确关键词匹配（次优先级）**： - 素材上传：用户提及「上传素材」「添加图片」「添加视频」「自定义素材」 - 素材分析：用户提及「素材分析」「分析商品」「拉取素材」 - 创意分析：用户提及「创意分析」「卖点分析」「受众分析」「创作方向」 - 故事板创作：用户提及「故事板」「分镜」「脚本」「故事构思」 - 视频生成：用户提及「生成视频」「成片」「导出视频」「制作短视频」 - 视频发布：用户提及「生成发布二维码」「视频发布」「发布抖音」「跳转发布」 3. **无明确意图处理**： - 用户仅发送商品链接未说明需求：询问用户需要的操作（素材分析/创意分析/故事板创作/直接生成短视频） ## 🛠️ 各任务执行指南 | 任务类型 | 执行依据 | 输出要求 | 前置要求 | |---------|---------|---------|---------| | 进度查询 | 读取`./references/任务查询指南.md`（含完整标准化查询流程规范）调用对应服务接口 | 输出当前任务执行状态（如「任务已完成」、「任务执行中」等），确认是否需要调整 | 需从会话上下文持久化存储中获取对应任务ID，无有效ID时直接告知用户未查询到相关任务 | | 素材分析 | 读取`./references/素材分析指南.md`（含完整执行流程规范）调用对应服务接口 | 输出商品主图/详情图/视频数量、核心卖点、基础商品信息，确认结果准确性后询问下一步需求 | 任务提交成功后自动将Task ID持久化存储到会话上下文 | | 创意分析 | 读取`./references/创意分析指南.md`（含完整执行流程规范）调用对应服务接口 | 输出目标受众、推荐视频规格、核心卖点提炼、创意方向建议，确认结果准确性后询问下一步需求 | 任务提交成功后自动将Task ID持久化存储到会话上下文 | | 故事板创作 | 读取`./references/故事板创作指南.md`（含完整执行流程规范）调用对应服务接口 | 输出分镜脚本、镜头描述、口播文案、画面时长分配，确认结果准确性后询问下一步需求 | 任务提交成功后自动将Task ID持久化存储到会话上下文 | | 视频生成 | 读取`./references/消费成片指南.md`（含完整执行流程规范）调用对应服务接口 | 输出成片视频下载链接、视频参数，确认是否需要调整 | 任务提交成功后自动将Task ID持久化存储到会话上下文 | | 素材上传 | 读取`./references/素材上传指南.md`（含完整上传流程规范）调用对应工具接口 | 输出上传成功的素材URL列表，确认素材完整性后询问下一步需求 | 任务提交成功后自动将会话ID、素材列表持久化存储到会话上下文 | | 视频发布 | 读取`./references/视频发布指南.md`（含完整二维码生成和发布流程规范）调用对应脚本接口 | 输出抖音发布跳转二维码（图片形式）、使用说明，提醒用户扫码即可跳转发布 | 前置要求：已获取到成片视频公网下载链接，且用户提供了发布文案内容 | ## 📝 任务状态管理规范（强制遵守） 1. **任务ID持久化要求**： 所有异步任务（素材分析/创意分析/故事板创作/视频生成）提交成功后，必须将以下信息持久化存储到会话上下文： - Task ID（后端返回的任务唯一标识） - 任务类型（素材分析/创意分析/故事板创作/视频生成） - 提交时间（UTC+8时间戳） - 执行会话ID（exec返回的sessionId） - 任务参数（用户指定的时长、prompt等关键信息） 禁止丢失Task ID，无有效Task ID时不得响应用户查询进度请求。 2. **任务状态查询优先级**： 用户查询进度时，必须按以下顺序执行查询： ① 检查是否存在正在运行的对应exec子进程：存在则调用`process(action=poll)`轮询进程状态 ② 无运行中的进程：使用持久化的Task ID调用`scripts/task.py`查询后端真实任务状态 ③ 最终任务状态以后端接口返回结果为准，禁止仅以本地脚本执行退出状态判断任务最终结果 3. **本地脚本超时处理逻辑**： 当本地执行脚本因超时退出（非0返回码）时，不得直接判定任务失败，必须立即使用持久化的Task ID调用查询接口确认后端任务状态： - 后端返回任务仍在执行：告知用户任务仍在后台运行，无需重复提交 - 后端返回任务成功：获取结果并反馈给用户 - 后端返回任务失败：告知用户错误原因和解决方案 4. **素材消耗规则（强制遵守）** 当视频生成任务确认完成并向用户交付成片后，必须立即执行素材消耗操作：调用`python3 upload.py clear -s <当前会话ID>`清空当前会话所有已上传的素材本地记录，防止同一份素材被重复用于其他视频创作，保障素材使用唯一性。 ## 📂 文件输出与命名规范（强制遵守） 所有任务输出文件必须遵循以下规范，避免文件名冲突和磁盘占用问题： 1. **统一输出目录**：所有输出文件必须保存到`/tmp/openclaw/kickart/output/`目录，执行前自动创建目录： ```bash mkdir -p /tmp/openclaw/kickart/output ``` 2. **唯一文件名生成规则**：文件名格式为 `<文件类型>_<13位毫秒时间戳>_<6位随机数>.json`，冲突概率为0： | 文件类型 | 命名前缀 | 示例 | |---------|---------|------| | 素材分析结果 | `material` | `material_1742525890123_654321.json` | | 创意分析结果 | `creative` | `creative_1742525912345_123456.json` | | 故事板生成结果 | `storyboard` | `storyboard_1742525956789_987654.json` | | 视频成片结果 | `video` | `video_1742526012345_456789.json` | 3. **文件名生成命令**：执行每个环节前动态生成唯一文件名，示例： ```bash # 生成素材分析输出文件名 MATERIAL_FILE="/tmp/openclaw/kickart/output/material_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json" ``` ## ⚠️ 错误处理规范 所有错误必须明确告知原因和可执行解决方案，禁止模糊提示！！！ | 错误码 | 错误描述 | 详细说明 | 用户处理建议 | | --- | --- | --- | --- | | 0 | 无返回值 | 接口调用成功，但服务返回结果为空 | 请稍后重试，如问题持续请联系火山技术支持 | | 1402 | 创点不足 | 调用接口时，用户账户的创点额度不足 | 请前往 [创点充值页面](https://console.volcengine.com/kickart/fusion/setting/combobuy?tab=additionalCombo) 充值创点或升级套餐 | | 1412 | 图片格式错误 | 调用接口时，输入参数中包含了非支持的图片格式 | 请检查图片格式是否为 jpg、png 等支持的格式 | | 1413 | 无效的媒体URL错误 | 调用接口时，输入参数中包含了无效的媒体URL | 请检查您提供的URL是否正确，避免包含特殊字符或格式错误 | | 1414 | 输入包含敏感信息错误 | 调用接口时，输入参数中包含了敏感信息，如个人隐私数据等 | 暂不可生成带人物的营销视频，请等待后续版本更新 | | 1415 | 输出包含敏感信息错误 | 调用接口时，服务返回结果中包含了敏感信息，如个人隐私数据等 | 暂不可生成带人物的营销视频，请等待后续版本更新 | | 1416 | 输入媒体数量错误 | 用户输入的素材数量超过限制 | 提供的媒体素材数量超出限制，多出的素材可能不会使用 | | 1417 | 大模型调用错误 | 模型调用出错，通常是输入参数错误 | 媒体素材处理存在问题，请重新尝试，如问题持续请联系火山技术支持 | | 1418 | 时长计费参数错误 | 提交时入参时间有问题 | 要求的成片时长不符合技能要求，请按照0-60s的时长限制提交制作需求，如问题持续请联系火山技术支持 | | 1501 | 用户套餐过期 | 调用接口时，用户套餐已过期 | 请前往 [套餐开通页面](https://console.volcengine.com/kickart/fusion/setting/combobuy?tab=combo) 开通套餐 | | 100010 | 签名验证失败 | AK/SK签名验证失败 | 请检查您提供的火山鉴权AK/SK是否正确，可访问[火山引擎控制台](https://console.volcengine.com/iam/keymanage)确认 | | 100013 | 缺少服务权限 | 缺少iccloud\_muse服务的RegisterArkClawCombo权限 | 您的企业账号未开通Kickart权限，请联系火山主账号管理员为您开通，或详询火山技术支持 | | x01001 | AK/SK未配置 | 用户未配置AK/SK | 请输入火山鉴权的AK/SK，可访问[火山引擎控制台](https://console.volcengine.com/iam/keymanage)获取 | | x01010 | 有效套餐缺失 | 素材上传出现错误，通常是套餐原因 | 请前往 [套餐开通页面](https://console.volcengine.com/kickart/fusion/setting/combobuy?tab=combo) 开通套餐 | | 其他 | \- | 未明确列出的其他错误情况 | 稍后重试，如问题持续请联系火山技术支持 |

path: scripts/service.py
content: import sys
import click
import logging
import jsonpath

from base import Result, authentication, perror

__all__ = ["do_request"]

service, module = authentication(), ""
if 0 == service:
    perror(Result(code="10010", message="AK/SK未配置"))
if 1 == service:
    import servicev1

    do_request = servicev1._do_request
if 2 == service:
    import servicev2

    do_request = servicev2._do_request


# 查询&注册免费的Ark Claw 套餐
@click.command()
def combo() -> None:
    """查询&注册免费的Ark Claw 套餐"""
    logging.info(f"[tool] >>> python3 {' '.join(sys.argv)}")
    try:
        resp = do_request("POST", {}, b"", action="RegisterArkClawCombo").json()
        # >>> [火山OpenTop错误] >>> #
        open_top_code = jsonpath.jsonpath(resp, "$.ResponseMetadata.Error.CodeN")
        if open_top_code and open_top_code[0] != 0:
            click.echo(Result(code=str(open_top_code), message=""), err=True)
            exit(1)

        # >>> [创作云错误] >>> #
        code = jsonpath.jsonpath(resp, "$.ResponseMetadata.Code")
        if code and code[0] != 0:
            click.echo(Result(code=str(code), message=""), err=True)
            exit(1)

        # >>> [创作云成功] >>> #
        if code and code[0] == 0:
            result = jsonpath.jsonpath(resp, "$.Result")
            if not result or not result[0]:
                click.echo(Result(code="-1", message="接口返回值解析错误"), err=True)
                exit(1)
            expire = jsonpath.jsonpath(resp, "$.Result.expire_time")
            click.echo(Result(code="0", message=str(expire and expire[0])))
            exit(0)

        click.echo(Result(code="-1", message="接口返回值解析错误"), err=True)
    except Exception as e:
        click.echo(Result(code="-1", message=str(e)), err=True)


if __name__ == "__main__":
    combo()


path: scripts/task.py
content: # Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import sys
import json
import time
import click
import logging
import jsonpath

from base import Result, perror
from service import do_request


__all__ = ["submit", "query"]


# 发起请求 获取 TaskID
def submit(service_id: int, params: str) -> Result:
    try:
        payload = {
            "ResourceList": [
                "https://lf3-static.bytednsdoc.com/obj/eden-cn/jhteh7uhpxnult/test_image/woman/woman_4.png"
            ],
            "TemplateId": "2000620034",
            "Resolution": "1080p",
            "Extra": params,
        }

        # 图生图 样例
        submit_body = {
            "ServerId": service_id,
            "PayloadJson": json.dumps(payload, ensure_ascii=False),
        }
        submit_bytes = json.dumps(submit_body, ensure_ascii=False).encode("utf-8")
        response = do_request(
            "POST", {}, submit_bytes, action="SubmitAiTemplateTaskAsync"
        ).json()

        code = jsonpath.jsonpath(response, "$.ResponseMetadata.Code")
        if not code:
            return Result(code="-1", message="提交任务失败, 响应内容为空")
        if code[0] != 0:
            return Result(code=str(code[0]), message=f"提交任务失败, Code: {code[0]}")

        task_id = jsonpath.jsonpath(response, "$.Result.TaskId")
        if not task_id or not task_id[0]:
            return Result(code="-1", message=f"解析TaskId失败, 响应内容: {response.c}")
        return Result(code="0", message=task_id[0])
    except Exception as e:
        return Result(code="-1", message=f"提交任务失败, 错误信息: {str(e)}")


# 查询任务状态
def query(task_id: str) -> Result:
    params = json.dumps({"TaskId": task_id}, ensure_ascii=False).encode("utf-8")
    try:
        resp = do_request("POST", {}, params, action="QueryAiTemplateTaskResult").json()

        code = jsonpath.jsonpath(resp, "$.ResponseMetadata.Code")
        if not code:
            return Result(code="-1", message="提交任务失败, 响应内容为空")
        if code[0] != 0:
            return Result(
                code=str(code[0]), message=f"查询任务状态失败, Code: {code[0]}"
            )

        result_code = jsonpath.jsonpath(resp, "$.Result.Code")
        if not result_code:
            return Result(code="-1", message="提交任务失败, 响应内容为空")

        # 继续轮询
        if result_code[0] in [1000, 1600]:
            return Result(code="1000", message="任务正在执行中")

        # 任务异常
        if result_code[0] != 0:
            msg = jsonpath.jsonpath(resp, "$.Result.Message")
            return Result(
                code=str(result_code[0]), message=msg[0] if msg else "任务异常"
            )

        # 任务成功
        progress = jsonpath.jsonpath(resp, "$.Result.Progress")
        if not progress or progress[0] != 100:
            return Result(code="1000", message="任务正在执行中")

        result = jsonpath.jsonpath(resp, "$.Result.ResultExtra")
        if not result or not result[0]:
            return Result(code="-1", message="未获取到任务结果")

        return Result(code="0", message=result[0])

    except Exception as e:
        return Result(code="-1", message=f"查询任务状态失败: {str(e)}")


# 轮询任务状态
def poll(
    task_id: str, max_attempts: int = 10 * 10, interval_seconds: int = 6
) -> Result:
    for _ in range(max_attempts):
        time.sleep(interval_seconds)
        result = query(task_id)
        if result.code != "1000":
            return result
    exit(0)


@click.command()
@click.option("--id", required=True, type=str, help="任务ID")
@click.option("--output", required=True, type=str, help="输出结果所在的json文件路径")
def main(id, output):
    """根据任务ID重新查询任务结果"""
    logging.info(f"[tool] >>> python3 {' '.join(sys.argv)}")

    poll_res = query(id)
    if poll_res.code != "0":
        perror(poll_res)
    with open(output, "w") as f:
        result = json.loads(poll_res.message)
        json.dump(result, f, ensure_ascii=False, indent=2)
    print(Result(code="0", message=output).model_dump_json())


if __name__ == "__main__":
    main()


path: scripts/material.py
content: import sys
import json
import click
import logging
import requests
from urllib.parse import urlparse, parse_qs, urlencode
from base import Result, perror
from task import submit, poll


def simplify(url: str, keys: list) -> Result:
    try:
        # 校验域名
        parsed_original = urlparse(url)
        original_domain = parsed_original.netloc
        allowed_domains = ["haohuo.jinritemai.com", "v.douyin.com"]

        if original_domain not in allowed_domains:
            return Result(
                code="-1",
                message=f"URL域名不支持，仅支持以下域名：{', '.join(allowed_domains)}",
            )

        response = requests.head(url, allow_redirects=True)
        parsed = urlparse(response.url)
        query = {k: v for k, v in parse_qs(parsed.query).items() if k in keys}
        simplified_url = parsed._replace(query=urlencode(query, doseq=True)).geturl()
        return Result(code="0", message=simplified_url)
    except Exception as e:
        return Result(code="-1", message=f"简化URL失败：{str(e)}")


@click.command()
@click.option("--url", required=True, type=str, help="抖店商品链接")
@click.option("--output", required=True, type=str, help="输出结果所在的json文件路径")
def main(url, output):
    """调用创作云服务"""
    logging.info(f"[tool] >>> python3 {' '.join(sys.argv)}")

    simplify_res = simplify(url, ["id"])
    if simplify_res.code != "0":
        perror(simplify_res)
    print(f"简化URL成功，简化后的URL: {simplify_res.message}")

    submit_res = submit(
        3296206833079096, json.dumps({"url": simplify_res.message}, ensure_ascii=False)
    )
    if submit_res.code != "0":
        perror(submit_res)
    print(f"提交任务成功，任务ID: {submit_res.message}", flush=True)

    poll_res = poll(submit_res.message)
    if poll_res.code != "0":
        perror(poll_res)
    with open(output, "w") as f:
        result = json.loads(poll_res.message)
        json.dump(result, f, ensure_ascii=False, indent=2)
    print(Result(code="0", message=output).model_dump_json())


if __name__ == "__main__":
    main()


path: scripts/media.py
content: import json
import os
import sys
import collections
from typing import Dict, Any
from pathlib import Path
import pandas as pd
from PIL import Image
import logging
import time
import click
from chunks import upload

__all__ = ["media_list"]

COLUMNS = ["session_id", "path", "material", "timestamp"]
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"}
VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov"}

IMAGE_MAX_SIZE, VIDEO_MAX_SIZE = 8 * 1024 * 1024, 50 * 1024 * 1024
IMAGE_MIN_WIDTH, IMAGE_MIN_HEIGHT, IMAGE_MAX_PIXELS = 300, 300, 36_000_000


def validate(file_path: str) -> Dict[str, Any]:
    """
    校验图片/视频文件是否合法。

    返回示例：
    {
        "valid": True,
        "file_type": "image",
        "errors": [],
        "warnings": []
    }
    """
    result = {"valid": False, "file_type": None, "errors": [], "warnings": []}

    if not os.path.isfile(file_path):
        result["errors"].append("文件不存在")
        return result

    _, ext = os.path.splitext(file_path)
    ext = ext.lower()

    file_size = os.path.getsize(file_path)

    # 图片校验
    if ext in IMAGE_EXTENSIONS:
        result["file_type"] = "image"

        if file_size > IMAGE_MAX_SIZE:
            result["warnings"].append("图片单张大小建议≤8MB")
            return result

        try:
            with Image.open(file_path) as img:
                width, height = img.size
                total_pixels = width * height

                if width < IMAGE_MIN_WIDTH or height < IMAGE_MIN_HEIGHT:
                    result["errors"].append(
                        f"图片分辨率不足，当前为 {width}x{height}，要求至少 300x300"
                    )

                if total_pixels > IMAGE_MAX_PIXELS:
                    result["errors"].append(
                        f"图片总像素过大，当前为 {total_pixels}，要求≤36,000,000"
                    )
                result["valid"] = True
                return result
        except Exception as e:
            result["errors"].append(f"无法读取图片文件: {e}")
            return result

    # 视频校验
    if ext in VIDEO_EXTENSIONS:
        result["file_type"] = "video"

        if file_size > VIDEO_MAX_SIZE:
            result["errors"].append("视频文件大小超过 50MB")
            return result

        result["valid"] = True
        return result

    result["errors"].append(
        "不支持的文件格式，仅支持图片(jpg/jpeg/png)或视频(mp4/avi/mov)"
    )
    return result


def load(session_id: str) -> pd.DataFrame:
    path = Path(f"/tmp/kickart/material_state_{session_id}.csv")
    if not path.exists():
        return pd.DataFrame(columns=COLUMNS)
    return pd.read_csv(path, header=None, names=COLUMNS)


def save(session_id: str, df: pd.DataFrame):
    path = Path(f"/tmp/kickart/material_state_{session_id}.csv")
    os.makedirs(path.parent, exist_ok=True)
    df.to_csv(path, index=False, header=False)


def remove(session_id: str):
    path = Path(f"/tmp/kickart/material_state_{session_id}.csv")
    os.remove(path)

def media_list(session_id):
    """列出所有已上传的抖音营销素材"""
    df = load(session_id)
    df = df["material"].map(lambda x: json.loads(x))
    return df.to_list()

@click.group()
def main():
    """抖音营销素材上传工具"""
    logging.info(f"[tool] >>> python3 {' '.join(sys.argv)}")


@main.command()
@click.argument("file")
@click.option("--session-id", "-s", required=True, help="会话ID")
def add(file, session_id):
    """上传抖音营销素材到远程服务器，返还上传后的素材URL"""
    # 文件校验 图片8M，视频50M
    result = validate(file)
    if not result["valid"]:
        click.echo(result)
        return
    # upload file to remote server
    matriel = upload({"file": file})

    row = collections.defaultdict()
    row["session_id"] = session_id
    row["path"] = file
    row["material"] = matriel.model_dump_json()
    row["timestamp"] = str(time.time())

    df = load(session_id)
    df.loc[len(df)] = row
    save(session_id, df)
    click.echo(matriel)


@main.command()
@click.option("--session-id", "-s", required=True, help="会话ID")
def list(session_id):
    """列出所有已上传的抖音营销素材"""
    df = load(session_id)
    df = df["material"].map(lambda x: json.loads(x))
    click.echo(df.to_list())
    return df.to_list()


@main.command()
@click.option("--session-id", "-s", required=True, help="会话ID")
def clear(session_id):
    """清空当前会话中的所有已上传的抖音营销素材"""
    remove(session_id)


if __name__ == "__main__":
    main()


path: scripts/requirements.txt
content: pydantic==2.12.5
qrcode==8.2
pandas==2.3.3
python-dotenv>=1.1.1
requests>=2.31.0
jsonpath>=0.82.2
Pillow>=10.1.0
urllib3>=2.1.0
click>=8.3.2


path: scripts/poll.sh
content: #!/bin/bash
TASK_ID="$1"
VIDEO_FILE="$2"
SESSION="$3"
CHANNEL="$4"
ACCOUNT="$5"

# 确保输出目录存在
mkdir -p "$(dirname "$VIDEO_FILE")"
# 记录开始时间和超时时间（1小时=3600秒）
START_TIME=$(date +%s)
TIMEOUT_SECONDS=3600

while true; do
    # 检查是否超时
    CURRENT_TIME=$(date +%s)
    ELAPSED=$((CURRENT_TIME - START_TIME))
    if [ "$ELAPSED" -ge "$TIMEOUT_SECONDS" ]; then
        echo timeout
        exit 1
    fi

    # 调用task.py查询，忽略错误输出
    RESULT=$(python3 task.py --id "$TASK_ID" --output "$VIDEO_FILE" 2>/dev/null)
    # 检查是否存在code字段
    CODE=$(echo "$RESULT" | jq -r '.code' 2>/dev/null || echo '-1')
    
    if [ "$CODE" = "0" ]; then
        # 任务成功：输出结果文件内容
        openclaw agent --session-id "$SESSION" -m "任务 $TASK_ID 执行成功，任务结果见$RESULT。阅读「消费成片指南.md」中「八、任务结果处理」章节，将任务结果告知用户。" --deliver --reply-channel "$CHANNEL" --reply-to "$ACCOUNT"
        exit 0
    elif [ "$CODE" = "1000" ] || [ "$CODE" = "-1" ]; then
        # 任务执行中/查询失败：等待30秒后重试
        sleep 30
        continue
    else
        # 任务失败：输出错误信息
        openclaw agent --session-id "$SESSION" -m "任务 $TASK_ID 执行失败，失败原因见$RESULT。阅读「消费成片指南.md」中「八、任务结果处理」章节，将任务结果告知用户。" --deliver --reply-channel "$CHANNEL" --reply-to "$ACCOUNT"
        exit 1
    fi
done

path: scripts/servicev2.py
content: # Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import requests
from urllib.parse import urlencode

import logging

# 请求接口信息
ADDR = os.getenv("ARK_SKILL_API_BASE")
[REDACTED_SECRET]"ARK_SKILL_API_KEY") or ""
SERVICE = "iccloud_muse"
REGION = "cn-north"
ACTION = "SubmitAiTemplateTaskAsync"
VERSION = "2025-11-25"


# 请求示例
def _do_request(
    method: str,
    queries: dict,
    body: bytes,
    action: str,
    version: str = VERSION,
    service: str = SERVICE,
):
    """发起请求（支持GET/POST，包含签名逻辑）"""
    # 1. 处理查询参数，添加Action和Version
    queries["Action"] = action or ACTION
    queries["Version"] = version or VERSION

    # 构建请求地址
    query_string = urlencode(queries)
    query_string = query_string.replace("+", "%20")
    url = f"{ADDR}?{query_string}"

    # 4. 构建完整请求头
    headers = {
        "Content-Type": "application/json",
        "ServiceName": service,
        "Authorization": "Bearer " + TOKEN,
    }

    # 6. 发起请求并处理响应
    logging.info(f">>> {method.upper()} {url} {headers} {body}")
    response = requests.request(
        method=method.upper(), url=url, headers=headers, data=body, timeout=30
    )
    logging.info(f"<<< {response.headers} {response.text}")

    return response


path: scripts/servicev1.py
content: import os
import hmac
import time
import logging
import hashlib
import requests
from urllib.parse import urlencode, urlparse


# 请求接口信息
ADDR = "https://icp.volcengineapi.com"
SERVICE = "iccloud_muse"
REGION = "cn-north"
ACTION = "SubmitAiTemplateTaskAsync"
VERSION = "2025-11-25"
### 认证配置
[REDACTED_SECRET]"ACCESS_KEY_ID") or ""
SECRET_ACCESS_KEY = os.getenv("SECRET_ACCESS_KEY") or ""


# 构造header Authorization
def hmac_sha256(key: bytes, content: str) -> bytes:
    """HMAC-SHA256加密"""
    h = hmac.new(key, content.encode("utf-8"), hashlib.sha256)
    return h.digest()


def get_signed_key(secret_key: str, date: str, region: str, service: str) -> bytes:
    """生成签名密钥链"""
    k_date = hmac_sha256(secret_key.encode("utf-8"), date)
    k_region = hmac_sha256(k_date, region)
    k_service = hmac_sha256(k_region, service)
    k_signing = hmac_sha256(k_service, "request")
    return k_signing


def hash_sha256(data: bytes) -> bytes:
    """SHA256哈希"""
    h = hashlib.sha256()
    h.update(data)
    return h.digest()


# 请求示例
def _do_request(
    method: str,
    queries: dict,
    body: bytes,
    action: str,
    version: str = VERSION,
    service: str = SERVICE,
):
    """发起请求（支持GET/POST，包含签名逻辑）"""
    # 1. 处理查询参数，添加Action和Version
    queries["Action"] = action or ACTION
    queries["Version"] = version or VERSION

    # 构建请求地址
    query_string = urlencode(queries)
    query_string = query_string.replace("+", "%20")
    url = f"{ADDR}?{query_string}"

    # 2. 构建签名核心材料
    date = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(time.time()))
    auth_date = date[:8]  # 提取日期部分（YYYYMMDD）

    # 计算请求体哈希
    payload = hash_sha256(body).hex()

    # 构建签名头部列表
    signed_headers = ["host", "x-date", "x-content-sha256", "content-type"]
    parsed_url = urlparse(ADDR)
    host = parsed_url.netloc  # 提取主机名（如：icp.volcengineapi.com）

    # 构建规范头部字符串
    header_list = []
    for header in signed_headers:
        if header == "host":
            header_list.append(f"{header}:{host}")
        elif header == "x-date":
            header_list.append(f"{header}:{date}")
        elif header == "x-content-sha256":
            header_list.append(f"{header}:{payload}")
        elif header == "content-type":
            header_list.append(f"{header}:application/json")
    header_string = "\n".join(header_list)

    # 构建规范请求字符串
    canonical_string = "\n".join(
        [
            method.upper(),
            "/",
            query_string,
            f"{header_string}\n",
            ";".join(signed_headers),
            payload,
        ]
    )
    hashed_canonical_string = hash_sha256(canonical_string.encode("utf-8")).hex()
    credential_scope = f"{auth_date}/{REGION}/{SERVICE}/request"
    sign_string = "\n".join(
        ["HMAC-SHA256", date, credential_scope, hashed_canonical_string]
    )
    signed_key = get_signed_key(SECRET_ACCESS_KEY, auth_date, REGION, SERVICE)
    signature = hmac_sha256(signed_key, sign_string).hex()
    authorization = (
        f"HMAC-SHA256 Credential={ACCESS_KEY_ID}/{credential_scope},"
        f" SignedHeaders={';'.join(signed_headers)},"
        f" Signature={signature}"
    )

    # 4. 构建完整请求头
    headers = {
        "X-Date": date,
        "X-Content-Sha256": payload,
        "Content-Type": "application/json",
        "Authorization": authorization,
    }

    # 6. 发起请求并处理响应
    logging.info(f">>> {method.upper()} {url} {headers} {body}")
    response = requests.request(
        method=method.upper(), url=url, headers=headers, data=body, timeout=30
    )
    logging.info(f"<<< {response.headers} {response.text}")

    return response


path: scripts/creative.py
content: import sys
import json
import logging
import click
from collections import defaultdict

from media import media_list
from base import Result, perror
from task import submit, poll


def concat(input=None, session_id=None, duration=15, prompt="无人物出镜") -> dict:
    material = defaultdict()
    if input:
        with open(input, "r") as f:
            material = json.load(f)
    material["video_duration"] = duration
    material["user_prompt"] = prompt

    if session_id:
        # Create a namespace-like object for media.list
        materials = media_list(session_id)
        material["user_images"] = [m for m in materials if m["type"] == "image"]
        material["user_videos"] = [m for m in materials if m["type"] == "video"]
    return material


def check(duration) -> Result:
    if duration < 0 or duration > 60:
        return Result(code="-1", message="视频时长必须在0-60秒之间", data=None)
    return Result(code="0", message="success", data=None)


@click.command()
@click.option("--input", type=str, help="素材分析结果JSON文件路径")
@click.option("--session-id", type=str, help="已上传的远程素材列表对应的会话ID")
@click.option("--duration", default=15, type=int, help="自定义时长，默认15秒")
@click.option(
    "--prompt", default="无人物出镜", type=str, help="自定义提示，默认无人物出镜"
)
@click.option("--output", required=True, type=str, help="输出结果所在的json文件路径")
def main(input, session_id, duration, prompt, output):
    """创意分析服务"""
    logging.info(f"[tool] >>> python3 {' '.join(sys.argv)}")

    material = json.dumps(
        concat(input, session_id, duration, prompt), ensure_ascii=False
    )

    check_res = check(duration)
    if check_res.code != "0":
        perror(check_res)

    submit_res = submit(3339986037439799, material)
    if submit_res.code != "0":
        perror(submit_res)
    print(f"提交任务成功，任务ID: {submit_res.message}", flush=True)

    poll_res = poll(submit_res.message)
    if poll_res.code != "0":
        perror(poll_res)
    with open(output, "w") as f:
        result = json.loads(poll_res.message)
        json.dump(result, f, ensure_ascii=False, indent=2)
    print(Result(code="0", message=output).model_dump_json())


if __name__ == "__main__":
    main()


path: scripts/storyboard.py
content: import sys
import json
import logging
import click

from base import Result, perror
from task import submit, poll


@click.command()
@click.option("--input", required=True, type=str, help="创意分析结果JSON文件路径")
@click.option("--output", required=True, type=str, help="输出结果所在的json文件路径")
def main(input, output):
    """故事板创作"""
    logging.info(f"[tool] >>> python3 {' '.join(sys.argv)}")

    with open(input, "r") as f:
        creative = f.read()
    submit_res = submit(4337201517621323, creative)
    if submit_res.code != "0":
        perror(submit_res)
    print(f"提交任务成功，任务ID: {submit_res.message}", flush=True)

    poll_res = poll(submit_res.message)
    if poll_res.code != "0":
        perror(poll_res)
    with open(output, "w") as f:
        result = json.loads(poll_res.message)
        json.dump(result, f, ensure_ascii=False, indent=2)
    print(Result(code="0", message=output).model_dump_json())


if __name__ == "__main__":
    main()


path: scripts/base.py
content: import os
import time
import logging
from pydantic import BaseModel
from dotenv import load_dotenv

__all__ = ["filename", "Result", "authentication"]
### 日志配置
filename = f"/tmp/openclaw/kickart/info.{time.strftime('%Y%m%d', time.localtime())}.log"
logging.basicConfig(
    level=logging.INFO,
    filename=filename,
    format="%(asctime)s - %(levelname)s - %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
load_dotenv()


### 结果模型
class Result(BaseModel):
    code: str
    message: str
    data: object = None


def perror(res: Result):
    if res.code != "0":
        print(res.model_dump_json())
    exit(1)


### 判断使用那种鉴权方式
def authentication():
    ADDR = os.getenv("ARK_SKILL_API_BASE")
    [REDACTED_SECRET]"ARK_SKILL_API_KEY")

    if ADDR is not None and TOKEN is not None:
        return 2

    [REDACTED_SECRET]"ACCESS_KEY_ID")
    SECRET_ACCESS_KEY = os.getenv("SECRET_ACCESS_KEY")
    if ACCESS_KEY_ID is not None and SECRET_ACCESS_KEY is not None:
        return 1
    return 0


path: scripts/consumption.py
content: import os
import sys
import json
import logging
import subprocess
from subprocess import DEVNULL
import click
from base import Result, perror
from task import submit


@click.command()
@click.option("--storyboard", default=0, type=int, help="用于成片任务的故事板编号")
@click.option(
    "--session",
    required=True,
    type=str,
    help="当前Session的UUID，可用于openclaw agent指令的--session-id参数",
)
@click.option("--metadata", required=True, type=str, help="当前消息的完整未修改元信息")
@click.option("--input", required=True, type=str, help="故事板创作结果JSON文件路径")
@click.option("--output", required=True, type=str, help="输出结果所在的json文件路径")
def main(storyboard, session, metadata, input, output):
    """消费成片任务"""
    logging.info(f"[tool] >>> python3 {' '.join(sys.argv)}")

    with open(input, "r") as f:
        data = json.load(f)

    storyboards = data["storyboards"]
    if storyboard >= len(storyboards):
        perror(
            Result(
                code="-1",
                message=f"故事板编号超出范围，总故事板数为{len(storyboards)}, 请指定一个有效的故事板编号（1-{len(storyboards)}）",
            )
        )

    data["storyboard"] = storyboards[storyboard]
    data["storyboards"] = None

    submit_res = submit(2935355633875543, json.dumps(data, ensure_ascii=False))
    if submit_res.code != "0":
        perror(submit_res)
    print(submit_res.model_dump_json(), flush=True)

    ### 创建后台轮询任务
    workspace = os.path.dirname(os.path.abspath(__file__))
    metadata = json.loads(metadata)
    cmd = [
        "bash",
        "poll.sh",
        submit_res.message,
        output,
        session,
        metadata["channel"],
        metadata["chat_id"],
    ]
    process = subprocess.Popen(
        cmd,
        cwd=workspace,
        text=True,
        start_new_session=True,
        stdin=DEVNULL,
        stdout=DEVNULL,
        stderr=DEVNULL,
    )
    logging.info(f"[cron] >>> {submit_res.message} to {process.pid}")


if __name__ == "__main__":
    main()


path: scripts/publish.py
content: import os
import sys
import json
import qrcode
import logging
import jsonpath
import subprocess
import urllib.parse
import click
from copy import deepcopy
from base import Result

# 数据模版
TEMPLATE = {
    "common_data": {"initial_scene": 4},
    "infini_editor": {
        "instances": [{"resource": {"file_type": 2, "is_local": False, "url": ""}}]
    },
    "publish": {"text": {"body": ""}},
}


def upload(url, body, output, conversation, metadata):
    payload = deepcopy(TEMPLATE)
    resource = jsonpath.jsonpath(payload, "$.infini_editor.instances.0.resource")
    if resource:
        resource[0]["url"] = url
    text = jsonpath.jsonpath(payload, "$.publish.text")
    if text:
        text[0]["body"] = body
    # 将字典转换为紧凑的JSON字符串
    compact_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)

    # 构建schema URL
    schema = "aweme://studio/composer?config=" + urllib.parse.quote(compact_json)
    img = qrcode.make(data=schema)
    # 自动创建父目录
    os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
    img.save(output, format="PNG")  # type: ignore

    # 通过工具将二维码发送给用户
    metadata = json.loads(metadata)
    conversation = json.loads(conversation)
    cmd = [
        "openclaw",
        "message",
        "send",
        "--media",
        output,
        "-t",
        metadata["chat_id"],
        "--reply-to",
        conversation["message_id"],
    ]
    logging.info(f"[openclaw] >>> {' '.join(cmd)}")
    retcode = subprocess.call(cmd)
    logging.info(f"[openclaw] >>> return code = {retcode}")

    # 这里必须开启ensure_ascii，否则无法跳转
    encoded_url = urllib.parse.quote(url)
    encoded_body = urllib.parse.quote(body)
    jump = (
        "https://magic.solutionsuite.cn/html-box/vev4VhD2gAY?url="
        + encoded_url
        + "&body="
        + encoded_body
    )
    return Result(code="0", message="", data={"qrcode": output, "jump": jump})


@click.command()
@click.option("--url", required=True, help="视频链接")
@click.option("--body", required=True, help="发布页正文")
@click.option("--output", "-o", required=True, help="二维码PNG图片本地保存路径")
@click.option(
    "--conversation", required=True, type=str, help="当前消息的完整未修改上下文元数据"
)
@click.option("--metadata", required=True, type=str, help="当前消息的完整未修改元信息")
def main(url, body, output, conversation, metadata):
    """视频发布到抖音平台"""
    logging.info(f"[tool] >>> python3 {' '.join(sys.argv)}")
    result = upload(
        url=url, body=body, output=output, conversation=conversation, metadata=metadata
    )
    print(result)


if __name__ == "__main__":
    main()


path: references/火山鉴权指南.md
content: # 火山引擎鉴权操作指南
## 用途说明
本指南用于指导agent完成火山引擎API调用前的身份鉴权校验，确保ACCESS_KEY_ID/SECRET_ACCESS_KEY（访问密钥）配置正确可用。

---

## 🚀 鉴权操作流程（必须严格按顺序执行）
### 步骤1：执行环境变量检查命令
直接运行以下命令，按优先级检查鉴权配置：
```bash
echo "ARK_SKILL_API_BASE: $ARK_SKILL_API_BASE" && echo "ARK_SKILL_API_KEY: $ARK_SKILL_API_KEY" && echo "[REDACTED_SECRET]" && echo "SECRET_ACCESS_KEY: $SECRET_ACCESS_KEY"
```

### 步骤2：判断鉴权结果（按优先级判断）
1. **第一优先级鉴权方式（Bearer Token）**：如果`ARK_SKILL_API_BASE`和`ARK_SKILL_API_KEY`均为非空值，鉴权通过，可直接使用Bearer Token方式调用接口
2. **第二优先级鉴权方式（AK/SK签名）**：如果第一优先级不满足，检查`ACCESS_KEY_ID`和`SECRET_ACCESS_KEY`是否均为非空值，若均非空则鉴权通过，使用AK/SK签名方式调用接口
3. **鉴权不通过**：上述两种方式均不满足，说明未配置或配置无效，进入「未配置引导流程」

---

## ❌ 未配置时的引导方案
1. 引导用户直接在聊天中发送ACCESS_KEY_ID/SECRET_ACCESS_KEY内容：
   > 直接在此处发送您的Access Key ID和Secret Access Key，我会帮您完成临时环境变量配置
2. 收到用户发送的ACCESS_KEY_ID/SECRET_ACCESS_KEY后，执行配置命令：
   ```bash
   export [REDACTED_SECRET]
   export SECRET_ACCESS_KEY=用户提供的SECRET_ACCESS_KEY值
   ```
3. 配置完成后告知用户：
   > 已完成ACCESS_KEY_ID/SECRET_ACCESS_KEY临时配置，当前配置仅在本次会话生效，不会持久化存储，请放心使用
4. 后续所有相关脚本执行时，均会自动通过`export`指定这两个环境变量，确保鉴权正常，无需用户重复配置

---

## ⚠️ 安全注意事项（强制遵守）
1. **敏感信息掩码处理**：用户提供的ACCESS_KEY_ID/SECRET_ACCESS_KEY属于最高级敏感信息，写入数据库时必须全部替换为`***`掩码，禁止明文存储
2. **临时生效原则**：仅将ACCESS_KEY_ID/SECRET_ACCESS_KEY配置到当前进程的环境变量中，禁止写入任何本地文件、配置文档或持久化存储
3. **最小权限提示**：提醒用户使用最小权限的ACCESS_KEY_ID/SECRET_ACCESS_KEY，避免使用主账号密钥，降低安全风险
4. **禁止泄露**：任何场景下都不得向第三方泄露用户的ACCESS_KEY_ID/SECRET_ACCESS_KEY内容，包括返回给用户的消息中也不得展示完整的ACCESS_KEY_ID/SECRET_ACCESS_KEY

path: references/套餐开通指南.md
content: # 火山Kickart套餐开通与校验指南
## 用途说明
本指南用于指导agent完成火山Kickart营销短视频创作服务的套餐有效性校验，确保用户有可用服务额度后再执行后续创作流程。

---

## 🚀 套餐校验操作流程（必须严格执行）
### 步骤1：执行套餐查询命令
直接运行以下脚本查询用户套餐状态：
```bash
python3 scripts/service.py
```

### 步骤2：结果处理逻辑
#### 场景1：接口调用错误（命令非0退出/返回非标准JSON）
- 立即终止后续业务流程
- 参考「错误处理规范」匹配错误码，向用户明确告知错误原因和解决方案
- 常见错误示例处理：
  - 签名验证失败：提示用户AK/SK配置错误，请重新核对
  - 服务权限不足：提示用户企业账号未开通Kickart权限，请联系管理员开通
  - 网络超时：提示用户当前网络不稳定，请稍后重试

#### 场景2：接口调用成功（返回标准JSON结构）
成功返回格式示例：
```json
{"code": 0, "message": "2026-03-23T11:57:59Z"}
```
解析返回结果中的`message`字段，转换为北京时间后与当前时间比较：
- ✅ **套餐有效**：`message`大于当前时间
  - 向用户告知：`✅ 当前套餐有效，有效期至：YYYY年MM月DD日 HH:MM:SS（北京时间）`
  - 校验通过，继续执行后续业务流程
- ❌ **套餐已过期**：`message`小于等于当前时间
  - 向用户告知：`❌ 当前创作服务套餐已过期，请前往 [套餐开通页面](https://console.volcengine.com/kickart/fusion/setting/combobuy?tab=combo) 开通或续费套餐后重试`
  - 立即终止后续业务流程

---

## ⚠️ 注意事项
1. 套餐查询结果必须100%持久化到本地数据库，记录用户套餐有效期
2. 禁止在套餐过期状态下调用任何付费创作接口，避免产生不必要的费用
3. 套餐有效期展示必须转换为北京时间（UTC+8），避免用户误解


path: references/视频发布指南.md
content: ## 🔍 功能说明
本工具可以基于生成好的营销视频链接和发布文案，自动生成抖音发布跳转二维码，手机扫码即可直接进入抖音创作者发布页面，**自动填充视频素材和发布文案**，无需手动上传填写，大幅提升发布效率。
---
## 📝 使用步骤（必须严格按顺序执行）
1. **参数校验**：验证视频链接（公网可访问MP4）和文案（长度≤20字符）合法性，不符合直接终止流程
2. **环境准备**：执行命令确保输出目录存在：
   ```bash
   mkdir -p /tmp/openclaw/kickart/output
   ```
3. **生成文件名**：严格使用指定格式生成唯一二维码文件名：
   ```bash
   QRCODE_FILE="/tmp/openclaw/kickart/output/qrcode_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).png"
   ```
4. **执行生成脚本**：调用`publish.py`生成二维码和跳转链接，检查返回`code`必须为`0`：
   ```bash
   python3 ./scripts/publish.py --url "你的视频链接" --body "你的发布文案" --output "$QRCODE_FILE"
   ```
   成功输出示例：
   ```json
   {"code": "0", "data": {"qrcode": "二维码路径", "jump": "跳转链接"}}
   ```
5. **回复用户**：使用标准化回复模板向用户返回结果。**⚠️ 二维码已通过脚本发送给用户，不需要二次发送**。
---

## 💬 标准化回复模板（必须严格使用）
```
✅ 抖音发布链接已生成～
---
### 📱 发布方式：
1. **扫码发布**：打开抖音APP扫描上方二维码，即可自动跳转至发布页面，视频和文案已预填完成
   ⚠️ 受网络影响，二维码发送存在延迟，请稍等片刻，或者稍后询问我～
2. **链接发布**：在手机端点击下方方跳转链接，直接唤起抖音APP进入发布页面
   🔗 跳转发布链接：[点击发布](<跳转链接>)

⚠️ 注意事项：
- 二维码和链接有效期均为24小时，请尽快使用
- 跳转发布后请自行检查视频和文案是否正确，确认无误后再点击发布
- 链接需要在手机端打开，PC端无法唤起抖音APP
```
---
## ⚡ 错误熔断机制
1. **参数校验失败**：立即终止流程，明确告知用户具体错误原因（如链接不可访问、文案过长等）
2. **脚本执行失败**：返回`code≠0`时终止流程，向用户展示具体错误信息
3. **二维码生成失败**：重试最多2次，仍失败则提示用户使用跳转链接发布
4. **网络异常**：返回任务执行失败，建议用户稍后重试
---
## 📌 参数说明
### 输入参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 是 | 生成的营销视频公网下载链接，支持MP4格式 |
| `--body` | 是 | 抖音发布页的正文文案，支持普通文本、话题标签（#xxx）、@用户（@xxx）格式 |
| `--output` | 是 | 二维码PNG图片本地保存路径，后缀为png |
| `--metadata` | 是 | String | 当前消息的完整未修改的元信息（inbound_meta），后续用于确定消息回复的具体用户 |
| `--conversation` | 是 | String | 当前消息的完整未修改的上下文元数据（conversation_info），后续用于确定回复的消息 |

### 返回参数
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | 字符串 | 状态码，`0` 表示成功 |
| `data.qrcode` | 字符串 | 生成的二维码图片本地路径 |
| `data.jump` | 字符串 | 网页跳转发布链接，点击可直接唤起抖音发布页 |
---
## ⚠️ 注意事项
1. 视频链接必须为公网可直接访问的地址，不能是需要鉴权的私有链接
2. 二维码和跳转链接有效期均为24小时，生成后请尽快使用
3. 发布的设备必须安装抖音APP，且登录的账号拥有创作者发布权限，否则无法跳转发布页
4. 发布文案长度需符合抖音平台规范，最长支持5000字符
5. 跳转发布后请自行检查视频和文案是否正确，确认无误后再点击发布
6. 跳转链接需要在手机端打开，PC端打开无法唤起抖音APP
---
## 💡 使用示例
### 示例命令：
```bash
python3 ./scripts/publish.py --url "https://example.com/marketing_video.mp4" --body "春季新款上新啦！超好用的保湿面霜，敏感肌也能用~ #好物推荐 #护肤 #春季上新" --output "/tmp/openclaw/kickart/output/qrcode_xxx.png" --metadata '{"schema":"openclaw.inbound_meta.v1","chat_id":"agent:main:feishu:direct:ou_33bc2d2fe3b01cdca65300c750a85c10","account_id":"default","channel":"feishu","provider":"feishu","surface":"feishu","chat_type":"direct"}'
```

path: references/消费成片指南.md
content: # 视频生成工具使用指南

## 一、概述
本工具用于基于故事板创作结果JSON文件，自动调用AIGC视频生成能力完成成片制作，最终输出包含视频成片信息的标准JSON结果文件。

## 二、前置依赖
1. 已配置火山鉴权的AK/SK，可正常调用接口
2. 环境已安装Python 3.12+
3. 依赖Python第三方库：`requests`
4. 需配套工具模块：`task.py`，包含以下方法/类：
   - `Result`：结果封装类，包含`code`（返回码）和`message`（返回内容）属性
   - `submit(product_id: str, params: str)`：提交分析任务接口
   - `poll(task_id: str)`：轮询任务结果接口
   - `perror(result: Result)`：错误处理方法，异常时自动退出程序

## 三、命令行参数说明
| 参数名 | 是否必填 | 类型 | 说明 |
|--------|----------|------|------|
| `--storyboard` | 否 | Integer | 用于成片任务的故事板编号，对应输入JSON文件中故事板数组的下标，默认值为0（即第1个故事板） |
| `--session` | 是 | String | 当前Session的UUID，后续用于openclaw agent指令的--session-id参数 |
| `--metadata` | 是 | String | 当前消息的完整未修改的元信息，后续用于确定消息回复的具体用户 |
| `--input` | 是 | String | 故事板创作结果JSON文件的本地路径，文件内容需为故事板对象数组结构 |
| `--output` | 是 | String | 生成的视频结果JSON文件的保存路径，需包含完整文件名（后缀为`.json`） |

## 四、返回结果说明
### 成功返回
工具最终标准输出JSON格式结果：
```json
{"code": "0", "message": "<消费成片任务ID>"}
```

### 失败返回
工具最终标准输出JSON格式错误信息：
```json
{"code": "<错误码>", "message": "<具体错误描述内容>"}
```

## 五、使用示例
```bash
# 1. 提交任务
STORYBOARD_FILE="/tmp/kickart/output/storyboard_1742525956789_987654.json"
VIDEO_FILE="/tmp/kickart/output/video_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json"
python3 ./scripts/consumption.py --storyboard 3 --session 4058aeed-6c30-4349-91ab-76572f77c341 --input /tmp/openclaw/kickart/output/storyboard_1775130638978_120618.json --output /tmp/openclaw/kickart/output/video_1775184027583_107114.json --metadata '{"schema":"openclaw.inbound_meta.v1","chat_id":"agent:main:feishu:direct:ou_33bc2d2fe3b01cdca65300c750a85c10","account_id":"default","channel":"feishu","provider":"feishu","surface":"feishu","chat_type":"direct"}'

# 任务提交成功返回示例
{"code": "0", "message": "123456789"}
> 任务ID：123456789
```

## 六、注意事项
1. 输入的故事板JSON必须为数组结构，每个元素对应一套独立的故事板方案，编号从0开始计数
2. 故事板内容需严格符合视频生成接口要求的字段规范，否则会导致任务提交失败
3. 视频生成耗时根据成片的时长、复杂度而定，可能需要数分钟到数十分钟不等。工具提交成功后会启动后台轮询并在完成/失败时主动通知用户；主调用方应以返回的 Task ID 为准做进度查询，避免等待脚本长时间运行导致 exec 超时。
4. 若输出路径下已存在同名文件，会直接覆盖原有文件，请提前做好数据备份
5. 生成的视频结果JSON默认使用UTF-8编码，包含视频下载地址、时长、分辨率、文件大小等完整元信息

## 七、任务提交流程
1. **前置强校验**：调用脚本前必须确认以下所有条件都满足，任意不满足直接终止流程：
   - 创意分析环节用户「确认创意无误」文字回复记录存在且有效
   - 故事板环节用户「确认使用该故事板生成视频」文字回复记录存在且有效
   - 故事板内容已获得用户明确确认，无修改意见
   - 故事板时长符合视频生成接口要求（≤30秒）
   - 全流程上下文完整（创意分析任务ID、故事板任务ID、用户确认的所有需求参数均已持久化）
2. **成本提示与二次确认**：调用脚本前必须向用户明确告知：
   - 创点扣除后不可退回，重复提交将产生额外费用
   必须获得用户明确的「确认生成」文字回复后，才可执行后续操作
3. **提交任务**：由主Agent直接调用`consumption.py`提交任务：
   ```bash
   python3 ./scripts/consumption.py --storyboard <故事板编号> --session <当前session的uuid> --metadata <当前消息的完整元数据> --input <故事板JSON文件路径> --output <最终视频的JSON文件路径>
   ```
   解析返回结果，提取任务ID：`{"code":"0", "message":"<任务ID>"}`

4. **通知用户提交成功**：任务提交后，主Agent立即回复用户：
```txt
✅ 成片生成任务已提交成功，任务ID：<任务ID>，正在后台执行~
⚠️ 请勿重复提交相同任务，避免产生额外创点消费
⚠️ 任务完成后我会自动通知您，您也可以随时询问进度
```

# 八、任务结果处理
1. 任务执行成功
   ① 从返回的JSON结果中提取`storyboard_input`（视频描述）、`video_url`（成片下载链接）、`expire`（链接有效期）等关键信息
   ② 将上述信息整理为用户易懂的格式后通知用户，**禁止直接返回原始文件路径或JSON内容**
2. 任务执行失败
   ① 从返回结果中提取错误描述，结合错误处理规范告知用户错误原因和解决方案

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