
Byted Kickart Marketing Material Generator
- 44 installs
- 411 repo stars
- Updated August 4, 2026
- bytedance/agentkit-samples
byted-kickart-marketing-material-generator is a Claude skill that generates and publishes Douyin product marketing short videos through Volcengine Kickart.
About
This skill generates product marketing short videos for Douyin and Douyin stores using Volcengine Kickart. A developer submits a Douyin-store or Douyin product link, or uploads custom images and videos, and the skill runs an end-to-end pipeline: material analysis, creative and audience analysis, storyboard scripting, video rendering, and Douyin publishing. Each async step is tracked by a persisted Task ID.
- Generates Douyin / Douyin-store product marketing short videos via Volcengine Kickart
- Runs a full pipeline: material analysis, creative analysis, storyboard, video render, publish
- Publishes to Douyin via a scan-to-post QR code
Byted Kickart Marketing Material Generator by the numbers
- 44 all-time installs (skills.sh)
- Ranked #911 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
byted-kickart-marketing-material-generator capabilities & compatibility
Requires a paid Volcengine Kickart plan with credit (创点) balance; async tasks consume credits
- Capabilities
- video generation · marketing video · social publishing
- Works with
- openai
- Use cases
- video generation · marketing
- Runs
- Runs locally
- Pricing
- Bring your own API key
What byted-kickart-marketing-material-generator says it does
基于火山引擎Kickart服务生成抖店/抖音商品营销短视频
视频生成 | 读取`./references/消费成片指南.md`(含完整执行流程规范)调用对应服务接口
输出抖音发布跳转二维码(图片形式)、使用说明,提醒用户扫码即可跳转发布
npx skills add https://github.com/bytedance/agentkit-samples --skill byted-kickart-marketing-material-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 44 |
|---|---|
| repo stars | ★ 411 |
| Last updated | August 4, 2026 |
| Repository | bytedance/agentkit-samples ↗ |
What it does
Generate and publish a Douyin product marketing short video from a store link or uploaded materials.
Who is it for?
Creating and publishing Douyin / Douyin-store product marketing short videos from a product link or custom assets.
When should I use this skill?
A user sends a Douyin-store product link or asks to make a marketing short video.
What you get
A rendered marketing short video and a Douyin publish QR code.
- marketing short video
- storyboard script
- creative analysis
By the numbers
- 5-plus step mandatory pre-check flow
- 6-stage pipeline (material/creative/storyboard/video/upload/publish)
- 0-60s output video length limit
Files
营销视频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/目录,执行前自动创建目录:
mkdir -p /tmp/openclaw/kickart/output2. 唯一文件名生成规则:文件名格式为 <文件类型>_<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. 文件名生成命令:执行每个环节前动态生成唯一文件名,示例:
# 生成素材分析输出文件名
MATERIAL_FILE="/tmp/openclaw/kickart/output/material_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json"⚠️ 错误处理规范
所有错误必须明确告知原因和可执行解决方案,禁止模糊提示!!!
| 错误码 | 错误描述 | 详细说明 | 用户处理建议 |
|---|---|---|---|
| 0 | 无返回值 | 接口调用成功,但服务返回结果为空 | 请稍后重试,如问题持续请联系火山技术支持 |
| 1402 | 创点不足 | 调用接口时,用户账户的创点额度不足 | 请前往 创点充值页面 充值创点或升级套餐 |
| 1412 | 图片格式错误 | 调用接口时,输入参数中包含了非支持的图片格式 | 请检查图片格式是否为 jpg、png 等支持的格式 |
| 1413 | 无效的媒体URL错误 | 调用接口时,输入参数中包含了无效的媒体URL | 请检查您提供的URL是否正确,避免包含特殊字符或格式错误 |
| 1414 | 输入包含敏感信息错误 | 调用接口时,输入参数中包含了敏感信息,如个人隐私数据等 | 暂不可生成带人物的营销视频,请等待后续版本更新 |
| 1415 | 输出包含敏感信息错误 | 调用接口时,服务返回结果中包含了敏感信息,如个人隐私数据等 | 暂不可生成带人物的营销视频,请等待后续版本更新 |
| 1416 | 输入媒体数量错误 | 用户输入的素材数量超过限制 | 提供的媒体素材数量超出限制,多出的素材可能不会使用 |
| 1417 | 大模型调用错误 | 模型调用出错,通常是输入参数错误 | 媒体素材处理存在问题,请重新尝试,如问题持续请联系火山技术支持 |
| 1418 | 时长计费参数错误 | 提交时入参时间有问题 | 要求的成片时长不符合技能要求,请按照0-60s的时长限制提交制作需求,如问题持续请联系火山技术支持 |
| 1501 | 用户套餐过期 | 调用接口时,用户套餐已过期 | 请前往 套餐开通页面 开通套餐 |
| 100010 | 签名验证失败 | AK/SK签名验证失败 | 请检查您提供的火山鉴权AK/SK是否正确,可访问火山引擎控制台确认 |
| 100013 | 缺少服务权限 | 缺少iccloud\_muse服务的RegisterArkClawCombo权限 | 您的企业账号未开通Kickart权限,请联系火山主账号管理员为您开通,或详询火山技术支持 |
| x01001 | AK/SK未配置 | 用户未配置AK/SK | 请输入火山鉴权的AK/SK,可访问火山引擎控制台获取 |
| x01010 | 有效套餐缺失 | 素材上传出现错误,通常是套餐原因 | 请前往 套餐开通页面 开通套餐 |
| 其他 | \- | 未明确列出的其他错误情况 | 稍后重试,如问题持续请联系火山技术支持 |
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
任务进度查询工具使用指南
目录
一、概述
本工具用于查询已提交的异步任务的执行进度和最终结果,支持自动轮询直到任务完成或超时。适用场景:当用户提及「查询进度」「查看结果」「生成怎么样了」「好了吗」等意图时使用,无需重复提交相同任务。
二、前置依赖
1. 已配置火山鉴权的AK/SK,可正常调用接口 2. 环境已安装Python 3.12+ 3. 依赖Python第三方库:requests 4. 已获取目标任务的有效Task ID(任务提交时返回的ID)
三、命令行参数说明
| 参数名 | 是否必填 | 类型 | 说明 |
|---|---|---|---|
--id | 是 | String | 要查询的目标任务ID,任务提交成功时返回 |
--output | 是 | String | 任务结果保存的JSON文件本地路径,需包含完整文件名(后缀为.json) |
四、返回结果说明
轮询中输出
任务执行过程中控制台会循环打印:
任务执行中,请不要中断任务...成功返回
工具最终标准输出JSON格式结果:
{"code": "0", "message": "<任务结果保存的文件绝对路径>"}同时任务完整结果会写入--output指定的JSON文件中。
失败返回
工具最终标准输出JSON格式错误信息:
{"code": "<错误码>", "message": "<具体错误描述内容>"}五、使用示例
# 示例:查询任务ID为7619174133103902774的任务进度,结果保存到/tmp/task_result.json
python3 ./scripts/task.py --id "7619174133103902774" --output "/tmp/task_result.json"
# 成功输出示例
任务执行中,请不要中断任务...
任务执行中,请不要中断任务...
{"code": "0", "message": "/tmp/task_result.json"}六、注意事项
1. 工具默认轮询策略:每次查询间隔6秒,最大重试3次,超时后返回失败 2. 仅支持查询本账号提交的任务,无法查询其他账号/其他应用创建的任务 3. 若输出路径下已存在同名文件,会直接覆盖原有文件,请提前做好数据备份 4. 任务完成后结果仅保留72小时,超过时效的任务无法查询到结果
七、Agent执行流程(强制遵守)
1. 意图匹配优先级检查:命中「查询进度/查看结果/生成怎么样了/好了吗」关键词时,优先触发本指南加载,禁止跳过直接执行其他逻辑 2. 任务ID持久化校验:优先从会话上下文临时存储中获取用户提交的最近一次异步任务(素材分析/创意分析/故事板创作/视频生成)对应的Task ID及关联信息(任务类型、提交时间、会话ID),若未获取到有效ID,直接告知用户「未查询到相关提交任务,请确认任务是否已提交」 3. 进程状态优先检查:
- 存在正在运行的对应任务exec子进程:直接调用
process(action=poll, sessionId=xxx, timeout=30000)轮询任务状态,无需重复调用查询脚本 - 无正在运行的子进程:使用获取到的有效Task ID调用查询脚本,禁止仅以本地脚本执行退出状态判断任务最终结果
4. 本地脚本超时特殊处理: 若本地执行脚本因超时/异常退出(返回非0状态码),必须立即触发查询流程,使用持久化的Task ID调用查询脚本确认后端任务真实状态,禁止直接告知用户任务失败 5. 查询脚本调用规范:调用scripts/task.py时,设置≥600000ms(10分钟)的yieldMs,长任务可自定义max_attempts参数增加轮询次数,调用完成后立即回复用户:「正在查询任务进度,请稍候~」 6. 结果反馈规范:
- 任务执行中:告知用户当前任务类型、提交时间,提示用户任务仍在后台执行,完成后会第一时间通知,无需反复查询
- 执行成功:读取结果文件内容,整理后反馈给用户
- 执行失败:明确告知用户错误原因和对应的解决建议,如需重试可协助重新提交任务
7. 兜底兼容逻辑:若本指南加载失败,自动降级到进程查询+Task ID接口查询逻辑,同时记录错误日志,不阻塞用户查询操作
创意分析服务使用指南
目录
一、概述
本脚本为创意分析批量处理工具,支持两种输入模式: 1. 基于已完成的素材分析结果,调用后端创意分析服务生成结构化的创意分析结果 2. 支持用户已上传到远程的图片/视频素材作为输入,自动完成素材解析、创意分析全流程 工具会自动完成任务提交、轮询、结果保存全流程。
⚠️ 重要提示:自定义素材必须先通过「素材上传指南」中的`upload.py add`命令上传到远程服务器,获取到远程URL后才可使用,不支持直接传入本地文件路径。
二、前置依赖
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):错误处理方法,异常时自动退出程序
三、命令行参数说明
| 参数 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
--input | String | 非必填 | 素材分析结果JSON文件的本地路径,与--session-id至少填写一个,两者同时提供时均生效,内容会合并用于创意分析 |
--session-id | String | 非必填 | 已上传的远程素材列表对应的会话ID,具体参考「素材上传指南.md」,与--input至少填写一个,两者同时提供时均生效,内容会合并用于创意分析 |
--duration | Integer | 是 | 自定义生成的视频总时长,取值范围0-60(单位:秒),需要用户指定。最终视频时长存在波动。 |
--prompt | String | 是 | 用户自定义创作提示词,传入后将优先基于提示词生成故事板,支持指定商品、投放平台、目标人群、核心卖点等方向 |
--output | String | 是 | 创意分析结果保存的JSON文件本地路径 |
四、返回结果说明
成功返回
{"code": "0","message": "<创意分析结果保存的文件绝对路径>"}同时控制台会打印:
提交任务成功,任务ID: <返回的任务ID>若用户上传素材数量超出20个,控制台会额外打印提示:
提示:您上传了X个素材,已自动保留前20个用于分析,超出的Y个素材已忽略同时结果文件会写入--output指定路径,包含完整的创意分析结果。
失败返回
{"code": "<错误码>","message": "<具体错误描述>"}五、使用示例
# 1. 确保输出目录存在
mkdir -p /tmp/kickart/output
# 2. 生成各环节唯一文件名
MATERIAL_FILE="/tmp/kickart/output/material_1742525890123_654321.json"
CREATIVE_FILE="/tmp/kickart/output/creative_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json"
# 示例1:基于素材分析结果文件生成创意分析
python3 ./scripts/creative.py --input "$MATERIAL_FILE" --output "$CREATIVE_FILE"
# 成功输出示例
提交任务成功,任务ID: 123456789
{"code": "0", "message": "/tmp/kickart/output/creative_1742525912345_123456.json"}
# 示例2:基于已上传的远程素材生成创意分析
CREATIVE_FILE2="/tmp/kickart/output/creative_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json"
python3 ./scripts/creative.py --session-id "<当前会话ID>" --output "$CREATIVE_FILE2"
# 超出数量限制的输出示例
提示:您上传了22个素材,已自动保留前20个用于分析,超出的2个素材已忽略
提交任务成功,任务ID: 987654321
{"code": "0", "message": "/tmp/kickart/output/creative_1742525956789_987654.json"}
# 示例3:自定义视频时长为20秒、传入自定义提示词生成故事板
CREATIVE_FILE3="/tmp/kickart/output/creative_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json"
python3 ./scripts/creative.py --input "$MATERIAL_FILE" --output "$CREATIVE_FILE3" --duration 20 --prompt "生成科沃斯X11洗地机器人的营销视频,用于抖音投放,面向25-40岁的大户型业主,突出无限续航和全能基站的卖点"
# 成功输出示例
提交任务成功,任务ID: 987654321
{"code": "0", "message": "/tmp/kickart/output/creative_1742526012345_456789.json"}
# 示例4:同时使用素材分析结果+已上传自定义素材组合生成创意分析
CREATIVE_FILE4="/tmp/kickart/output/creative_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json"
python3 ./scripts/creative.py --input "$MATERIAL_FILE" --session-id "<当前会话ID>" --output "$CREATIVE_FILE4" --duration 15 --prompt "生成家电营销视频,结合官方素材和用户实拍素材,突出产品实用性卖点"
# 成功输出示例
提交任务成功,任务ID: 987654321
{"code": "0", "message": "/tmp/kickart/output/creative_1742526012345_456789.json"}六、注意事项
1. 输入的素材分析JSON必须符合后端服务要求的格式规范 2. 输出路径建议使用绝对路径,避免相对路径导致的写入失败 3. 创意分析任务执行时间随素材复杂度波动,若长时间未返回可检查后端服务状态 4. 素材总数限制为20个(图片+视频合计,官方素材+自定义素材合并计算),超出部分自动忽略,工具会明确告知用户超出数量 5. 素材不可重复使用规则:同一批自定义素材仅可用于一次创作任务,创意分析完成后系统会自动清空当前会话素材记录,如需再次使用需要重新上传 6. 同时使用素材分析结果和自定义素材时,两类素材的合计数量仍受20个上限限制,超出部分自动忽略 7. 清空素材记录仅会删除本地会话关联关系,不会删除远程服务器上的素材文件,如有需要可保留素材手动复用 8. 主动询问素材补充仅针对仅使用官方素材的场景,已上传自定义素材的场景不会重复询问
七、Agent执行流程(强制遵守)
1. 参数强校验:调用脚本前必须先校验--input和--session-id参数,两者必须至少提供一个,都未提供时终止流程,提示用户「当前未检测到任何可用素材,请选择: ① 提供抖店/抖音商品链接完成官方素材拉取分析 ② 上传自定义素材,也可同时提供两类素材进行组合创作」 2. 素材补充主动询问:参数校验完成后,检查素材状态:
- 若仅提供了
--input(官方素材分析结果):主动询问用户「是否需要额外上传自定义实拍素材补充用于创意分析?」 - 用户回复「需要」:引导进入自定义素材上传流程,完成所有素材上传后再继续后续流程
- 用户回复「不需要」/确认仅使用官方素材:直接进入需求确认环节
- 若已提供自定义素材:跳过该询问步骤
3. 强制需求确认:调用脚本前必须先向用户确认以下信息,缺一不可,且必须获得用户明确的文字回复确认:
- 视频总时长:需明确告知用户,视频创作最大支持60秒,且最终视频时长存在波动
- 核心创作需求:投放平台、目标人群、需要突出的核心卖点
- 确认用户无其他自定义创作要求
4. 需求留痕要求:将用户确认的所有需求信息(时长、投放平台、目标人群、核心卖点)持久化到会话上下文,作为后续环节的校验依据 5. 参数校验:
- 用户指定的时长必须≤60秒,若超过60秒需明确提示用户故事板最大支持60秒,引导用户调整时长;
- 同时校验两类素材合计数量不超过20个,超出部分自动忽略
6. 若脚本未立即返回结果,先回复用户:"正在为您进行创意分析,任务执行时间可能较长,请您稍候~" 7. 任务提交成功获取到Task ID后,立即将Task ID、任务类型、会话ID、提交时间、任务参数(时长、prompt)、用户确认的需求信息持久化到会话上下文,禁止丢失 8. 持续使用process(action=poll)轮询任务状态,直到脚本执行完成 9. 若脚本因超时/异常退出,立即使用持久化的Task ID调用任务查询接口确认后端状态,禁止直接判定任务失败 10. 结果强制确认:任务完成后必须向用户同步创意分析结果摘要,获得用户明确的「确认创意无误」文字回复后,必须立即执行`python3 ./scripts/upload.py clear -s <当前会话ID>`命令清空当前会话的所有已上传素材本地记录(若创意分析调用失败或用户提出修改意见,不清空素材,支持直接重试无需重新上传),防止同一份素材被重复用于其他创作任务,保障素材使用唯一性,之后才可进入后续故事板创作流程,无确认记录不得跳转至下一环节 11. 若用户上传素材数量超出20个,执行完成后需要主动告知用户超出的素材数量及已自动保留前20个的处理逻辑 12. 错误熔断机制:任意环节校验不通过时,自动终止流程,引导用户回到对应环节补充信息/完成确认,不得跳过校验执行后续操作
火山Kickart套餐开通与校验指南
用途说明
本指南用于指导agent完成火山Kickart营销短视频创作服务的套餐有效性校验,确保用户有可用服务额度后再执行后续创作流程。
---
🚀 套餐校验操作流程(必须严格执行)
步骤1:执行套餐查询命令
直接运行以下脚本查询用户套餐状态:
python3 scripts/service.py步骤2:结果处理逻辑
场景1:接口调用错误(命令非0退出/返回非标准JSON)
- 立即终止后续业务流程
- 参考「错误处理规范」匹配错误码,向用户明确告知错误原因和解决方案
- 常见错误示例处理:
- 签名验证失败:提示用户AK/SK配置错误,请重新核对
- 服务权限不足:提示用户企业账号未开通Kickart权限,请联系管理员开通
- 网络超时:提示用户当前网络不稳定,请稍后重试
场景2:接口调用成功(返回标准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),避免用户误解
故事板创作工具使用指南
目录
一、概述
本工具用于基于已有的创意分析结果自动调用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):错误处理方法,异常时自动退出程序
三、命令行参数说明
| 参数名 | 是否必填 | 类型 | 说明 |
|---|---|---|---|
--input | 是 | String | 创意分析结果JSON文件的本地路径(支持绝对路径/相对路径) |
--output | 是 | String | 生成的故事板结果JSON文件的保存路径,需包含完整文件名(后缀为.json) |
四、返回结果说明
成功返回
工具最终标准输出JSON格式结果:
{"code": "0", "message": "<故事板创作结果保存的文件绝对路径>"}五、使用示例
# 1. 确保输出目录存在
mkdir -p /tmp/kickart/output
# 2. 生成各环节唯一文件名
CREATIVE_FILE="/tmp/kickart/output/creative_1742525912345_123456.json"
STORYBOARD_FILE="/tmp/kickart/output/storyboard_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json"
# 示例1:基于创意分析结果生成故事板
python3 ./scripts/storyboard.py --input "$CREATIVE_FILE" --output "$STORYBOARD_FILE"
# 成功输出示例
提交任务成功,任务ID: 123456789
{"code": "0", "message": "/tmp/kickart/output/storyboard_1742525956789_987654.json"}六、Agent执行流程(强制遵守)
1. 前置强校验:调用脚本前必须先校验两个条件,任意不满足直接终止流程:
- 上一环节(创意分析)的用户「确认创意无误」文字回复记录存在且有效
- 创意分析结果、用户确认的需求参数完整无误
2. 调用exec工具启动python脚本,传入用户指定的duration、prompt参数,设置≥900000ms(15分钟)的yieldMs 3. 若脚本未立即返回结果,先回复用户:
正在为您生成故事板,任务执行时间可能较长,请您稍候~
⚠️ 如长时间未收到结果,您可以稍后主动询问当前创作进度或结果。4. 任务提交成功获取到Task ID后,立即将Task ID、任务类型、会话ID、提交时间、关联的创意分析任务ID、用户确认的创意参数持久化到会话上下文,禁止丢失 5. 持续使用process(action=poll)轮询任务状态,直到脚本执行完成 6. 若脚本因超时/异常退出,立即使用持久化的Task ID调用任务查询接口确认后端状态,禁止直接判定任务失败 7. 结果强制确认:任务完成后必须向用户同步故事板完整内容(分镜、口播、时长分配),获得用户明确的「确认使用该故事板生成视频」文字回复后方可进入后续视频生成流程,无确认记录不得跳转至下一环节 8. 若用户拒绝当前故事板,引导用户调整创意参数后重新生成故事板,重复以上流程 9. 错误熔断机制:任意环节校验不通过时,自动终止流程,引导用户回到对应环节补充信息/完成确认,不得跳过校验执行后续操作
视频生成工具使用指南
一、概述
本工具用于基于故事板创作结果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格式结果:
{"code": "0", "message": "<消费成片任务ID>"}失败返回
工具最终标准输出JSON格式错误信息:
{"code": "<错误码>", "message": "<具体错误描述内容>"}五、使用示例
# 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提交任务:
python3 ./scripts/consumption.py --storyboard <故事板编号> --session <当前session的uuid> --metadata <当前消息的完整元数据> --input <故事板JSON文件路径> --output <最终视频的JSON文件路径>解析返回结果,提取任务ID:{"code":"0", "message":"<任务ID>"}
4. 通知用户提交成功:任务提交后,主Agent立即回复用户:
✅ 成片生成任务已提交成功,任务ID:<任务ID>,正在后台执行~
⚠️ 请勿重复提交相同任务,避免产生额外创点消费
⚠️ 任务完成后我会自动通知您,您也可以随时询问进度八、任务结果处理
1. 任务执行成功 ① 从返回的JSON结果中提取storyboard_input(视频描述)、video_url(成片下载链接)、expire(链接有效期)等关键信息 ② 将上述信息整理为用户易懂的格式后通知用户,禁止直接返回原始文件路径或JSON内容 2. 任务执行失败 ① 从返回结果中提取错误描述,结合错误处理规范告知用户错误原因和解决方案
火山引擎鉴权操作指南
用途说明
本指南用于指导agent完成火山引擎API调用前的身份鉴权校验,确保ACCESS_KEY_ID/SECRET_ACCESS_KEY(访问密钥)配置正确可用。
---
🚀 鉴权操作流程(必须严格按顺序执行)
步骤1:执行环境变量检查命令
直接运行以下命令,按优先级检查鉴权配置:
echo "ARK_SKILL_API_BASE: $ARK_SKILL_API_BASE" && echo "ARK_SKILL_API_KEY: $ARK_SKILL_API_KEY" && echo "ACCESS_KEY_ID: $ACCESS_KEY_ID" && 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后,执行配置命令:
export ACCESS_KEY_ID=用户提供的ACCESS_KEY_ID值
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
🔍 功能说明
本工具可以基于生成好的营销视频链接和发布文案,自动生成抖音发布跳转二维码,手机扫码即可直接进入抖音创作者发布页面,自动填充视频素材和发布文案,无需手动上传填写,大幅提升发布效率。 ---
📝 使用步骤(必须严格按顺序执行)
1. 参数校验:验证视频链接(公网可访问MP4)和文案(长度≤20字符)合法性,不符合直接终止流程 2. 环境准备:执行命令确保输出目录存在:
mkdir -p /tmp/openclaw/kickart/output3. 生成文件名:严格使用指定格式生成唯一二维码文件名:
QRCODE_FILE="/tmp/openclaw/kickart/output/qrcode_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).png"4. 执行生成脚本:调用publish.py生成二维码和跳转链接,检查返回code必须为0:
python3 ./scripts/publish.py --url "你的视频链接" --body "你的发布文案" --output "$QRCODE_FILE"成功输出示例:
{"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 |
--conversation | 是 | String |
返回参数
| 字段 | 类型 | 说明 |
|---|---|---|
code | 字符串 | 状态码,0 表示成功 |
data.qrcode | 字符串 | 生成的二维码图片本地路径 |
data.jump | 字符串 | 网页跳转发布链接,点击可直接唤起抖音发布页 |
---
⚠️ 注意事项
1. 视频链接必须为公网可直接访问的地址,不能是需要鉴权的私有链接 2. 二维码和跳转链接有效期均为24小时,生成后请尽快使用 3. 发布的设备必须安装抖音APP,且登录的账号拥有创作者发布权限,否则无法跳转发布页 4. 发布文案长度需符合抖音平台规范,最长支持5000字符 5. 跳转发布后请自行检查视频和文案是否正确,确认无误后再点击发布 6. 跳转链接需要在手机端打开,PC端打开无法唤起抖音APP ---
💡 使用示例
示例命令:
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"}'抖音营销素材上传工具使用指南
📖 概述
本工具为抖音营销视频生成配套的素材上传管理工具,支持本地图片/视频素材的上传、查询、会话级管理,上传后的素材可直接用于后续创意分析、故事板创作、视频生成全流程,无需手动填写素材URL。 ---
⚙️ 前置依赖
1. 环境要求:Python 3.12+ 2. 安装依赖包:
pip install -r scripts/requirements.txt3. 权限要求:确保/tmp/kickart目录有读写权限(工具会自动创建该目录存储日志和会话状态) ---
🎯 核心功能
| 命令 | 功能说明 | 必填参数 |
|---|---|---|
add | 上传单个本地素材到远程服务器,返回上传后的素材URL,自动记录到当前会话素材列表 | 1. 本地文件路径<br>2. --session-id/-s <会话ID> |
list | 查询当前会话下所有已上传的素材列表,返回每个素材的完整信息,包括素材类型、URL地址、宽度、高度、视频时长(仅视频类型包含) | 1. --session-id/-s <会话ID> |
clear | 清空当前会话下所有已上传的素材本地记录(不会删除远程服务器上的素材文件) | 1. --session-id/-s <会话ID> |
---
💡 使用示例
1. 上传素材
# 上传单张图片到会话ID为task_123456的素材库
python3 ./scripts/media.py add /local/path/product1.jpg --session-id task_123456
# 上传单个视频到同一会话
python3 ./scripts/media.py add /local/path/showcase.mp4 -s task_123456上传成功后工具会自动返回素材URL,同时将素材记录到会话列表中
---
2. 查看已上传素材
# 查询会话task_123456下所有已上传的素材
python3 ./scripts/media.py list -s task_123456返回示例:
[
{"type": "image", "url": "https://xxx.xxx.com/material/abc123.jpg", "width": 276, "height": 183},
{"type": "video", "url": "https://xxx.xxx.com/material/def456.mp4", "width": 1920, "height": 1080, "duration": 15.2}
]---
3. 清空会话素材记录
# 清空会话task_123456的本地素材记录
python3 ./scripts/media.py clear -s task_123456---
⚠️ 注意事项
1. 素材上传限制:
- 数量限制:单次任务最多可上传20个素材(图片+视频合计),超出20个的部分将无法成功添加
- 格式要求:
- 图片仅支持:jpg、png、jpeg 格式
- 视频仅支持:mp4、avi、mov 格式
- 参数限制:
- 图片:单张大小建议≤8MB,分辨率≥300*300,总像素(长×宽)≤36,000,000
- 视频:单个文件大小≤50MB
2. 会话ID规范:建议每个独立任务使用唯一的会话ID(如任务ID、时间戳等),避免不同任务的素材混淆 3. 日志路径:上传操作日志会自动保存到/tmp/kickart/info.当日日期.log,出现问题可查看日志定位错误 4. 批量上传:如需批量上传多个素材,可通过shell循环调用add命令实现,每次上传单个文件 5. 素材有效期:上传后的远程素材默认长期有效,清除本地会话记录不会影响远程素材的使用 ---
❓ 常见问题
Q1: 上传后调用list看不到素材?
A:检查是否使用了正确的session-id,不同会话的素材记录是互相隔离的。
Q2: 上传失败提示权限错误?
A:检查本地文件是否有读取权限,以及/tmp/kickart目录是否有写入权限。
Q3: 清空会话后之前的素材还能使用吗?
A:可以,clear命令只会删除本地的会话记录,远程服务器上的素材不会被删除,保留素材URL即可继续使用。
Q4: 支持断点续传吗?
A:当前版本暂不支持断点续传,大文件上传建议检查网络稳定性后重试。
抖店商品素材分析工具使用指南
目录
一、概述
本工具用于自动分析抖店/抖音商品的素材信息,支持输入抖店商品链接或抖音商品链接,自动完成链接校验、简化、任务提交、结果轮询全流程,最终将分析结果输出为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):错误处理方法,异常时自动退出程序
三、命令行参数
| 参数 | 必填 | 类型 | 说明 |
|---|---|---|---|
--url | 是 | string | 抖店/抖音商品链接,仅支持以下域名:<br>haohuo.jinritemai.com(抖店)<br>v.douyin.com(抖音商品) |
--output | 是 | string | 分析结果输出的JSON文件绝对/相对路径,需确保路径有写入权限 |
四、返回值说明
成功返回
{"code": "0","message": "<素材分析结果保存的文件绝对路径>"}同时控制台会打印:
提交任务成功,任务ID: <返回的任务ID>同时结果文件会写入--output指定路径,包含完整的创意分析结果。
失败返回
{"code": "xxx","message": "具体错误描述"}五、使用示例
# 1. 确保输出目录存在
mkdir -p /tmp/kickart/output
# 2. 生成唯一输出文件名
MATERIAL_FILE="/tmp/kickart/output/material_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json"
# 3. 执行命令
python3 ./scripts/material.py --url "https://haohuo.jinritemai.com/ecommerce/trade/detail/index.html?id=1234567890&other_param=xxx" --output "$MATERIAL_FILE"
# 成功输出示例
简化URL成功,简化后的URL: https://haohuo.jinritemai.com/ecommerce/trade/detail/index.html?id=1234567890
提交任务成功,任务ID: task_1234567890abcdef
{"code":"0","message":"/tmp/kickart/output/material_1742525890123_654321.json"}六、注意事项
1. 短链跳转和网络请求需要可访问外网的网络环境 2. 输出路径如果包含多级目录,需提前手动创建目录,否则会写入失败 3. 单次分析任务最大执行时间受后端服务限制,若长时间未返回可重新提交任务 4. 仅支持商品链接,不支持店铺链接、直播间链接等其他类型URL
七、Agent执行流程(强制遵守)
1. 调用exec工具启动python脚本,设置≥180000ms(3分钟)的yieldMs 2. 若脚本未立即返回结果,先回复用户:"正在为您进行素材分析,任务执行时间可能较长,请您稍候~" 3. 任务提交成功获取到Task ID后,立即将Task ID、任务类型、会话ID、提交时间持久化到会话上下文,禁止丢失 4. 持续使用process(action=poll)轮询任务状态,直到脚本执行完成 5. 若脚本因超时/异常退出,立即使用持久化的Task ID调用任务查询接口确认后端状态,禁止直接判定任务失败 6. 结果确认:任务完成后向用户同步素材分析摘要(商品信息、素材数量、核心卖点识别结果),确认无误后再进入后续创意分析流程
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")
TOKEN = os.getenv("ARK_SKILL_API_KEY")
if ADDR is not None and TOKEN is not None:
return 2
ACCESS_KEY_ID = os.getenv("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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
智能创作云 aPaaS 媒资上传完整流程脚本 (含 ListUsers 获取 Admin ID)
流程:
0. ListUsers — 查找角色为 admin 的用户,获取其 Uid 作为 owner-id
1. GetUploadState — 查询文件是否已上传(支持断点续传)
2. StreamUploadData — 分片上传文件(支持并发、幂等)
3. CreateMaterial — 创建媒资,获取 MediaId
4. GetMediaInfo — 轮询媒资详情,获取处理状态
5. 提取下载链接
用法:
pip install requests
# 使用 ArkClaw Token
export ARKCLAW_TOKEN="your-token"
python upload_material.py --host <host> --file /path/to/file.mp4 \
--owner-type user --title "我的视频" --category video
# 使用 AK/SK
export ACCESS_KEY_ID="your-ak"
export SECRET_ACCESS_KEY="your-sk"
python upload_material.py --host <host> --file /path/to/file.mp4 \
--owner-type user --title "我的视频" --category video
"""
import argparse
import hashlib
import json
import os
import sys
import time
import hmac
import jsonpath
import requests
from pydantic import BaseModel
from urllib.parse import urlencode, urlparse
from typing import List, TypedDict
from base import Result
__all__ = ["Matriel", "upload"]
# ─── 类型定义 ──────────────────────────────────────────
class RangeDict(TypedDict):
Start: int
End: int
class UploadStateResult(TypedDict):
SkipDataComplete: bool
PartSize: int
Ranges: List[RangeDict]
### 素材
class Matriel(BaseModel):
type: str
url: str
width: int
height: int
class ImageMatriel(Matriel):
pass
class VideoMatriel(Matriel):
duration: float
# ─── 全局常量 ──────────────────────────────────────────
# 从环境变量读取配置
ACCESS_KEY_ID = os.getenv("ACCESS_KEY_ID") or ""
SECRET_ACCESS_KEY = os.getenv("SECRET_ACCESS_KEY") or ""
ARKCLAW_TOKEN = os.getenv("ARK_SKILL_API_KEY") or ""
# 默认域名(ArkClaw 方式)
DEFAULT_HOST = os.getenv("ARK_SKILL_API_BASE") or ""
# AK/SK 方式的默认域名
DEFAULT_ICP_HOST = "https://icp.volcengineapi.com"
REGION = "cn-north"
VERSION = "2022-02-01"
# 不同接口可能属于不同的 Service
SERVICE_MUSE = "iccloud_muse" # 媒资/任务相关
SERVICE_IAM = "ic_iam" # 用户/权限相关
POLL_MAX_ATTEMPTS = 60 # 最多轮询 60 次
POLL_INTERVAL = 5 # 每次间隔 5 秒
# 文件类型与后缀映射
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "tif"}
VIDEO_EXTENSIONS = {"mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v", "3gp"}
# Debug 开关
DEBUG = False
def debug_print(*args, **kwargs):
if DEBUG:
print(*args, **kwargs)
# ─── AK/SK 鉴权函数(参考 icp.py)─────────────────────
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 _request_with_aksk(
action: str,
service: str,
host: str | None,
body: dict | None = None,
extra_query: dict | None = None,
) -> dict:
"""使用 AK/SK 鉴权发起请求"""
extra_query = extra_query or {}
body_bytes = json.dumps(body or {}, ensure_ascii=False).encode()
queries = extra_query.copy()
queries["Action"] = action
queries["Version"] = VERSION
query_string = urlencode(sorted(queries.items()))
query_string = query_string.replace("+", "%20")
url = f"{host}?{query_string}"
date = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(time.time()))
auth_date = date[:8]
payload = hash_sha256(body_bytes).hex()
signed_headers = ["host", "x-date", "x-content-sha256", "content-type"]
parsed_url = urlparse(host)
host_name = parsed_url.netloc
header_list = []
for header in signed_headers:
if header == "host":
header_list.append(f"{header}:{host_name}")
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(
[
"POST",
"/",
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}"
)
headers = {
"X-Date": date,
"X-Content-Sha256": payload,
"Content-Type": "application/json",
"Authorization": authorization,
}
debug_print(f"result is {url}")
debug_print(f"headers is {headers}")
debug_print(f"url is {url}, body is {body_bytes}")
debug_print(f"canonical_string: {repr(canonical_string)}")
debug_print(f"sign_string: {repr(sign_string)}")
resp = requests.post(url, data=body_bytes, headers=headers, timeout=30)
try:
result = resp.json()
except Exception as e:
print(f"json parse error, resp is {resp.text}, error is {e}")
sys.exit(1)
_check_resp(result, action)
return result
def _request_with_arkclaw(
action: str,
service: str,
host: str | None,
arkclaw_token: str | None,
body: dict | None,
extra_query: dict | None,
) -> dict:
"""使用 ArkClaw Token 鉴权发起请求"""
extra_query = extra_query or {}
body_bytes = json.dumps(body or {}, ensure_ascii=False).encode()
headers = {
"ServiceName": service,
"Authorization": f"Bearer {arkclaw_token}",
"Content-Type": "application/json",
}
url = f"{host}/?Action={action}&Version={VERSION}"
if extra_query:
url += "&" + urlencode(extra_query)
debug_print(f"result is {url}")
debug_print(f"headers is {headers}")
debug_print(f"url is {url}, body is {body_bytes}")
resp = requests.post(url, data=body_bytes, headers=headers, timeout=30)
try:
result = resp.json()
except Exception as e:
print(f"json parse error, resp is {resp.text}, error is {e}")
sys.exit(1)
_check_resp(result, action)
return result
def _request_binary_with_arkclaw(
action: str,
service: str,
host: str | None,
arkclaw_token: str | None,
extra_query: dict | None,
data: bytes,
) -> dict:
"""使用 ArkClaw Token 鉴权发起二进制请求"""
headers = {
"ServiceName": service,
"Authorization": f"Bearer {arkclaw_token}",
"Content-Type": "application/octet-stream",
}
url = f"{host}/?Action={action}&Version={VERSION}"
if extra_query:
url += "&" + urlencode(extra_query)
debug_print(f"url is {url}, data size is {len(data)}")
resp = requests.post(url, data=data, headers=headers, timeout=60)
try:
result = resp.json()
except Exception as e:
print(f"json parse error, resp is {resp.text}, error is {e}")
sys.exit(1)
_check_resp(result, action)
return result
def _request_binary_with_aksk(
action: str, service: str, host: str | None, extra_query: dict, data: bytes
) -> dict:
"""使用 AK/SK 鉴权发起二进制请求"""
queries = extra_query.copy()
queries["Action"] = action
queries["Version"] = VERSION
query_string = urlencode(sorted(queries.items()))
query_string = query_string.replace("+", "%20")
url = f"{host}?{query_string}"
date = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(time.time()))
auth_date = date[:8]
payload = hash_sha256(data).hex()
signed_headers = ["host", "x-date", "x-content-sha256", "content-type"]
parsed_url = urlparse(host)
host_name = parsed_url.netloc
header_list = []
for header in signed_headers:
if header == "host":
header_list.append(f"{header}:{host_name}")
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/octet-stream")
header_string = "\n".join(header_list)
canonical_string = "\n".join(
[
"POST",
"/",
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}"
)
headers = {
"X-Date": date,
"X-Content-Sha256": payload,
"Content-Type": "application/octet-stream",
"Authorization": authorization,
}
debug_print(f"url is {url}, data size is {len(data)}")
debug_print(f"canonical_string: {repr(canonical_string)}")
debug_print(f"sign_string: {repr(sign_string)}")
resp = requests.post(url, data=data, headers=headers, timeout=60)
try:
result = resp.json()
except Exception as e:
print(f"json parse error, resp is {resp.text}, error is {e}")
sys.exit(1)
_check_resp(result, action)
return result
# 全局变量,存储授权方式和参数
_auth_mode = None
_arkclaw_token = None
_host = None
def init_auth(
auth_mode: str, arkclaw_token: str | None = None, host: str | None = None
):
"""初始化授权配置"""
global _auth_mode, _arkclaw_token, _host
_auth_mode = auth_mode
_arkclaw_token = arkclaw_token
_host = host
def _request(
action: str,
service: str = SERVICE_MUSE,
body: dict | None = None,
extra_query: dict | None = None,
) -> dict:
"""通用请求函数,根据授权模式选择"""
if _auth_mode == "arkclaw":
return _request_with_arkclaw(
action, service, _host, _arkclaw_token, body, extra_query
)
else:
return _request_with_aksk(action, service, _host, body, extra_query)
def _request_binary(action: str, service: str, extra_query: dict, data: bytes) -> dict:
"""通用二进制请求函数,根据授权模式选择"""
if _auth_mode == "arkclaw":
return _request_binary_with_arkclaw(
action, service, _host, _arkclaw_token, extra_query, data
)
else:
return _request_binary_with_aksk(action, service, _host, extra_query, data)
# ─── Step 0: ListUsers ────────────────────────────────
def get_admin_user_id() -> int:
"""调用 ListUsers 接口,从用户列表中寻找 admin 用户。"""
debug_print("[0/5] ListUsers — 正在寻找 admin 用户...")
result = _request(action="ListUsers", service=SERVICE_IAM, body={"UserType": "All"})
_check_resp(result, "ListUsers")
users = result.get("Result", {}).get("Users", [])
debug_print(f"\n📋 调试信息:共找到 {len(users)} 个用户")
for i, user in enumerate(users, 1):
debug_print(f"\n用户 {i}:")
debug_print(f" Id: {user.get('Id')}")
debug_print(f" IsAdmin: {user.get('IsAdmin')}")
debug_print(f" Permitted: {user.get('Permitted')}")
debug_print(f" DisplayName: {user.get('DisplayName')}")
debug_print(f" VolcUserName: {user.get('VolcUserName')}")
debug_print(f" VolcUserId: {user.get('VolcUserId')}")
if not users:
print("❌ 未获取到任何用户信息")
sys.exit(1)
for user in users:
if user.get("IsAdmin"):
uid = user.get("Id")
name = user.get("DisplayName") or user.get("VolcUserName") or ""
if uid:
debug_print(f" 找到 admin 用户: {name} (Id={uid}) ✅")
return uid
first_uid = users[0].get("Id")
first_name = users[0].get("DisplayName") or users[0].get("VolcUserName") or ""
debug_print(
f"⚠️ 未找到 IsAdmin=true 的用户,使用第一个用户代替: {first_name} (Id={first_uid})"
)
return first_uid
# ─── Step 1: GetUploadState ────────────────────────────
def get_upload_state(
file_md5: str, file_size: int, file_crc32: int, owner_id: int
) -> UploadStateResult:
"""查询文件是否已上传(支持断点续传)"""
debug_print(
f"[1/5] GetUploadState — md5={file_md5}, size={file_size}, crc32={file_crc32}"
)
body = {
"Owner": {"Id": owner_id, "Type": "PERSON"},
"Md5": file_md5,
"Size": file_size,
"Start": 0,
"End": file_size - 1,
"Crc": file_crc32,
}
result = _request(action="GetUploadState", service=SERVICE_MUSE, body=body)
_check_resp(result, "GetUploadState")
raw_state = result.get("Result", {})
state: UploadStateResult = {
"SkipDataComplete": bool(raw_state.get("SkipDataComplete", False)),
"PartSize": int(raw_state.get("PartSize", 0)),
"Ranges": raw_state.get("Ranges", []),
}
debug_print(f"文件上传状态:{state}")
return state
def upload_part(
owner_id: int, chunk: bytes, offset: int, part_size: int, chunk_md5: str
) -> dict:
"""上传文件分片"""
debug_print(
f" 上传分片 {offset} 到 {offset + part_size - 1} (大小 {part_size} 字节)"
)
query = {
"Md5": chunk_md5,
"Size": part_size,
"Offset": offset,
"OwnerId": owner_id,
"OwnerType": "PERSON",
}
resp = _request_binary("StreamUploadData", SERVICE_MUSE, query, chunk)
_check_resp(resp, f"StreamUploadData,分片 {offset} 到 {offset + part_size - 1}")
return resp
# ─── Step 2: StreamUploadData ──────────────────────────
def stream_upload_data(
file_path: str,
file_md5: str,
file_size: int,
file_crc32: int,
owner_id: int,
upload_state: UploadStateResult,
) -> UploadStateResult:
if upload_state["SkipDataComplete"]:
debug_print("[2/5] 文件已存在且上传完成,跳过上传 ✅")
return upload_state
with open(file_path, "rb") as f:
data = f.read()
current_state = upload_state
chunk_count = 0
max_chunks = 1000
offset = 0
debug_print("[2/5] StreamUploadData — 开始上传...")
while not current_state["SkipDataComplete"] and chunk_count < max_chunks:
skip_data_complete = current_state["SkipDataComplete"]
part_size = current_state.get("PartSize", 0)
if skip_data_complete:
break
if part_size == 0:
debug_print(" PartSize 为 0,上传整个文件")
chunk = data
chunk_size = file_size
else:
debug_print(f" 使用服务端返回的 PartSize: {part_size} bytes")
debug_print(f" 当前上传位置: offset={offset}")
if offset + part_size * 2 <= file_size:
chunk = data[offset : offset + part_size]
chunk_size = part_size
else:
chunk = data[offset:file_size]
chunk_size = file_size - offset
debug_print(
f"[2/5] StreamUploadData — 上传第 {chunk_count + 1} 个分片,offset={offset}, size={chunk_size}"
)
upload_part(owner_id, chunk, offset, chunk_size, file_md5)
chunk_count += 1
offset += chunk_size
current_state = get_upload_state(file_md5, file_size, file_crc32, owner_id)
debug_print(f"current_state is {current_state}")
if (
current_state["SkipDataComplete"]
or current_state["Ranges"] == []
or offset >= file_size
):
debug_print(
" 上传完成,SkipDataComplete=true 或 Ranges 为空 或 offset 已超过文件大小 ✅"
)
return current_state
if chunk_count >= max_chunks and not current_state["SkipDataComplete"]:
print(
f"❌ 上传失败:已上传 {max_chunks} 个分片,但 SkipDataComplete 仍为 false"
)
sys.exit(1)
return current_state
# ─── Step 3: CreateMaterial ────────────────────────────
def create_material(
file_md5: str,
file_size: int,
file_name: str,
file_ext: str,
skip_data_complete: bool,
owner_id: int,
owner_type: str,
title: str,
category: str,
) -> str:
debug_print(
f"[3/5] CreateMaterial — title={title}, category={category}, skip_data_complete={skip_data_complete}"
)
body = {
"Owner": {"Id": owner_id, "Type": "PERSON"},
"StoreItem": {
"Md5": file_md5,
"Size": file_size,
"SkipDataComplete": skip_data_complete,
"Filename": file_name,
"FileExtension": file_ext,
},
"CreateMaterialInfo": {
"Visibility": 0,
"Title": title,
"MediaType": 1,
"MediaFirstCategory": category,
"Tags": [],
"MediaExtension": file_ext,
},
}
debug_print(
f" [DEBUG] CreateMaterial Body: {json.dumps(body, ensure_ascii=False)}"
)
result = _request(action="CreateMaterial", service=SERVICE_MUSE, body=body)
_check_resp(result, "CreateMaterial")
media_id = result.get("Result", {}).get("MediaId")
debug_print(f" MediaId={media_id} ✅")
return media_id
# ─── Step 4: GetMediaInfo ──────────────────────────────
def get_media_info(media_id: str, owner_id: int, owner_type: str) -> dict:
debug_print(f"[4/5] GetMediaInfo 轮询 — MediaId={media_id}")
for attempt in range(1, POLL_MAX_ATTEMPTS + 1):
result = _request(
action="GetMediaInfo",
service=SERVICE_MUSE,
body={"MediaIds": [media_id], "MediaType": 1},
)
_check_resp(result, "GetMediaInfo")
media_infos = result.get("Result", {}).get("MediaInfos", [])
if not media_infos:
debug_print(f" [{attempt}/{POLL_MAX_ATTEMPTS}] 未获取到媒资信息...")
time.sleep(POLL_INTERVAL)
continue
media_info = media_infos[0]
status = media_info.get("BasicInfo", {}).get("MediaStatus")
if status >= 2:
debug_print(f" 处理成功 ✅, media_info={media_info}")
return media_info
if status == 5 or status == 1:
print("❌ 处理失败")
sys.exit(1)
debug_print(
f" [{attempt}/{POLL_MAX_ATTEMPTS}] 处理中 (MediaStatus={status})..."
)
time.sleep(POLL_INTERVAL)
sys.exit(1)
# ─── Step 5: 提取下载链接 ──────────────────────────────
def extract_url(media_info: dict) -> str:
media_first_category = media_info.get("BasicInfo", {}).get("MediaFirstCategory", "")
if media_first_category == "image":
image_media = media_info.get("ImageMedia", {})
download_url = image_media.get("DownloadUrl", "")
if download_url:
return download_url
transcode_urls = image_media.get("TranscodeDownloadUrls", {})
if transcode_urls:
for quality in ["origin", "jpeg_1080p", "jpeg_480p"]:
if quality in transcode_urls:
return transcode_urls[quality]
elif media_first_category == "video":
video_media = media_info.get("VideoMedia", {})
download_url = video_media.get("DownloadUrl", "")
if download_url:
return download_url
play_info = video_media.get("PlayInfo", [])
if play_info and play_info[0].get("Url"):
return play_info[0]["Url"]
elif media_first_category == "audio":
audio_media = media_info.get("AudioMedia", {})
download_url = audio_media.get("DownloadUrl", "")
if download_url:
return download_url
play_info = audio_media.get("PlayInfo", [])
if play_info and play_info[0].get("Url"):
return play_info[0]["Url"]
return ""
# ─── 辅助函数 ──────────────────────────────────────────
def _file_md5(file_path: str) -> str:
h = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _check_resp(resp: dict, action: str):
debug_print(f"resp is {resp}")
meta = resp.get("ResponseMetadata", {})
error_obj = meta.get("Error")
if error_obj:
code = error_obj.get("Code") or error_obj.get("CodeN")
if code == 500 or code == "500":
print(
Result(
code="x01010",
message="阅读SKILL.md中的「错误处理规范」,向用户推荐处理意见~",
).model_dump_json()
)
sys.exit(1)
msg = error_obj.get("Message", "")
print(f"❌ {action} 失败: code={code}, msg={msg}")
sys.exit(1)
code = meta.get("Code")
if code is not None and str(code) not in ("0", "Success", "200"):
if code == 500 or code == "500":
print(
Result(
code="x01010",
message="阅读SKILL.md中的「错误处理规范」,向用户推荐处理意见~",
).model_dump_json()
)
sys.exit(1)
msg = meta.get("Message") or ""
print(f"❌ {action} 失败: code={code}, msg={msg}")
sys.exit(1)
debug_print(f"✅ {action} 成功")
# ─── 主流程 ────────────────────────────────────────────
def list_users_debug():
"""调试接口:单独列出所有用户信息"""
print("🔍 调试模式:获取用户列表\n")
result = _request(action="ListUsers", service=SERVICE_IAM, body={"UserType": "All"})
_check_resp(result, "ListUsers")
users = result.get("Result", {}).get("Users", [])
print(f"共找到 {len(users)} 个用户\n")
for i, user in enumerate(users, 1):
print(f"{'=' * 60}")
print(f"用户 {i}:")
print(f"{'=' * 60}")
print(f" Id: {user.get('Id')}")
print(f" IsAdmin: {user.get('IsAdmin')}")
print(f" Permitted: {user.get('Permitted')}")
print(f" DisplayName: {user.get('DisplayName')}")
print(f" Description: {user.get('Description')}")
print(f" VolcUserName: {user.get('VolcUserName')}")
print(f" VolcUserId: {user.get('VolcUserId')}")
print(f" TeamInfos: {user.get('TeamInfos')}")
print(f" RoleInfos: {user.get('RoleInfos')}")
print(f" 完整信息: {json.dumps(user, ensure_ascii=False, indent=4)}")
print()
return users
def main():
parser = argparse.ArgumentParser(description="智能创作云媒资上传工具")
parser.add_argument("--host", help="API 域名地址")
parser.add_argument(
"--list-users", action="store_true", help="调试模式:仅列出所有用户信息"
)
parser.add_argument("--file", help="本地文件路径")
parser.add_argument(
"--owner-id", type=int, help="Owner Id (可选,若不提供则自动查找 admin)"
)
parser.add_argument("--owner-type", default="user", help="Owner Type (user/team)")
parser.add_argument("--title", help="标题(可选,默认自动生成)")
parser.add_argument("--category", default="video", help="类型: video/image/audio")
parser.add_argument("--debug", action="store_true", help="开启调试日志")
args = parser.parse_args()
if args.debug:
global DEBUG
DEBUG = True
auth_mode = None
arkclaw_token = None
host = None
if ARKCLAW_TOKEN:
auth_mode = "arkclaw"
arkclaw_token = ARKCLAW_TOKEN
host = args.host or DEFAULT_HOST
debug_print(f"使用 ArkClaw Token 方式,域名: {host}")
elif ACCESS_KEY_ID and SECRET_ACCESS_KEY:
auth_mode = "aksk"
host = args.host or DEFAULT_ICP_HOST
debug_print(f"使用 AK/SK 方式,域名: {host}")
else:
print("❌ 请设置环境变量 ARKCLAW_TOKEN 或 ACCESS_KEY_ID/SECRET_ACCESS_KEY")
sys.exit(1)
init_auth(auth_mode, arkclaw_token, host)
if args.list_users:
list_users_debug()
return
if not args.file:
parser.error("上传模式需要 --file 参数")
if not args.title:
args.title = f"artclaw-material-{int(time.time())}"
if not os.path.exists(args.file):
print(f"❌ 文件不存在: {args.file}")
sys.exit(1)
if not os.path.isfile(args.file):
print(f"❌ 路径不是文件: {args.file}")
sys.exit(1)
owner_id = args.owner_id
if owner_id is None:
owner_id = get_admin_user_id()
import hashlib
import zlib
file_md5_obj = hashlib.md5()
file_crc32 = 0
file_size = 0
with open(args.file, "rb") as f:
while chunk := f.read(8192 * 1024):
file_md5_obj.update(chunk)
file_crc32 = zlib.crc32(chunk, file_crc32)
file_size += len(chunk)
file_md5 = file_md5_obj.hexdigest()
file_crc32 = file_crc32 & 0xFFFFFFFF
file_name = os.path.splitext(os.path.basename(args.file))[0]
file_ext = os.path.splitext(args.file)[1].lstrip(".")
if not args.category or args.category == "video":
file_ext_lower = file_ext.lower()
if file_ext_lower in IMAGE_EXTENSIONS:
args.category = "image"
elif file_ext_lower in VIDEO_EXTENSIONS:
args.category = "video"
state = get_upload_state(file_md5, file_size, file_crc32, owner_id)
state = stream_upload_data(
args.file, file_md5, file_size, file_crc32, owner_id, state
)
media_id = create_material(
file_md5,
file_size,
file_name,
file_ext,
state["SkipDataComplete"],
owner_id,
args.owner_type,
args.title,
args.category,
)
media_info = get_media_info(media_id, owner_id, args.owner_type)
url = extract_url(media_info)
print(f"\n✅ 流程结束!\nMediaId: {media_id}\nDownloadUrl: {url}")
def simplify(media_info: dict):
media_first_category = media_info.get("BasicInfo", {}).get("MediaFirstCategory", "")
if media_first_category == "image":
image_media = media_info.get("ImageMedia", {})
download_url = image_media.get("DownloadUrl", None)
if download_url:
image_media["DownloadUrl"] = download_url
transcode_urls = image_media.get("TranscodeDownloadUrls", {})
for quality in ["origin", "jpeg_1080p", "jpeg_480p"]:
if quality in transcode_urls:
transcode_urls[quality] = transcode_urls[quality]
if media_first_category == "video":
video_media = media_info.get("VideoMedia", {})
download_url = video_media.get("DownloadUrl", None)
if download_url:
video_media["DownloadUrl"] = download_url
play_info = video_media.get("PlayInfo", [])
if play_info and play_info[0].get("Url"):
play_info[0]["Url"] = play_info[0]["Url"]
if media_first_category == "audio":
audio_media = media_info.get("AudioMedia", {})
download_url = audio_media.get("DownloadUrl", None)
if download_url:
audio_media["DownloadUrl"] = download_url
play_info = audio_media.get("PlayInfo", [])
if play_info and play_info[0].get("Url"):
play_info[0]["Url"] = play_info[0]["Url"]
return media_info
def format(media_info: dict) -> Matriel:
image_media = jsonpath.jsonpath(media_info, "$.ImageMedia")
if image_media:
matriel = ImageMatriel(type="image", url="", height=0, width=0)
url = jsonpath.jsonpath(media_info, "$.ImageMedia.DownloadUrl")
matriel.url = url[0] if url else ""
width = jsonpath.jsonpath(media_info, "$.ImageMedia.Width")
matriel.width = width[0] if width else 0
height = jsonpath.jsonpath(media_info, "$.ImageMedia.Height")
matriel.height = height[0] if height else 0
return matriel
video_media = jsonpath.jsonpath(media_info, "$.VideoMedia")
if video_media:
matriel = VideoMatriel(type="video", url="", height=0, width=0, duration=0)
url = jsonpath.jsonpath(media_info, "$.VideoMedia.DownloadUrl")
matriel.url = url[0] if url else ""
width = jsonpath.jsonpath(media_info, "$.VideoMedia.MediaMetaInfo.Width")
matriel.width = width[0] if width else 0
height = jsonpath.jsonpath(media_info, "$.VideoMedia.MediaMetaInfo.Height")
matriel.height = height[0] if height else 0
duration = jsonpath.jsonpath(media_info, "$.VideoMedia.MediaMetaInfo.Duration")
matriel.duration = duration[0] / 1000 if duration else 0.0
return matriel
return Matriel(type="", url="", height=0, width=0)
def upload(args: dict) -> Matriel:
# 自动生成 title
args["title"] = f"artclaw-material-{int(time.time())}"
args["owner_type"] = "user"
auth_mode = None
arkclaw_token = None
host = None
if ARKCLAW_TOKEN:
auth_mode = "arkclaw"
arkclaw_token = ARKCLAW_TOKEN
host = DEFAULT_HOST
debug_print(f"使用 ArkClaw Token 方式,域名: {host}")
elif ACCESS_KEY_ID and SECRET_ACCESS_KEY:
auth_mode = "aksk"
host = DEFAULT_ICP_HOST
debug_print(f"使用 AK/SK 方式,域名: {host}")
else:
print("❌ 请设置环境变量 ARKCLAW_TOKEN 或 ACCESS_KEY_ID/SECRET_ACCESS_KEY")
sys.exit(1)
init_auth(auth_mode, arkclaw_token, host)
# 如果没有提供 owner-id,则自动通过 ListUsers 获取
owner_id = args["owner_id"] if "owner_id" in args else get_admin_user_id()
import hashlib
import zlib
file_md5_obj = hashlib.md5()
file_crc32 = 0
file_size = 0
with open(args["file"], "rb") as f:
while chunk := f.read(8192 * 1024):
file_md5_obj.update(chunk)
file_crc32 = zlib.crc32(chunk, file_crc32)
file_size += len(chunk)
file_md5 = file_md5_obj.hexdigest()
file_crc32 = file_crc32 & 0xFFFFFFFF
file_name = os.path.splitext(os.path.basename(args["file"]))[0]
file_ext = os.path.splitext(args["file"])[1].lstrip(".")
# 根据文件后缀自动识别 category
file_ext_lower = file_ext.lower()
if file_ext_lower in IMAGE_EXTENSIONS:
args["category"] = "image"
elif file_ext_lower in VIDEO_EXTENSIONS:
args["category"] = "video"
state = get_upload_state(file_md5, file_size, file_crc32, owner_id)
state = stream_upload_data(
args["file"], file_md5, file_size, file_crc32, owner_id, state
)
media_id = create_material(
file_md5,
file_size,
file_name,
file_ext,
state["SkipDataComplete"],
owner_id,
args["owner_type"],
args["title"],
args["category"],
)
media_info = get_media_info(media_id, owner_id, args["owner_type"])
return format(simplify(media_info))
if __name__ == "__main__":
main()
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()
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()
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()
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()
#!/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
doneimport 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()
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
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()
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"
### 认证配置
ACCESS_KEY_ID = os.getenv("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
# 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")
TOKEN = os.getenv("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
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()
# 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()