
Byted Kickart Viral Replicator
- 12 installs
- 411 repo stars
- Updated August 4, 2026
- bytedance/agentkit-samples
byted-kickart-viral-replicator is a Claude skill that clones viral short-video structure, script, and style into same-style clips via the Volcengine Kickart service.
About
This skill replicates viral Douyin and short-video content by copying a hot video's structure, script, and style to generate a same-style clip. A developer supplies a reference video and runs a creative analysis first, then the skill produces a replicated finished video. It supports uploading custom materials and adding a digital avatar or model to appear in the clip, using the Volcengine Kickart service.
- Clones viral Douyin/short-video structure, copy, and style into same-style content
- Requires a prior creative analysis and a reference video before replication
- Supports custom models/digital avatars appearing in the replicated clip
Byted Kickart Viral Replicator by the numbers
- 12 all-time installs (skills.sh)
- Ranked #1,038 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
byted-kickart-viral-replicator capabilities & compatibility
Requires a paid Volcengine Kickart plan with credit (创点) balance; async tasks consume credits
- Capabilities
- video generation · viral replication · marketing video
- Works with
- openai
- Use cases
- video generation · marketing
- Runs
- Runs locally
- Pricing
- Bring your own API key
What byted-kickart-viral-replicator says it does
抖音/短视频平台爆款视频裂变、克隆工具。支持复制热门视频结构、文案、风格生成同款内容
输出裂变成片视频下载链接,确认结果并询问下一步需求
需要先完成创意分析,获取创意分析结果文件路径,以及用户提供的参考视频
npx skills add https://github.com/bytedance/agentkit-samples --skill byted-kickart-viral-replicatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 12 |
|---|---|
| repo stars | ★ 411 |
| Last updated | August 4, 2026 |
| Repository | bytedance/agentkit-samples ↗ |
What it does
Clone a viral short video's structure, copy, and style into a same-style finished clip.
Who is it for?
Cloning viral Douyin/short-video structure, copy, and style into a same-style product clip.
When should I use this skill?
A user asks to replicate, clone, or make a same-style version of a viral video.
What you get
A replicated finished short video in the style of a reference viral clip.
- replicated short video
- creative analysis result
By the numbers
- 4-step mandatory pre-check flow
- 7 task types incl. viral replication and digital avatar
- 0-60s output video length limit
Files
爆款裂变SKILL
🚨 强制前置校验流程(必须按顺序执行,任意不通过直接终止流程)
所有用户请求必须先完成以下4步校验,不得跳过: 1. 模型输入能力校验
- 读取openclaw.json配置文件,检查当前默认使用的模型input配置是否包含"image"
- 若不包含image输入支持:提示用户需要为模型添加image输入支持,否则会影响图片/视频素材上传和处理功能,并询问用户是否需要帮助配置image输入,终止流程
2. 火山鉴权校验
- 读取
./references/火山鉴权指南.md,检查本地是否已配置环境变量 - 未配置:引导用户按指南完成密钥配置,终止流程
3. 套餐有效性校验
- 读取
./references/套餐开通指南.md,调用套餐查询接口检查用户套餐是否在有效期内、创点余额充足 - 套餐失效/余额不足:引导用户按指南升级/充值套餐,终止流程
🎯 意图识别与任务执行指南(基于对话历史自动识别)
结合当前用户输入和历史记录判断意图后,必须严格读取并遵循对应的执行依据指南,不可凭空猜测执行步骤。
| 任务类型 | 触发意图与关键词 | 执行依据 (强制首要读取) | 输出要求 | 前置要求 |
|---|---|---|---|---|
| 进度查询 | 【最高优先级】用户提及「查询进度」「查看结果」「生成怎么样了」「好了吗」或查询某个具体任务ID的状态时。 | 读取 references/任务查询指南.md。<br>⚠️绝对禁止不阅读指南直接调用任何脚本,严禁使用未经指南允许的内部 poll 命令! | 输出当前任务执行状态(如「任务已完成」、「任务执行中」等),确认是否需要调整 | 需从会话上下文持久化存储中获取对应任务ID,无有效ID时直接告知用户未查询到相关任务 |
| 素材上传 | 用户提及「上传素材」「添加图片」「添加视频」「自定义素材」 | 读取 references/素材上传指南.md | 输出上传成功的素材URL列表,确认素材完整性后询问下一步需求 | 任务提交成功后自动将会话ID、素材列表持久化存储到会话上下文 |
| 素材分析 | 用户提及「素材分析」「分析商品」「拉取素材」或者直接发送「抖音商品详情链接」 | 读取 references/素材分析指南.md | 输出商品主图/详情图/视频数量、核心卖点、基础商品信息,确认结果准确性后询问下一步需求 | 任务提交成功后自动将Task ID持久化存储到会话上下文 |
| 创意分析 | 用户提及「创意分析」「卖点分析」「受众分析」「创作方向」 | 读取 references/创意分析指南.md | 输出目标受众、推荐视频规格、核心卖点提炼、创意方向建议,确认结果准确性后询问下一步需求 | 任务提交成功后自动将Task ID持久化存储到会话上下文 |
| 爆款裂变 | 用户提及「爆款复刻」「爆款裂变」「爆款克隆」「复制视频」「克隆视频」「同款视频」「生成同款」「裂变视频」等,或者表达希望制作和某热门视频一样的内容的等价意图时。 | 读取 references/爆款裂变指南.md | 输出裂变成片视频下载链接,确认结果并询问下一步需求 | 需要先完成创意分析,获取创意分析结果文件路径,以及用户提供的参考视频 |
| 视频参考 | 用户主动提供参考视频、视频链接,或表示要上传视频时 | 读取 references/视频参考指南.md | 获取符合规范的参考视频链接或素材ID,为裂变做准备 | 用户触发爆款裂变意图时首要的前置步骤 |
| 数字形象 | 用户提及「添加模特」「自定义角色」「模特出镜」「数字人」「自定义模特」时 | 读取 references/数字形象指南.md | 获取符合规范的角色图片媒资ID,为裂变提供自定义模特 | 用户在裂变任务中需要指定角色出镜时触发 |
⚠️ 错误处理规范
所有错误必须明确告知原因和可执行解决方案,禁止模糊提示!!!
| 错误码 | 错误描述 | 详细说明 | 用户处理建议 |
|---|---|---|---|
| 0 | 无返回值 | 接口调用成功,但服务返回结果为空 | 请稍后重试,如问题持续请联系火山技术支持 |
| 1400 | ParamErr参数错误 | 参数错误 | 联系技术支持 |
| 1402 | 创点不足 | 调用接口时,用户账户的创点额度不足 | 请前往 创点充值页面 充值创点或升级套餐 |
| 1410 | 服务ID不存在 | 调用接口时,输入参数中包含了不存在的服务ID | |
| 1411 | 输入分辨率错误 | 调用接口时,输入参数中的图片或视频分辨率不符合要求 | 请检查素材分辨率是否符合规格要求(如≥480p) |
| 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 | 有效套餐缺失 | 素材上传出现错误,通常是套餐原因 | 请前往 套餐开通页面 开通套餐 |
| A0101 | Session元数据格式错误 | 接口传入的Session元数据格式错误 | 根据 references/消费成片指南.md 校验当前主会话,确保传入的Session元数据格式正确 |
| 其他 | \- | 未明确列出的其他错误情况 | 稍后重试,如问题持续请联系火山技术支持 |
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第三方库已包含在 scripts/requirements.txt 中
三、命令行参数说明
| 参数名 | 是否必填 | 类型 | 说明 |
|---|---|---|---|
--task-id | 是 | String | 要查询的目标任务ID,任务提交成功时返回 |
--output / -o | 是 | String | 任务结果保存的JSON文件本地路径,需包含完整文件名(后缀为.json) |
四、返回结果说明
轮询中输出
任务执行过程中控制台会循环打印:
任务执行中,请不要中断任务...成功返回
工具最终标准输出JSON格式结果:
{"code": "0", "message": "<任务结果保存的文件绝对路径>"}同时任务完整结果会写入--output指定的JSON文件中。
失败返回
工具最终标准输出JSON格式错误信息:
{"code": "<错误码>", "message": "<具体错误描述内容>"}五、使用示例
# 示例:查询任务ID为7619174133103902774的任务进度,结果保存到/tmp/task_result.json
python3 scripts/query.py --task-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持久化校验:先按 当前会话定位与session获取规范.md 校验当前主会话,再从该主会话上下文临时存储中获取用户提交的最近一次异步任务(素材分析/创意分析/故事板创作/视频生成)对应的Task ID及关联信息(任务类型、提交时间、会话ID),若未获取到有效ID,直接告知用户「未查询到相关提交任务,请确认任务是否已提交」 3. 本地脚本超时特殊处理: 若本地执行脚本因超时/异常退出(返回非0状态码),必须立即触发查询流程,使用持久化的Task ID调用查询脚本确认后端任务真实状态,禁止直接告知用户任务失败 4. 结果反馈规范:
- 任务执行中:告知用户当前任务类型、提交时间,提示用户任务仍在后台执行,完成后会第一时间通知,无需反复查询
- 执行成功:读取结果文件内容,整理后反馈给用户
- 执行失败:明确告知用户错误原因和对应的解决建议,如需重试可协助重新提交任务
5. 兜底兼容逻辑:若本指南加载失败,自动降级到进程查询+Task ID接口查询逻辑,同时记录错误日志,不阻塞用户查询操作
创意分析服务使用指南
目录
一、概述
本脚本为创意分析批量处理工具,支持两种输入模式: 1. 基于已完成的素材分析结果,调用后端创意分析服务生成结构化的创意分析结果 2. 支持用户已上传到远程的图片/视频素材作为输入,自动完成素材解析、创意分析全流程 工具会自动完成任务提交、轮询、结果保存全流程。
⚠️ 重要提示:自定义素材必须先通过「素材上传指南」中的`add`命令上传到远程服务器,获取到远程URL后才可使用,不支持直接传入本地文件路径。
二、前置依赖
1. 已配置火山鉴权的AK/SK,可正常调用接口 2. 环境已安装Python 3.12+ 3. 依赖Python第三方库已包含在 scripts/requirements.txt 中
三、命令行参数说明
| 参数 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
--input | String | 非必填 | 素材分析结果JSON文件的本地路径,与--group至少填写一个,两者同时提供时均生效,内容会合并用于创意分析 |
--group | String | 非必填 | 用于标识素材所属的分组。该 ID 完全由上层 Agent 或工作流在调用时指定, 与--input至少填写一个,两者同时提供时均生效,内容会合并用于创意分析 |
--ids | String | 非必填 | 配合 --group 使用。提供需要参与分析的指定素材ID列表(多个ID用逗号分隔)。只有存在于 group 且 ID 包含在 ids 中的素材才会被提取参与分析。若不提供,则默认提取分组下的所有素材。 |
--output | String | 是 | 创意分析结果保存的JSON文件本地路径 |
四、返回结果说明
成功返回
{"code": "0","message": "<创意分析结果保存的文件绝对路径>"}同时控制台会打印:
提交任务成功,任务ID: <返回的任务ID>同时结果文件会写入--output指定路径,包含完整的创意分析结果。
失败返回
{"code": "<错误码>","message": "<具体错误描述>"}五、使用示例
# 1. 确保输出目录存在
mkdir -p /tmp/openclaw/byted-kickart-viral-replicator/output
# 2. 生成各环节唯一文件名
MATERIAL_FILE="/tmp/openclaw/byted-kickart-viral-replicator/output/material_1742525890123_654321.json"
CREATIVE_FILE="/tmp/openclaw/byted-kickart-viral-replicator/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/openclaw/byted-kickart-viral-replicator/output/creative_1742525912345_123456.json"}
# 示例2:基于已上传的远程素材生成创意分析
CREATIVE_FILE2="/tmp/openclaw/byted-kickart-viral-replicator/output/creative_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json"
python3 scripts/creative.py --group grp-001 --output "$CREATIVE_FILE2"
# 示例2.1:基于分组下指定的素材ID生成创意分析
python3 scripts/creative.py --group grp-001 --ids "mat-123,mat-456" --output "$CREATIVE_FILE2"
# 超出数量限制的输出示例(将直接报错并停止执行)
{"code": "1416", "message": "素材总数(12个)超过了10个的限制,请减少素材数量后再试"}
# 示例3:同时使用素材分析结果+已上传自定义素材组合生成创意分析
CREATIVE_FILE3="/tmp/openclaw/byted-kickart-viral-replicator/output/creative_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json"
python3 scripts/creative.py --input "$MATERIAL_FILE" --group grp-001 --output "$CREATIVE_FILE3"
# 成功输出示例
提交任务成功,任务ID: 987654321
{"code": "0", "message": "/tmp/openclaw/byted-kickart-viral-replicator/output/creative_1742526012345_456789.json"}六、注意事项
1. 输入的素材分析JSON必须符合后端服务要求的格式规范 2. 输出路径建议使用绝对路径,避免相对路径导致的写入失败 3. 创意分析任务执行时间随素材复杂度波动,若长时间未返回可检查后端服务状态 4. 素材总数严格限制为最多10个(图片+视频合计,官方素材+自定义素材合并计算)。若超出10个,将直接报错返回失败(错误码 1416),不会自动截断。 5. 素材不可重复使用规则:同一批自定义素材仅可用于一次创作任务,创意分析完成后系统会自动清空当前会话素材记录,如需再次使用需要重新上传 6. 同时使用素材分析结果和自定义素材时,两类素材的合计数量仍受10个上限限制,超出部分将直接报错。 7. 清空素材记录仅会删除本地会话关联关系,不会删除远程服务器上的素材文件,如有需要可保留素材手动复用 8. 主动询问素材补充仅针对仅使用官方素材的场景,已上传自定义素材的场景不会重复询问 9. 明确隔离要求:在「爆款裂变」任务中,用户提供的 参考视频(ref_video) 和 模特/角色图片(character_images) 是裂变环节的专有参数,严禁将其作为自定义商品素材上传并混入创意分析的输入(即 --group 中不能包含这两种文件)。 10. ⚠️ 严禁使用参考视频:在「爆款裂变」任务中,参考视频(ref_video)仅用于爆款裂变环节的视频结构参考,绝对禁止将其作为创意分析的输入素材!创意分析必须基于商品素材(抖店链接或商品图片)进行!
七、Agent执行流程(强制遵守)
1. 参数强校验:调用脚本前必须先校验--input和--group参数,两者必须至少提供一个,都未提供时终止流程,提示用户「当前未检测到任何可用素材,请选择: ① 提供抖店/抖音商品链接完成官方素材拉取分析 ② 上传自定义素材,也可同时提供两类素材进行组合创作
- 注意:如果当前分组(
group)内混杂了非商品素材(如:参考视频、数字人形象等),必须使用--ids参数明确传入仅属于商品素材的 ID 列表,以防止无关素材污染创意分析结果! - ⚠️ 严禁使用参考视频:在「爆款裂变」任务中,参考视频(
ref_video)仅用于爆款裂变环节的视频结构参考,绝对禁止将其作为创意分析的输入素材!创意分析必须基于商品素材(抖店链接或商品图片)进行!
2. 素材提取与补充询问:如果用户处于“爆款裂变”或类似主流程中,请使用以下话术引导用户提供商品素材:
“接下来请提供目标商品素材,请给我发送抖店链接,我来自动提取商品图片和名称,你也可以手动提供”
用户提供后,如果有商品链接,则先调用素材分析提取结果。提取完成后向用户总结:
“目标商品素材如下:\n - 商品名称:xxx\n - 商品图片:x张”
循环收集确认(强制):无论用户提供的是抖店链接(官方素材)还是自己上传的图片/视频(自定义素材),在处理成功并记录后,都必须主动询问用户:“请问还有其他商品素材或链接需要补充吗?如果没有,我们将开始创意分析。”
- 🛑 强制暂停:询问后必须终止当前回合,等待用户回复!
- 用户回复「有」/ 继续上传素材:循环收集素材。
- 用户明确回复「没有了」/「不需要补充」:结束素材收集,进入下一步。
3. 参数校验:
- 同时校验两类素材合计数量是否超过10个,若超过10个,必须立即终止流程,将报错信息反馈给用户,并引导用户重新筛选/删减素材。
4. 若脚本未立即返回结果,先回复用户:"正在为您进行创意分析,任务执行时间可能较长,请您稍候~" 5. 持续使用process(action=poll)轮询任务状态,直到脚本执行完成 6. 若脚本因超时/异常退出,立即使用持久化的Task ID调用任务查询接口确认后端状态,禁止直接判定任务失败 7. 结果展示与交互式修改:任务完成后,必须向用户展示结构化的创意分析结果(格式参考下方的“返回消息模板”)。支持用户通过对话方式交互式修改创意分析(例如:“把受众修改为大学生”、“添加一个关于价格的卖点”等)。当用户提出修改意见时,你需要根据用户的要求直接编辑并覆盖本地生成的创意分析结果 JSON 文件,无需重新提交任务。注意:提取展示创意分析的结果时,不再展示成片相关的信息(如视频比例、时长、配音等)。 8. 结果强制确认:在用户完成所有修改并明确回复「确认创意无误」后,必须立即执行`python3 scripts/media.py clear --group <分组ID>`命令清空当前分组的所有已上传素材本地记录(若创意分析调用失败,不清空素材,支持直接重试无需重新上传),防止同一份素材被重复用于其他创作任务,保障素材使用唯一性。清理完成后,必须严格返回主流程(如《爆款裂变指南》)继续执行主流程的下一步要求(例如按照 `references/数字形象指南.md` 第18-24行规定的标准话术询问是否需要指定角色出镜)。无确认记录不得跳转至下一环节。 9. 若任务因为素材数量超限(错误码 1416)等原因报错,请明确告知用户超限的原因,并引导用户重新调整素材数量。 10. 错误熔断机制:任意环节校验不通过时,自动终止流程,引导用户回到对应环节补充信息/完成确认,不得跳过校验执行后续操作
八、返回消息模板
当创意分析任务完成后,必须使用以下模板向用户反馈。 严格要求:
- 必须使用普通正文格式展示,绝不可使用加粗语法(
**),并且必须严格保留每一行的换行符! - 严禁在返回结果中展示成片相关的信息(如视频比例、时长、配音等)。
✅ 创意分析完成 🎉
为您提炼的创意方向如下:
- 目标受众:[提取分析结果中的目标受众]
- 核心卖点:[提取分析结果中的核心卖点]
- 创意建议:[提取分析结果中的创意方向或建议]
👉 如果您对创意方向有调整需求(如修改受众、增删卖点等),请直接告诉我;
👉 确认无误请回复「确认创意无误」,我们将为您进行后续的流程~火山Kickart套餐开通与校验指南
用途说明
本指南用于指导agent完成火山Kickart营销短视频创作服务的套餐有效性校验,确保用户有可用服务额度后再执行后续创作流程。
---
🔒 强制前置校验步骤(必须100%按顺序执行,任意步骤不通过直接终止流程,禁止执行后续操作)
以下步骤优先级高于所有套餐查询逻辑,不得颠倒顺序、不得跳过:
步骤1:Python版本校验
执行命令检查Python版本是否符合要求:
python3.12 --version || (echo "❌ Python 3.12+ 未安装,请先安装Python 3.12" && exit 1)✅ 校验通过:输出Python 3.12.x版本号,进入下一步 ❌ 校验不通过:终止流程,告知用户需要安装Python 3.12+环境
---
步骤2:Python依赖安装
执行命令安装所需依赖包,使用清华镜像源加速下载:
pip install -r ./scripts/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple✅ 校验通过:依赖安装成功,进入下一步 ❌ 校验不通过:提示用户依赖安装失败,请检查网络或手动安装
---
步骤3:技能版本校验
- 步骤3-1:执行版本检查命令
python3.12 -m ./scripts/upgrade.py- 步骤3-2:解析返回结果
返回格式示例:
{"code":"0","message":"success","data":"{\"install_command\":\"\",\"latest_version\":\"1.0.0\",\"latest_version_number\":100000000,\"update_message\":\"\"}"}latest_version:最新版本号(如 "1.0.0")install_command:新版本安装指令- 步骤3-3:版本对比逻辑
- ✅ 当前版本 >= 最新版本:版本校验通过,继续后续流程
- ⚠️ 当前版本 < 最新版本:执行以下更新询问流程
1. 询问用户是否更新到最新版本:
检测到技能有新版本 {latest_version},是否更新?(是/否)
2. 用户确认更新(是):执行 install_command 安装新版本 3. 用户不更新(否):跳过更新,继续后续流程
---
🚀 套餐校验操作流程(必须完成上述全部前置校验步骤后才可执行)
步骤4:执行套餐查询命令
直接运行以下脚本查询用户套餐状态:
python3 scripts/plan.py步骤5:结果处理逻辑
场景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),避免用户误解 ---
⚠️ 执行规则(零容忍)
1. 未完成前置校验直接执行套餐查询命令的,属于严重流程违规,必须立即回滚并重新按顺序执行 2. 所有执行日志必须包含前置校验的完整输出,无前置校验日志的套餐查询结果视为无效
数字形象指南
目的与适用场景
当用户在「爆款裂变」任务中,需要自定义模特图或指定角色出镜时使用本指南,引导用户提供并校验符合规范的角色图片。
图片规格要求
- 数量:
1–3张 - 格式:
JPEG或PNG - 单张大小:
≤10MB - 分辨率:
≥480p - 总像素 (宽 × 高):在
9万到3600万之间 - 宽高比:在
0.25到4之间 - 形象一致性限制:若上传多张图片,数字人人物形象若不一致,系统默认选择第1张的人物形象。
- 合规限制:严禁上传明星、公众IP形象。
交互方式与话术规范(强制遵守)
1. 引导提供图片: 用户确认创意分析无误后,必须严格按照以下话术引导用户上传图片(需保持结构清晰、友好的格式):
💡 我为你智能匹配了数字人,如果你有想用的自定义数字人形象,也可以直接发图片给我哦!
>
⚠️ 上传注意事项:
1️⃣ 数量限制:最多可上传 3 张图片;
2️⃣ 形象一致:若上传多张图片且人物形象不一致,将默认选择第 1 张的人物形象;
3️⃣ 合规要求:请勿上传明星、公众IP等侵权形象。
⚠️ 重要规范:
- 禁止使用其他话术:不得使用"是否需要添加自定义模特"等询问式话术,必须使用上述标准话术
- 禁止提及尺寸要求:不得提及"500*500px"等具体尺寸要求,以指南中的规格为准(分辨率≥480p,总像素9万-3600万)
- 禁止使用"继续下一步流程"等催促性语言:话术中不得出现催促用户继续流程的表述
2. 接收多张图片:
- 当用户上传新的数字人形象图片时,默认追加到现有数字人形象列表中,除非用户明确说明「替换原有形象」才覆盖之前的ID。
- 总数量限制为最多3张:当前列表 + 新上传图片总数 ≤3时全部保留;超过3张时,明确告知用户已达到3张上限,并请用户选择保留哪3张,或指定要替换的原有形象。
- 所有有效形象按上传顺序排列,第一张为默认使用的数字人形象。
- 主动询问继续上传:每次成功处理并保存用户的数字人图片后,如果当前收集到的图片不足3张,必须主动询问用户是否还需要继续上传其他角度/形象的图片。只有当用户明确确认不再上传(或已经达到3张上限)时,才结束图片收集环节并进入下一步。
3. 错误/超限提示: 如果用户上传的图片不符合规格要求(脚本返回错误),必须明确指出具体超限数值,并引导用户重新提供。例如:
这张图片大小 15MB 超过 10MB 限制,请压缩或换一张再发我
(注意:请根据实际的超限指标灵活替换上述模板中的字段)
图片处理流程
获取到用户的图片后,必须按照以下步骤通过 python3 scripts/avatar.py 工具上传文件,以获取合法的媒资ID:
步骤1:获取本地文件
python3 scripts/avatar.py 工具仅支持本地文件路径作为输入。
- 如果用户直接上传了文件:可直接使用系统提供的文件路径。
- 如果用户提供的是图片链接:必须先将图片下载到本地,操作规范如下:
1. 确保下载目录存在:
mkdir -p /tmp/openclaw/byted-kickart-viral-replicator/download2. 使用 wget 或 curl 下载并保存到该目录,需生成唯一文件名:
wget -O "/tmp/openclaw/byted-kickart-viral-replicator/download/image_$(date +%s%N).jpg" "<图片链接>"步骤2:上传素材
执行以下命令上传图片:
python3 scripts/avatar.py --file <图片文件路径>参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
--file | string | 数字形象图片绝对路径 |
步骤3:提取并保留结果
执行上传命令后,提取上传成功后的 素材ID(返回结果中的 id 字段)。如果有多张图片,则获取多个素材ID,使用逗号分隔拼接。
步骤4:提交前校验(新增强制步骤)
在将素材ID传入 character_images 参数之前,必须确认素材已成功上传。可通过检查返回结果的 code 字段是否为 0 来确认上传成功。
火山引擎鉴权操作指南
用途说明
本指南用于指导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
抖音/短视频爆款视频裂变、克隆工具指南
触发场景
当用户需要裂变或克隆爆款视频,提到「爆款裂变」、「爆款克隆」、「复制视频」、「克隆视频」、「同款视频」、「生成同款」、「裂变视频」等,或者表达希望制作和某热门视频一样的内容的等价意图时使用本工具。
执行流程
Phase 0: 初始化(提交前确认)
在执行爆款裂变任务前,必须依次引导用户完成以下准备工作:
1. 参考视频获取:向用户发送话术:"好的,我帮你创建爆款克隆项目。请先提供 参考视频:可以发我视频链接,或上传 MP4/MOV 文件(≤60s、≤50MB、≥480p)"。然后阅读 references/视频参考指南.md,引导用户提供或上传参考视频,获取参考视频的素材ID作为 ref_video。成功获取后,必须明确记录状态已流转至下一步。
- ⚠️ 严禁混用素材:参考视频(
ref_video)与下一步的商品素材是完全不同的输入!绝对禁止将用户刚刚提供的参考视频直接当作商品素材去执行第3步的创意分析! - ⚠️ 禁止直接基于参考视频创意分析:参考视频仅用于爆款裂变环节的视频结构参考,绝对禁止将其作为创意分析的输入素材!创意分析必须基于商品素材(抖店链接或商品图片)进行!
- 交互与强制暂停:成功处理完参考视频并向用户反馈"上传成功"后,必须紧接着主动向用户发送第3步的索要话术("接下来请提供目标商品素材,可以是抖店商品链接,或者商品图片素材,我会帮你进行创意分析。"),然后必须在此终止当前回合,等待用户回复新的抖店链接或商品素材。绝不可在没有用户新输入的情况下自行去执行创意分析!
2. 目标商品素材输入与分析:
- ⚠️ 核心状态切换提醒:参考视频上传成功并发送了索要商品素材的话术后,流程即刻进入「商品素材收集」阶段。此时用户输入的新链接(如抖店链接、商品详情页)或图片,必须作为商品素材处理,绝不能再按参考视频的逻辑去报错。
- 循环收集商品素材:当用户提供新的商品链接或商品素材图片后,必须调用对应工具处理,并记录当前素材的ID或生成的文件路径。处理成功后,必须主动询问用户:"请问还有其他商品素材或链接需要补充吗?如果没有,我们将开始创意分析。"
- 🛑 强制暂停:每次成功处理一个商品素材后,必须在此终止当前回合,等待用户回复,绝不可自行跳至创意分析环节!
- 执行创意分析:只有当用户明确回复"没有了"、"不需要补充"或直接同意进行分析时,才结束素材收集,阅读
references/创意分析指南.md并执行创意分析。 - ⚠️ ID隔离规则:进行创意分析时,如果使用了自定义图片素材,必须将刚收集到的所有商品素材ID拼接成逗号分隔的字符串,通过
--ids参数传入,以严格排除之前的ref_video。 - 获取创意分析的输出路径作为
input参数。 - 🛑 强制暂停:创意分析结果需要用户确认。在用户明确确认无误之前,必须在此终止当前回合,等待用户回复,绝不可自行连续执行第4步! 创意分析结果由用户确认无误并完成记录清理后,必须严格返回本指南,并无缝衔接执行第4步!
3. 提供角色图片(创意确认后的强制第一问):用户确认创意分析无误后,必须严格遵循 `references/数字形象指南.md` 中第18-24行规定的标准话术引导用户上传图片,获取图片素材ID作为 character_images 参数(多张图片素材ID使用逗号分隔)。(需遵循数字形象指南中的多张图片接收与确认逻辑)。严禁使用其他话术或自行修改提示内容。 4. 通过 sessions_list 获取当前会话的 sessionId(本步骤为强制校验步骤,校验不通过严禁进入后续流程):
4.1 正确获取流程
1. 执行 sessions_list 命令获取当前所有活跃会话列表 2. 按以下优先级匹配当前正在执行任务的会话,仅提取标准UUID格式的 sessionId 字段: ① 优先匹配 status为running 状态的会话 ② 在running会话中,匹配 deliveryContext.channel 与当前入站上下文的channel一致、deliveryContext.to 与当前入站上下文的chat_id一致的会话 ③ 如果有多个匹配结果,取 updatedAt时间最新 的会话 ④ 如果没有running会话,再匹配最近5分钟内updatedAt的done会话,取匹配channel和chat_id且时间最新的会话
4.2 强制格式校验规则(必须全部满足)
- ✅ 唯一合法格式:仅支持通过
sessions_list工具获取到的标准UUID格式(8-4-4-4-12位十六进制字符串,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx),示例:49fceb6c-0df8-4345-9d8e-5d971c68f906 - ❌ 绝对禁止使用:
1. 以 agent: 开头的系统会话ID 2. 以 omt_ 开头的飞书话题ID 3. 以 om_ 开头的飞书消息ID 4. 任意其他非标准UUID格式的ID
4.3 校验失败处理
- 如果获取到的ID不符合标准UUID格式要求,或
sessions_list未返回有效标准UUID格式的sessionId,立即终止流程,不得提交任务 - 向用户返回提示:「系统暂时无法获取有效会话信息,请稍后重试」
4.4 构造参数
校验通过后,构造session参数(合法JSON字符串):{"sessionId": "<校验通过的标准UUID格式sessionId>"} 5. 从入站上下文提取完整metadata:
sender:直接使用入站上下文中提供的untrusted metadata完整JSONchat_type:从入站元信息中读取,默认为directchannel:从入站元信息中读取,如feishu/webchat/discord等chat_id:从入站元信息中读取,如user:ou_22070ce404148983b57582d681963872- 必须从返回结果中获取有效的channel和chat_id,禁止任何构造、推理、fallback等行为
- 构造metadata参数(完整JSON字符串,确保所有必填字段都存在):
{
"sender": <完整sender元信息对象>,
"chat_type": "<direct/group>",
"chat_id": "<会话ID>",
"channel": "<当前渠道,如feishu/webchat>"
}- 注意:sender必须保留原始JSON结构,不要拆解或修改
- 必须确保metadata是完整合法的JSON字符串,供工具内部json.loads解析
6. 展示成片信息:在收集齐上述所有必要参数并准备正式提交任务之前,必须先读取创意分析的结果(input)以及相关的视频参考信息,按照以下格式向用户展示即将生成的成片信息,询问用户是否符合预期。
播报模板示例(严格使用普通正文格式展示,保留换行,具体内容需从上文的分析结果中提取填充):
视频成片信息如下,请进行确认:
- 成片时长:[默认按照输入参考视频的时长填充,如 15s]
- 语种:[默认中文,支持用户指定多语种,如英文、巴西葡萄牙语]
- 卖点:[从创意分析结果提取的卖点,如:①主动降噪 ②40h 续航 ③轻量佩戴]
- 受众:[从创意分析结果提取的受众,如:通勤白领、长途差旅人群]
- 场景:[从创意分析结果提取的场景,如:地铁通勤、办公专注、长途飞行]
👉 请确认以上成片信息是否符合您的预期?7. 二次确认与合规提示:当用户明确回复成片信息符合预期后,必须进行二次确认,明确告知扣费风险及合规要求。等待用户明确回复确认或同意后,才可进入提交任务(Phase 1)环节。
二次确认播报模板示例(保留换行):
⚠️ 本次任务将会消耗创点,任务发起后创点消耗不可退还。
请确认您已经阅读并同意了 [虚拟人像合规承诺函](https://www.volcengine.com/docs/6664/2369812?lang=zh) 和 [合规承诺函](https://www.volcengine.com/docs/6664/2369383?lang=zh)。
👉 确认请回复我,我将立即为您开始制作视频~Phase 1: 提交任务
用户确认生成后执行以下操作: 1. 确保输出目录存在:
mkdir -p /tmp/openclaw/byted-kickart-viral-replicator/output2. 生成唯一输出文件名:
VIDEO_FILE="/tmp/openclaw/byted-kickart-viral-replicator/output/video_$(date +%s%N | cut -b1-13)_$((RANDOM%900000+100000)).json"3. 提交生成任务(注意根据用户是否提供角色图选择是否传入 --character-images 参数,并根据成片信息确认结果传入 --language,同时确保 --session、--metadata 参数传入正确的值):
python3 scripts/replication.py replication --input <input> --ref-video <ref_video> [--character-images <character_images>] [--language <语种代号>] --output "$VIDEO_FILE" --session '<session参数JSON字符串>' --metadata '<metadata参数JSON字符串>'(注意:语种代号如 `zh`代表中文,`en`代表英文,`pt-br`代表巴西葡萄牙语。如果没有指定,可不传该参数默认使用中文) 4. 如果提交返回错误,根据错误码补充缺失的参数后重试,最多重试3次
Phase 2: 提交后回复
脚本将自动提交任务并进行轮询查询进度。如果轮询期间任务完成,结果会直接保存到 output 文件中,请直接向用户播报结果。 如果在轮询期间未完成(命令行提示"任务正在执行中"),从输出中提取返回的任务ID,并按照以下模板回复用户:
✅ 爆款裂变任务已提交成功,任务ID:<任务ID>
⚠️ 任务正在后台执行中,您可以随时通过「查询进度」来获取最新状态~ 🦞如果提交返回错误,需严格按照全局 SKILL.md 中的【错误处理规范】匹配对应的错误码,并按照以下模板回复用户建议:
❌ 爆款裂变任务提交失败(任务ID:<任务ID>)
- 错误码:<返回的错误码>
- 处理建议:<匹配 SKILL.md 错误处理规范中的用户处理建议>结果通知
生成成功
从完成的JSON输出文件中提取到成片视频URL后,使用以下模板回复:
爆款复刻成功!,请点击链接预览或下载视频:[视频](<提取到的视频URL>)生成失败
❌ 爆款裂变任务失败(任务ID:<任务ID>)
- 错误码:<错误码>
- 处理建议:<匹配SKILL错误规范的处理建议>
如需重新提交,请告诉我。🦞视频参考指南
目的与适用场景
当用户意图进行「爆款裂变」或主动提供参考视频时,使用本指南引导用户提供并校验参考视频,最终获取可用的参考视频素材ID。
支持的输入方式
- 支持用户提供有效的视频链接。
- 支持用户本地上传视频文件。
- 不支持选择 SAAS 资产库内视频。
视频规格要求
- 格式:仅支持
MP4或MOV。 - 大小:
≤50MB。 - 时长:
≤60s。 - 分辨率:
≥480p。 - 比例:支持
9:16,16:9,3:4,4:3,1:1。
交互方式与话术规范(强制遵守)
1. 引导提供视频: 当用户表达了爆款裂变的意图,但尚未提供参考视频时,必须按照以下话术引导:
好的,我帮你创建爆款克隆项目。请先提供 参考视频:可以发我视频链接,或上传 MP4/MOV 文件(≤60s、≤50MB、≥480p)
2. 错误/超限提示: 如果用户上传的视频不符合规格要求(脚本返回错误),必须根据错误信息明确指出具体超限的数值,并引导用户重新提供。例如:
这条视频 大小 80MB 超过 50MB 限制,请压缩或换一条再发我
(注意:请根据实际的超限指标(如时长、分辨率等)灵活替换上述模板中的字段)
视频处理流程
用户提供视频后,必须按照以下步骤通过 python3 scripts/refer.py 工具上传文件,以获取合法的引用信息:
步骤1:获取本地文件
python3 scripts/refer.py 工具仅支持本地文件路径作为输入。
- 如果用户直接上传了文件:可直接使用系统提供的文件路径。
- 如果用户提供的是视频链接:必须先将视频下载到本地,操作规范如下:
1. 确保下载目录存在:
mkdir -p /tmp/openclaw/byted-kickart-viral-replicator/download2. 使用 curl 下载并保存到该目录,需生成唯一文件名:
curl -L -o "/tmp/openclaw/byted-kickart-viral-replicator/download/video_$(date +%s%N).mp4" "<视频链接>"3. 注意:下载命令中的 <视频链接> 必须是未经转义的原始链接,不能包含反引号 \` 等特殊字符
步骤2:上传素材
执行以下命令上传视频:
python3 scripts/refer.py --file <视频文件路径>参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
--file | string | 参考视频绝对路径 |
返回结果示例:
{"code":"0","message":"success","data":{"id":"xxx","url":"xxx","duration":60,"width":1080,"height":1920}}步骤3:提取并保留结果
执行上传命令后,从返回结果中提取上传成功后的 素材ID(data.id 字段)。该素材ID必须妥善保留,并将作为 ref_video 参数传递给后续的「爆款裂变」主流程。
步骤4:向用户反馈
获取素材ID后,必须使用以下模板向用户反馈结果。 严格要求:必须使用普通正文格式展示,绝不可使用加粗语法(**),并且必须严格保留每一行的换行符!
📤 返回消息模板(强制遵守)
✅ 参考视频上传成功 🎉
📁 素材分组:[替换为实际分组名]
🆔 素材ID:[替换为实际material_id]
📝 素材概述:[替换为实际素材信息,如视频格式、分辨率、时长等]素材上传指南
本指南包装了一个命令行工具 (python3 scripts/upload.py),用于上传本地素材文件并获取媒资ID。
目的与适用场景
本指南的主要目的是上传本地素材文件(图片、视频等)到媒体服务,并获取可用的媒资ID。
输入与参数
本指南的所有操作都基于对 python3 scripts/upload.py 工具的调用,其核心参数如下:
- 文件路径: 要上传的本地素材文件的绝对路径。
- `--file`: (必需)字符串,本地素材文件的绝对路径。
执行流程
Agent 在使用此指南时,应遵循以下流程:
1. 获取本地文件:
- 如果用户直接上传了文件:可直接使用系统提供的文件路径。
- 如果用户提供的是文件链接:必须先将文件下载到本地临时目录。
2. 上传素材:
- 调用
python3 scripts/upload.py --file <文件路径>命令上传素材。 - 提取返回结果中的素材ID。
3. 向用户反馈:
- 使用消息模板向用户反馈上传结果。
CLI 使用示例
# 上传素材文件
python3 scripts/upload.py --file /path/to/file.mp4参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
--file | string | 本地素材文件的绝对路径 |
返回结果示例:
{"code":"0","message":"success","data":{"id":"xxx","url":"xxx","width":1080,"height":1920}}注意事项
- 文件格式:支持图片(JPEG/PNG)和视频(MP4/MOV)等格式。
- 文件大小:需符合各具体场景的大小限制。
- 文件路径:必须提供文件的绝对路径。
📤 返回消息模板(强制遵守)
所有素材上传完成后必须使用以下模板向用户反馈。 严格要求:必须使用普通正文格式展示,绝不可使用加粗语法(**),并且必须严格保留每一行的换行符!
场景1:单素材上传成功
✅ [替换为实际素材类型,如参考视频]上传成功 🎉
📁 素材分组:[替换为实际分组名]
🆔 素材ID:[替换为实际material_id]
📝 素材概述:[替换为实际素材信息,如尺寸/时长/文件名/大小等]场景2:多素材批量上传成功
✅ 批量素材上传成功 🎉
📁 素材分组:[替换为实际分组名]
📊 上传统计:成功[替换为成功数量]个 / 总[替换为总数量]个
#### 素材详情:
1. 🆔 [替换为第一个素材ID]
📝 概述:[替换为第一个素材信息]
2. 🆔 [替换为第二个素材ID]
📝 概述:[替换为第二个素材信息]
[按需添加更多素材条目]场景3:批量上传部分失败
⚠️ 批量上传完成,部分失败
📁 素材分组:[替换为实际分组名]
📊 上传统计:成功[替换为成功数量]个 / 总[替换为总数量]个 | 失败[替换为失败数量]个
#### 成功素材:
1. 🆔 [替换为第一个成功素材ID]
📝 概述:[替换为第一个成功素材信息]
[按需添加更多成功素材条目]
#### 上传失败素材:
1. 📝 原文件名:[替换为失败素材原文件名]
❌ 失败原因:[替换为具体失败原因和解决方案]
[按需添加更多失败素材条目]抖店商品素材分析工具使用指南
目录
一、概述
本工具用于自动分析抖店/抖音商品的素材信息,支持输入抖店商品链接或抖音商品链接,自动完成链接校验、简化、任务提交、结果轮询全流程,最终将分析结果输出为JSON格式文件。
二、前置依赖
1. 已配置火山鉴权的AK/SK,可正常调用接口 2. 环境已安装Python 3.12+ 3. 依赖Python第三方库已包含在 scripts/requirements.txt 中
三、命令行参数
| 参数 | 必填 | 类型 | 说明 |
|---|---|---|---|
--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/openclaw/byted-kickart-viral-replicator/output
# 2. 生成唯一输出文件名
MATERIAL_FILE="/tmp/openclaw/byted-kickart-viral-replicator/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/openclaw/byted-kickart-viral-replicator/output/material_1742525890123_654321.json"}六、注意事项
1. 短链跳转和网络请求需要可访问外网的网络环境 2. 输出路径如果包含多级目录,需提前手动创建目录,否则会写入失败 3. 单次分析任务最大执行时间受后端服务限制,若长时间未返回可重新提交任务 4. 仅支持商品链接,不支持店铺链接、直播间链接等其他类型URL 5. ⚠️ 阶段命令隔离:
- 参考视频阶段:使用
python3 scripts/refer.py --file <本地视频路径>上传视频,或使用curl命令下载视频 - 商品素材阶段:使用
python3 scripts/material.py --url "<抖店链接>" --output "$MATERIAL_FILE"分析商品 - 严禁混淆:商品链接(抖店/抖音商品链接)绝对禁止使用
refer.py或curl下载命令处理,必须使用material.py脚本
七、Agent执行流程(强制遵守)
1. 调用exec工具启动 python3 scripts/material.py 脚本,设置≥180000ms(3分钟)的yieldMs 2. 若脚本未立即返回结果,先回复用户:"正在为您进行素材分析,任务执行时间可能较长,请您稍候~" 3. 若脚本因超时/异常退出,立即使用持久化的Task ID调用任务查询接口确认后端状态,禁止直接判定任务失败 4. 结果确认:任务完成后向用户同步素材分析摘要(商品信息、素材数量、核心卖点识别结果),确认无误后再进入后续创意分析流程
📤 返回消息模板(强制遵守)
当 python3 scripts/material.py 脚本执行成功,并从输出的 JSON 文件中读取到分析结果后,必须使用以下模板向用户反馈。 严格要求:必须使用普通正文格式展示,绝不可使用加粗语法(**),并且必须严格保留每一行的换行符!
✅ 商品素材分析完成 🎉
目标商品素材如下:
- 商品名称:[替换为实际商品名称]
- 商品图片:[替换为实际图片数量]张
- 核心卖点:[替换为识别出的核心卖点,若有多个请以逗号分隔]
👉 确认无误后,我们将为您进行下一步的创意分析与生成~# MIT License
#
# Copyright (c) 2026 ByteDance
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import click
import logging
import sys
import os
from core import Result
from core.api.meida.media import SimpleMediaService
from core.utils.extractor import ImageMetadataExtractor
from core.utils.validator import ImageValidator
@click.command()
@click.option("--file", required=True, type=str, help="数字形象图片绝对路径")
def main(file):
"""本地图片文件上传工具,上传图片并获取媒资ID"""
logging.info(f"[tool] >>> python3 {' '.join(sys.argv)}")
# 检查文件是否存在
if not os.path.isfile(file):
click.echo(Result(code="-1", message=f"文件不存在: {file}").model_dump_json(), err=True)
exit(1)
try:
# 创建媒体服务实例
media_service = SimpleMediaService()
# 创建元数据提取器和校验器
extractor = ImageMetadataExtractor()
validator = ImageValidator()
# 上传图片文件
click.echo(f"正在上传图片文件: {file}")
matriel = media_service.add_media(file, extractor, validator)
click.echo(Result(code="0", message="success", data=matriel).model_dump_json())
except Exception as e:
click.echo(Result(code="-1", message=str(e)).model_dump_json(), err=True)
exit(1)
if __name__ == "__main__":
main()# MIT License
#
# Copyright (c) 2026 ByteDance
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
import time
import logging
from functools import cache
from pydantic import BaseModel
class Result(BaseModel):
code: str
message: str
data: object = None
class MediaConfig:
"""媒体相关配置与常量"""
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"}
VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov"}
IMAGE_MAX_SIZE = 8 * 1024 * 1024
VIDEO_MAX_SIZE = 50 * 1024 * 1024
IMAGE_MIN_WIDTH = 300
IMAGE_MIN_HEIGHT = 300
IMAGE_MAX_PIXELS = 36_000_000
CSV_COLUMNS = ["group", "channel", "account", "path", "id", "material", "timestamp"]
STORAGE_BASE_DIR = "/tmp/openclaw/byted-kickart-viral-replicator/media"
@cache
def init():
"""只执行一次的初始化方法,用于配置日志和目录"""
log_dir = "/tmp/openclaw/byted-kickart-viral-replicator/logs"
os.makedirs(log_dir, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
filename=f'{log_dir}/info.{time.strftime("%Y%m%d", time.localtime())}.log',
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
init()
__all__ = ["Result"]# MIT License
#
# Copyright (c) 2026 ByteDance
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from abc import ABC, abstractmethod
import sys
import os
import time
import logging
import requests
from collections import defaultdict
from urllib.parse import urlencode, urlparse
# 动态加载项目根目录,以便于引入 core
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
from core.utils.hash import HashUtils
from core.auth.strategy import AuthType, AuthStrategy
class IccpClient(ABC):
@abstractmethod
def do_request(self, method: str, queries: dict, body: bytes, action: str) -> dict:
pass
class V1IccpClient(IccpClient):
"""基于 AK/SK 的请求客户端 (Strategy 实现)"""
ADDR = "https://icp.volcengineapi.com"
SERVICE = "iccloud_muse"
REGION = "cn-north"
VERSION = "2025-11-25"
def __init__(self):
self.ak = os.getenv("ACCESS_KEY_ID") or ""
self.sk = os.getenv("SECRET_ACCESS_KEY") or ""
def _get_signed_key(self, secret_key: str, date: str, region: str, service: str) -> bytes:
k_date = HashUtils.hmac_sha256(secret_key.encode("utf-8"), date)
k_region = HashUtils.hmac_sha256(k_date, region)
k_service = HashUtils.hmac_sha256(k_region, service)
return HashUtils.hmac_sha256(k_service, "request")
def do_request(self, method: str, queries: dict, body: bytes, action: str) -> dict:
queries["Action"] = action
queries["Version"] = self.VERSION
query_string = urlencode(queries).replace("+", "%20")
url = f"{self.ADDR}?{query_string}"
date = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(time.time()))
auth_date = date[:8]
payload = HashUtils.hash_sha256(body).hex()
signed_headers = ["host", "x-date", "x-content-sha256", "content-type"]
host = urlparse(self.ADDR).netloc
header_list = [
f"host:{host}",
f"x-date:{date}",
f"x-content-sha256:{payload}",
"content-type: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 = HashUtils.hash_sha256(canonical_string.encode("utf-8")).hex()
credential_scope = f"{auth_date}/{self.REGION}/{self.SERVICE}/request"
sign_string = "\n".join(["HMAC-SHA256", date, credential_scope, hashed_canonical_string])
signed_key = self._get_signed_key(self.sk, auth_date, self.REGION, self.SERVICE)
signature = HashUtils.hmac_sha256(signed_key, sign_string).hex()
authorization = (
f"HMAC-SHA256 Credential={self.ak}/{credential_scope},"
f" SignedHeaders={';'.join(signed_headers)},"
f" Signature={signature}"
)
headers = defaultdict(str)
headers["X-Date"] = date
headers["X-Content-Sha256"] = payload
headers["Content-Type"] = "application/json"
headers["Authorization"] = authorization
if ppe_env := os.getenv("X_VOLC_ENV"):
headers.update({"X-TT-Env": "ppe_volcengine", "X-Volc-Env": ppe_env, "X-Use-Ppe": "1"})
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.json()
class V2IccpClient(IccpClient):
"""基于 Ark Token 的请求客户端 (Strategy 实现)"""
SERVICE = "iccloud_muse"
REGION = "cn-north"
VERSION = "2025-11-25"
def __init__(self):
self.addr = os.getenv("ARK_SKILL_API_BASE")
self.token = os.getenv("ARK_SKILL_API_KEY") or ""
def do_request(self, method: str, queries: dict, body: bytes, action: str) -> dict:
queries["Action"] = action
queries["Version"] = V2IccpClient.VERSION
query_string = urlencode(queries).replace("+", "%20")
url = f"{self.addr}?{query_string}"
headers = defaultdict(str)
headers["Authorization"] = f"Bearer {self.token}"
headers["Content-Type"] = "application/json"
headers["ServiceName"] = V2IccpClient.SERVICE
if ppe_env := os.getenv("X_VOLC_ENV"):
headers.update({"X-TT-Env": "ppe_volcengine", "X-Volc-Env": ppe_env, "X-Use-Ppe": "1"})
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.json()
class IccpClientFactory:
@staticmethod
def create(strategy: AuthStrategy) -> IccpClient:
if strategy.strategy == AuthType.API_KEY:
return V2IccpClient()
if strategy.strategy == AuthType.AK_SK:
return V1IccpClient()
raise ValueError(f"不支持的认证策略类型: {strategy.strategy}")# MIT License
#
# Copyright (c) 2026 ByteDance
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
import sys
import json
import jsonpath
# 动态加载根目录以便正确导入
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
from core import Result
from core.auth.strategy import AuthStrategyFactory
from core.api.iccp.client import IccpClientFactory
# ─── 业务服务层 (Service Layer) ───────────────────────────────────
class IccpService:
def __init__(self):
strategy = AuthStrategyFactory.create()
self.client = IccpClientFactory.create(strategy)
def submit(self, 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": str(service_id),
"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 = self.client.do_request("POST", {}, submit_bytes, action="SubmitAiTemplateTaskAsync")
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}")
return Result(code="0", message="success", data=task_id[0])
except Exception as e:
return Result(code="-1", message=f"提交任务失败, 错误信息: {str(e)}")
def query(self, task_id: str) -> Result:
params = json.dumps({"TaskId": task_id}, ensure_ascii=False).encode("utf-8")
try:
resp = self.client.do_request("POST", {}, params, action="QueryAiTemplateTaskResult")
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="success", data=result[0])
except Exception as e:
return Result(code="-1", message=f"查询任务状态失败: {str(e)}")
def post(self, action: str, params: bytes) -> Result:
try:
resp = self.client.do_request("POST", {}, params, action=action)
open_top_code = jsonpath.jsonpath(resp, "$.ResponseMetadata.Error.CodeN")
if open_top_code and open_top_code[0] != 0: return Result(code=str(open_top_code[0]), message="")
code = jsonpath.jsonpath(resp, "$.ResponseMetadata.Code")
if code and code[0] != 0: return Result(code=str(code[0]), message="")
if code and code[0] == 0:
result = jsonpath.jsonpath(resp, "$.Result")
if not result or not result[0]: return Result(code="-1", message="接口返回值解析错误")
expire = jsonpath.jsonpath(resp, "$.Result.expire_time")
return Result(code="0", message=str(expire and expire[0]))
return Result(code="-1", message="接口返回值解析错误")
except Exception as e:
return Result(code="-1", message=str(e))# MIT License
#
# Copyright (c) 2026 ByteDance
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import logging
import os
import sys
import time
import collections
from abc import ABC, abstractmethod
from typing import Dict, List, TypedDict
from urllib.parse import urlencode, urlparse
import jsonpath
import requests
# 动态加载项目根目录,以便于引入 utils
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from utils.hash import HashUtils
from utils.matriel import Matriel, ImageMatriel, VideoMatriel
from auth.strategy import AuthType, AuthStrategy
# ─── 类型定义 ──────────────────────────────────────────
class RangeDict(TypedDict):
Start: int
End: int
class UploadStateResult(TypedDict):
SkipDataComplete: bool
PartSize: int
Ranges: List[RangeDict]
# ─── 配置管理 ──────────────────────────────────────────
class AppConfig:
"""全局配置管理"""
REGION = "cn-north"
VERSION = "2022-02-01"
SERVICE_MUSE = "iccloud_muse"
SERVICE_IAM = "ic_iam"
POLL_MAX_ATTEMPTS = 60
POLL_INTERVAL = 5
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "tif"}
VIDEO_EXTENSIONS = {"mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v", "3gp"}
# ─── API 客户端 ────────────────────────────────────────
class ApiClient(ABC):
"""处理与后端的 HTTP 交互"""
def __init__(self, host: str):
self.host = host
def _check_resp(self, resp: dict, action: str):
meta = resp.get("ResponseMetadata", {})
error_obj = meta.get("Error")
if error_obj:
code = error_obj.get("Code") or error_obj.get("CodeN")
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"):
msg = meta.get("Message") or ""
print(f"❌ {action} 失败: code={code}, msg={msg}")
sys.exit(1)
def request(self, action: str, service: str, body: dict = None, extra_query: dict = None) -> dict: # type: ignore
extra_query = extra_query or {}
body_bytes = json.dumps(body or {}, ensure_ascii=False).encode()
payload_hash = HashUtils.hash_sha256(body_bytes).hex()
url = self.build_url(self.host, action, extra_query)
query_string = urlparse(url).query
headers = self.build_headers(service, self.host, query_string, payload_hash, is_binary=False)
logging.info(f"[http] <<< {headers} {json.dumps(body or {}, ensure_ascii=False)}")
resp = requests.post(url, data=body_bytes, headers=headers, timeout=30)
logging.info(f"[http] <<< {resp.headers} {resp.text}")
try:
result = resp.json()
except Exception:
print(f"json parse error, resp is {resp.text}")
sys.exit(1)
self._check_resp(result, action)
return result
def request_binary(self, action: str, service: str, extra_query: dict, data: bytes) -> dict:
payload_hash = HashUtils.hash_sha256(data).hex()
url = self.build_url(self.host, action, extra_query)
query_string = urlparse(url).query
headers = self.build_headers(service, self.host, query_string, payload_hash, is_binary=True)
resp = requests.post(url, data=data, headers=headers, timeout=60)
try:
result = resp.json()
except Exception:
print(f"json parse error, resp is {resp.text}")
sys.exit(1)
self._check_resp(result, action)
return result
@abstractmethod
def build_headers(self, service: str, host: str, query_string: str, payload_hash: str, is_binary: bool) -> Dict[str, str]:
pass
@abstractmethod
def build_url(self, host: str, action: str, extra_query: dict) -> str:
pass
class ArkClawApiClient(ApiClient):
def __init__(self):
super().__init__(os.getenv("ARK_SKILL_API_BASE", ""))
self.token = os.getenv("ARK_SKILL_API_KEY", "")
def build_headers(self, service: str, host: str, query_string: str, payload_hash: str, is_binary: bool) -> Dict[str, str]:
headers = collections.defaultdict(str)
headers["ServiceName"] = service
headers["Authorization"] = f"Bearer {self.token}"
headers["Content-Type"] = "application/octet-stream" if is_binary else "application/json"
if ppe_env := os.getenv("X_VOLC_ENV"):
headers.update({"X-TT-Env": "ppe_volcengine", "X-Volc-Env": ppe_env, "X-Use-Ppe": "1"})
return headers
def build_url(self, host: str, action: str, extra_query: dict) -> str:
url = f"{host}/?Action={action}&Version={AppConfig.VERSION}"
if extra_query:
url += "&" + urlencode(extra_query)
return url
class AkSkApiClient(ApiClient):
def __init__(self):
super().__init__("https://icp.volcengineapi.com")
self.ak = os.getenv("ACCESS_KEY_ID", "")
self.sk = os.getenv("SECRET_ACCESS_KEY", "")
def build_headers(self, service: str, host: str, query_string: str, payload_hash: str, is_binary: bool) -> Dict[str, str]:
date = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(time.time()))
auth_date = date[:8]
content_type = "application/octet-stream" if is_binary else "application/json"
signed_headers = ["host", "x-date", "x-content-sha256", "content-type"]
parsed_url = urlparse(host)
host_name = parsed_url.netloc
header_list = [
f"host:{host_name}",
f"x-date:{date}",
f"x-content-sha256:{payload_hash}",
f"content-type:{content_type}"
]
header_string = "\n".join(header_list)
canonical_string = "\n".join(["POST", "/", query_string, f"{header_string}\n", ";".join(signed_headers), payload_hash])
hashed_canonical_string = HashUtils.hash_sha256(canonical_string.encode("utf-8")).hex()
credential_scope = f"{auth_date}/{AppConfig.REGION}/{service}/request"
sign_string = "\n".join(["HMAC-SHA256", date, credential_scope, hashed_canonical_string])
k_date = HashUtils.hmac_sha256(self.sk.encode("utf-8"), auth_date)
k_region = HashUtils.hmac_sha256(k_date, AppConfig.REGION)
k_service = HashUtils.hmac_sha256(k_region, service)
signed_key = HashUtils.hmac_sha256(k_service, "request")
signature = HashUtils.hmac_sha256(signed_key, sign_string).hex()
authorization = (f"HMAC-SHA256 Credential={self.ak}/{credential_scope},"
f" SignedHeaders={';'.join(signed_headers)},"
f" Signature={signature}")
headers = collections.defaultdict(str)
headers["X-Date"] = date
headers["X-Content-Sha256"] = payload_hash
headers["Content-Type"] = content_type
headers["Authorization"] = authorization
if ppe_env := os.getenv("X_VOLC_ENV"):
headers.update({"X-TT-Env": "ppe_volcengine", "X-Volc-Env": ppe_env, "X-Use-Ppe": "1"})
return headers
def build_url(self, host: str, action: str, extra_query: dict) -> str:
queries = extra_query.copy()
queries["Action"] = action
queries["Version"] = AppConfig.VERSION
query_string = urlencode(sorted(queries.items())).replace("+", "%20")
return f"{host}?{query_string}"
class ApiClientFactory:
@staticmethod
def create(strategy: AuthStrategy) -> ApiClient:
if strategy.strategy == AuthType.API_KEY:
return ArkClawApiClient()
if strategy.strategy == AuthType.AK_SK:
return AkSkApiClient()
raise ValueError(f"不支持的认证策略类型: {strategy.strategy}")
# ─── 业务服务层 ────────────────────────────────────────
class IamService:
def __init__(self, client: ApiClient):
self.client = client
def get_admin_user_id(self) -> int:
result = self.client.request(action="ListUsers", service=AppConfig.SERVICE_IAM, body={"UserType": "All"})
users = result.get("Result", {}).get("Users", [])
if not users:
print("❌ 未获取到任何用户信息")
sys.exit(1)
for user in users:
if user.get("IsAdmin") and user.get("Id"):
return user.get("Id")
return users[0].get("Id")
class MuseService:
def __init__(self, client: ApiClient):
self.client = client
def get_upload_state(self, file_md5: str, file_size: int, file_crc32: int, owner_id: int) -> UploadStateResult:
body = {
"Owner": {"Id": owner_id, "Type": "PERSON"},
"Md5": file_md5, "Size": file_size,
"Start": 0, "End": file_size - 1, "Crc": file_crc32
}
result = self.client.request(action="GetUploadState", service=AppConfig.SERVICE_MUSE, body=body)
raw_state = result.get("Result", {})
return {
"SkipDataComplete": bool(raw_state.get("SkipDataComplete", False)),
"PartSize": int(raw_state.get("PartSize", 0)),
"Ranges": raw_state.get("Ranges", [])
}
def upload_part(self, owner_id: int, chunk: bytes, offset: int, part_size: int, chunk_md5: str) -> dict:
query = {
"Md5": chunk_md5, "Size": part_size, "Offset": offset,
"OwnerId": owner_id, "OwnerType": "PERSON"
}
return self.client.request_binary("StreamUploadData", AppConfig.SERVICE_MUSE, query, chunk)
def create_material(self, 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:
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,
},
}
result = self.client.request(action="CreateMaterial", service=AppConfig.SERVICE_MUSE, body=body)
return result.get("Result", {}).get("MediaId")
def poll_media_info(self, media_id: str, owner_id: int, owner_type: str) -> dict:
for _ in range(AppConfig.POLL_MAX_ATTEMPTS):
result = self.client.request(
action="GetMediaInfo", service=AppConfig.SERVICE_MUSE,
body={"MediaIds": [media_id], "MediaType": 1},
)
media_infos = result.get("Result", {}).get("MediaInfos", [])
if media_infos:
media_info = media_infos[0]
status = media_info.get("BasicInfo", {}).get("MediaStatus")
if status >= 2:
return media_info
if status in (1, 5):
print("❌ 处理失败")
sys.exit(1)
time.sleep(AppConfig.POLL_INTERVAL)
sys.exit(1)
class KickartMuseService:
def __init__(self, client: ApiClient):
self.client = client
def poll_media_info(self, media_id: str, owner_id: int, owner_type: str) -> dict:
for _ in range(AppConfig.POLL_MAX_ATTEMPTS):
time.sleep(AppConfig.POLL_INTERVAL)
result = self.client.request(
action="GetMediaInfo", service=AppConfig.SERVICE_MUSE,
body={"MediaIds": [media_id], "MediaType": 3},
)
media_infos = result.get("Result", {}).get("MediaInfos", [])
if not media_infos:
continue
media_info = media_infos[0]
status = media_info.get("BasicInfo", {}).get("MediaStatus")
if status == 4:
return media_info
elif status == 5 or status == 1:
print("❌ 处理失败")
sys.exit(1)
sys.exit(1)
# ─── 编排与格式化层 ────────────────────────────────────
class MaterialUploader:
def __init__(self, client: ApiClient):
self.iam = IamService(client)
self.muse = MuseService(client)
def stream_upload(self, file_path: str, file_md5: str, file_size: int, file_crc32: int,
owner_id: int, state: UploadStateResult) -> UploadStateResult:
if state["SkipDataComplete"]:
return state
with open(file_path, "rb") as f:
data = f.read()
offset = 0
for _ in range(1000):
if state["SkipDataComplete"]: break
part_size = state.get("PartSize", 0)
if part_size == 0:
chunk, chunk_size = data, file_size
else:
chunk_size = part_size if offset + part_size * 2 <= file_size else file_size - offset
chunk = data[offset : offset + chunk_size]
self.muse.upload_part(owner_id, chunk, offset, chunk_size, file_md5)
offset += chunk_size
state = self.muse.get_upload_state(file_md5, file_size, file_crc32, owner_id)
if state["SkipDataComplete"] or not state["Ranges"] or offset >= file_size:
return state
return state
class MediaFormatter:
@staticmethod
def extract_url(media_info: dict) -> str:
cat = media_info.get("BasicInfo", {}).get("MediaFirstCategory", "")
if cat == "image":
image_media = media_info.get("ImageMedia", {})
if dl := image_media.get("DownloadUrl"): return dl
for q in ["origin", "jpeg_1080p", "jpeg_480p"]:
if url := image_media.get("TranscodeDownloadUrls", {}).get(q): return url
elif cat in ("video", "audio"):
media = media_info.get("VideoMedia" if cat == "video" else "AudioMedia", {})
if dl := media.get("DownloadUrl"): return dl
if play := media.get("PlayInfo", []): return play[0].get("Url", "")
return ""
@staticmethod
def simplify(media_info: dict) -> dict:
# 保持原逻辑的 simplify
cat = media_info.get("BasicInfo", {}).get("MediaFirstCategory", "")
if cat == "image":
im = media_info.get("ImageMedia", {})
if dl := im.get("DownloadUrl"): im["DownloadUrl"] = dl
for q in ["origin", "jpeg_1080p", "jpeg_480p"]:
if url := im.get("TranscodeDownloadUrls", {}).get(q):
im["TranscodeDownloadUrls"][q] = url
elif cat in ("video", "audio"):
vm = media_info.get("VideoMedia" if cat == "video" else "AudioMedia", {})
if dl := vm.get("DownloadUrl"): vm["DownloadUrl"] = dl
if play := vm.get("PlayInfo", []): play[0]["Url"] = play[0].get("Url")
return media_info
@staticmethod
def format(media_info: dict) -> Matriel:
if jsonpath.jsonpath(media_info, "$.ImageMedia"):
m = ImageMatriel(id='', type="image", url="", size=0, height=0, width=0)
if v := jsonpath.jsonpath(media_info, "$.BasicInfo.MediaId"): m.id = v[0]
if v := jsonpath.jsonpath(media_info, "$.ImageMedia.DownloadUrl"): m.url = v[0]
if v := jsonpath.jsonpath(media_info, "$.ImageMedia.Width"): m.width = v[0]
if v := jsonpath.jsonpath(media_info, "$.ImageMedia.Height"): m.height = v[0]
return m
elif jsonpath.jsonpath(media_info, "$.VideoMedia"):
m = VideoMatriel(id="", type="video", url="", size=0, height=0, width=0, duration=0)
if v := jsonpath.jsonpath(media_info, "$.BasicInfo.MediaId"): m.id = v[0]
if v := jsonpath.jsonpath(media_info, "$.VideoMedia.DownloadUrl"): m.url = v[0]
if v := jsonpath.jsonpath(media_info, "$.VideoMedia.MediaMetaInfo.Width"): m.width = v[0]
if v := jsonpath.jsonpath(media_info, "$.VideoMedia.MediaMetaInfo.Height"): m.height = v[0]
if v := jsonpath.jsonpath(media_info, "$.VideoMedia.MediaMetaInfo.Duration"): m.duration = v[0] / 1000
return m
return Matriel(id="", type="", url="", size=0, height=0, width=0)# MIT License
#
# Copyright (c) 2026 ByteDance
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import json
import os
import sys
import time
from abc import ABC, abstractmethod
from typing import Dict, Any, List
from pathlib import Path
import pandas as pd
# 动态加载项目根目录,以便于引入 core.Result
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from auth.strategy import AuthStrategyFactory
from utils.hash import HashUtils
from utils.validator import Validator
from utils.extractor import MetadataExtractor
from api.meida.chunks import AppConfig, ApiClientFactory, MaterialUploader, MediaFormatter, IamService, KickartMuseService
from core.utils.matriel import VideoMatriel
from core import MediaConfig
class RemoteUploader(ABC):
"""远程上传器接口 (策略模式)"""
@abstractmethod
def upload(self, file: str) -> Any:
pass
class MuseRemoteUploader(RemoteUploader):
"""基于 Muse 的远程上传器具体实现"""
def __init__(self): # type: ignore
strategy = AuthStrategyFactory.create()
client = ApiClientFactory.create(strategy)
self.uploader = MaterialUploader(client)
def upload(self, file: str) -> Any:
owner_id = self.uploader.iam.get_admin_user_id()
file_md5, file_crc32, file_size = HashUtils.file_hash(file)
file_name = os.path.splitext(os.path.basename(file))[0]
file_ext = os.path.splitext(file)[1].lstrip(".")
cat = "image" if file_ext.lower() in AppConfig.IMAGE_EXTENSIONS else "video"
title = f"artclaw-material-{int(time.time())}"
owner_type = "user"
state = self.uploader.muse.get_upload_state(file_md5, file_size, file_crc32, owner_id)
state = self.uploader.stream_upload(file, file_md5, file_size, file_crc32, owner_id, state)
media_id = self.uploader.muse.create_material(
file_md5, file_size, file_name, file_ext,
state["SkipDataComplete"], owner_id, owner_type, title, cat
)
media_info = self.uploader.muse.poll_media_info(media_id, owner_id, owner_type)
return MediaFormatter.format(MediaFormatter.simplify(media_info))
class KickartUploader(RemoteUploader):
def __init__(self, source: str):
self.strategy = AuthStrategyFactory.create()
self.client = ApiClientFactory.create(self.strategy)
self.iam = IamService(self.client)
self.muse = KickartMuseService(self.client)
self.source = source
def upload(self, file: str) -> Any:
"""通过文件 上传媒资"""
owner_id = self.iam.get_admin_user_id()
owner_type = "user"
title = f"artclaw-material-{int(time.time())}"
body = {
"Owner": {"Id": owner_id, "Type": "PERSON"},
"CreateUrlFilmInfo": {
"Title": title,
"SourceFrom": self.source,
"MediaFirstCategory": "video",
"MaterialUrl": file,
"Description": title
},
}
result = self.client.request(action="CreateUrlFilm", service=AppConfig.SERVICE_MUSE, body=body)
media_id = result.get("Result", {}).get("MediaId")
if not media_id:
raise ValueError("CreateUrlFilm 未返回 MediaId")
media_info = self.muse.poll_media_info(media_id, owner_id, owner_type)
return MediaFormatter.format(MediaFormatter.simplify(media_info))
class MediaRepository:
"""仓储层:处理底层 CSV 数据的读写"""
def __init__(self, base_dir: str = MediaConfig.STORAGE_BASE_DIR):
self.base_dir = Path(base_dir)
def _get_path(self, group: str) -> Path:
return self.base_dir / f"{group}.csv"
def load(self, group: str) -> pd.DataFrame:
path = self._get_path(group)
if not path.exists():
return pd.DataFrame(columns=MediaConfig.CSV_COLUMNS)
return pd.read_csv(path, header=None, names=MediaConfig.CSV_COLUMNS)
def save(self, group: str, df: pd.DataFrame):
path = self._get_path(group)
os.makedirs(path.parent, exist_ok=True)
df.to_csv(path, index=False, header=False)
def clear(self, group: str):
path = self._get_path(group)
if path.exists():
os.remove(path)
class SimpleMediaRepository:
"""媒体缓存仓储层:处理底层 JSON 文件的读写(仿照 MediaRepository 设计)"""
def __init__(self, base_dir: str = None): # type: ignore
self.base_dir = Path(base_dir or MediaConfig.STORAGE_BASE_DIR)
self.base_dir.mkdir(parents=True, exist_ok=True)
def _get_path(self, media_id: str) -> Path:
"""获取媒体缓存文件路径"""
return self.base_dir / f"{media_id}.json"
def load(self, media_id: str) -> dict | None:
"""加载指定媒体ID的缓存数据"""
path = self._get_path(media_id)
if not path.exists():
raise FileNotFoundError(f"文件不存在: {path}")
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def save(self, media_id: str, data: dict):
"""保存媒体数据到缓存"""
path = self._get_path(media_id)
os.makedirs(path.parent, exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def clear(self, media_id: str):
"""清除指定媒体ID的缓存"""
path = self._get_path(media_id)
if path.exists():
os.remove(path)
def clear_all(self):
"""清除所有缓存"""
for file in self.base_dir.glob("*.json"):
file.unlink()
class MediaService:
"""业务服务层:协调校验、提取、上传与存储 (依赖注入)"""
def __init__(self, repository: MediaRepository = None, uploader: RemoteUploader = None): # type: ignore
self.repository = repository or MediaRepository()
self.uploader = uploader or MuseRemoteUploader()
def add_media(self, file: str, group: str, metadata: dict, extractor: MetadataExtractor, validator: Validator) -> Any: # type: ignore
metadata_result = extractor.extract(file)
validation_result = validator.validate(metadata_result)
if not validation_result.get('valid', False):
return validation_result
# 上传文件到远程服务器
matriel = self.uploader.upload(file)
# 补全缺失的媒体信息
if hasattr(matriel, 'type') and matriel.type == "":
matriel.type = validation_result.get('file_type', '')
# 构造存储记录
row = {
'group': group,
'channel': metadata.get('channel', ''),
'account': metadata.get('chat_id', ''),
'path': file,
'id': matriel.id,
'material': matriel.model_dump_json(),
'timestamp': str(time.time())
}
# 持久化到仓储
df = self.repository.load(group)
df.loc[len(df)] = row
self.repository.save(group, df)
return matriel
def list_media(self, group: str) -> List[Dict]:
df = self.repository.load(group)
if df.empty:
return []
return df["material"].map(lambda x: json.loads(x)).to_list() # type: ignore
def remove_media(self, media_id: str, group: str):
df = self.repository.load(group)
df.drop(df[df["id"].eq(media_id)].index, inplace=True)
self.repository.save(group, df)
def clear_media(self, group: str):
self.repository.clear(group)
class SimpleMediaService:
"""简化版媒体服务:仅负责文件上传,不需要group参数,支持本地缓存"""
def __init__(self, repository: SimpleMediaRepository = None, uploader: RemoteUploader = None): # type: ignore
self.repository = repository or SimpleMediaRepository()
self.uploader = uploader or MuseRemoteUploader()
def add_media(self, file: str, extractor: MetadataExtractor, validator: Validator) -> Any: # type: ignore
"""
上传单个文件到远程服务器,并将信息存储到本地缓存
Args:
file: 本地文件绝对路径
extractor: 元数据提取器(可选)
validator: 校验器(可选)
Returns:
Matriel 对象,包含上传后的媒体信息(id、url等)
"""
metadata_result = extractor.extract(file)
validation_result = validator.validate(metadata_result)
if not validation_result.get('valid', False):
return validation_result
# 上传文件到远程服务器
matriel = self.uploader.upload(file)
# 补全缺失的媒体信息
matriel.width = getattr(metadata_result, 'width', 0)
matriel.height = getattr(metadata_result, 'height', 0)
if hasattr(metadata_result, 'duration'):
matriel.duration = getattr(metadata_result, 'duration', 0)
if hasattr(metadata_result, 'closest_ratio'):
matriel.closest_ratio = getattr(metadata_result, 'closest_ratio', "")
matriel.size = getattr(metadata_result, 'size', 0)
# 将媒体信息保存到本地缓存
media_info = {
'id': matriel.id,
'url': matriel.url,
'type': matriel.type,
'width': matriel.width,
'height': matriel.height,
'size': matriel.size,
'timestamp': time.time()
}
if hasattr(matriel, 'duration'):
media_info['duration'] = matriel.duration
if hasattr(matriel, 'closest_ratio'):
media_info['closest_ratio'] = matriel.closest_ratio
self.repository.save(matriel.id, media_info)
return matriel
def get_media(self, media_id: str) -> dict:
"""
通过媒资ID获取媒体详细信息(优先从本地缓存读取)
Args:
media_id: 媒资ID
Returns:
媒体信息字典
"""
return self.repository.load(media_id) # type: ignore# MIT License
#
# Copyright (c) 2026 ByteDance
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from .strategy import AuthStrategy, AkSkAuthStrategy, ApiKeyAuthStrategy, AuthStrategyFactory, AuthType
__all__ = ["AuthStrategy", "AkSkAuthStrategy", "ApiKeyAuthStrategy", "AuthStrategyFactory", "AuthType"]# MIT License
#
# Copyright (c) 2026 ByteDance
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
from abc import ABC, abstractmethod
from functools import cache
from enum import Enum
class AuthType(Enum):
AK_SK = "ak_sk"
API_KEY = "api_key"
class AuthStrategy(ABC):
"""鉴权策略接口 (Strategy Pattern)"""
@property
@abstractmethod
def strategy(self) -> AuthType:
"""获取当前使用的鉴权策略类型"""
pass
class AkSkAuthStrategy(AuthStrategy):
"""AK/SK 鉴权策略"""
@property
def strategy(self) -> AuthType:
return AuthType.AK_SK
def __init__(self):
self.ak = os.getenv("ACCESS_KEY_ID")
self.sk = os.getenv("SECRET_ACCESS_KEY")
if not self.ak or not self.sk:
raise ValueError("AK/SK未提供,且环境变量中未找到 ACCESS_KEY_ID/SECRET_ACCESS_KEY")
class ApiKeyAuthStrategy(AuthStrategy):
"""API Key 鉴权策略"""
@property
def strategy(self) -> AuthType:
return AuthType.API_KEY
def __init__(self):
self.api_key = os.getenv("ARK_SKILL_API_KEY")
self.base_url = os.getenv("ARK_SKILL_API_BASE")
if not self.api_key or not self.base_url:
raise ValueError("API Key/Base URL 未提供,且环境变量中未找到 ARK_SKILL_API_KEY/ARK_SKILL_API_BASE")
class AuthStrategyFactory:
"""鉴权策略工厂 (Factory Pattern)"""
@staticmethod
@cache
def create() -> AuthStrategy:
if os.getenv("ARK_SKILL_API_BASE") and os.getenv("ARK_SKILL_API_KEY"):
return ApiKeyAuthStrategy()
if os.getenv("ACCESS_KEY_ID") and os.getenv("SECRET_ACCESS_KEY"):
return AkSkAuthStrategy()
raise Exception("鉴权凭证未配置(缺少 AK/SK 或 Token)")import os
import asyncio
import aiohttp
import logging
from abc import ABC, abstractmethod
from typing import List, Callable, Optional
class FilenameGenerator(ABC):
"""
文件名生成策略接口(策略模式)
"""
@abstractmethod
def generate(self, url: str) -> str:
"""
根据URL生成文件名
Args:
url: 文件URL
Returns:
生成的文件名
"""
pass
def modify(self, file_path: str) -> str:
"""
修改文件路径(模板方法)
默认直接返回原始文件路径,子类可以重写此方法实现文件修改逻辑
Args:
file_path: 原始文件路径
Returns:
修改后的文件路径
"""
return file_path
class DefaultFilenameGenerator(FilenameGenerator):
"""
默认文件名生成策略:从URL提取文件名
"""
def generate(self, url: str) -> str:
filename = os.path.basename(url).split('?')[0]
if not filename or filename == '.':
filename = f"download_{hash(url) % 10000}.tmp"
return filename
class MagicFilenameGenerator(FilenameGenerator):
"""
魔法文件名生成策略:根据文件内容生成文件名
"""
# MIME类型到文件扩展名的映射
MIME_TYPE_MAP = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/bmp': '.bmp',
'image/tiff': '.tiff',
'image/svg+xml': '.svg',
'video/mp4': '.mp4',
'video/mov': '.mov',
'video/avi': '.avi',
'video/mkv': '.mkv',
'video/flv': '.flv',
'video/webm': '.webm',
'video/wmv': '.wmv',
'video/mpeg': '.mpeg',
'application/pdf': '.pdf',
'application/json': '.json',
'text/plain': '.txt',
'application/octet-stream': '.bin'
}
def generate(self, url: str) -> str:
filename = os.path.basename(url).split('?')[0]
if not filename or filename == '.':
filename = f"download_{hash(url) % 10000}"
return filename
def modify(self, file_path: str) -> str:
"""
修改文件路径:使用magic库检测文件类型并添加正确的文件后缀
Args:
file_path: 原始文件路径
Returns:
添加正确后缀后的文件路径
"""
import magic
# 检查文件是否存在
if not os.path.exists(file_path):
return file_path
# 使用magic库检测文件类型
with open(file_path, 'rb') as f:
file_data = f.read(2048)
mime_type = magic.from_buffer(file_data, mime=True)
# 根据MIME类型获取扩展名
ext = self.MIME_TYPE_MAP.get(mime_type, '.tmp')
new_file_path = f"{file_path}{ext}"
os.rename(file_path, new_file_path)
return new_file_path
class SequentialFilenameGenerator(FilenameGenerator):
"""
顺序文件名生成策略:按下载顺序生成文件名
"""
def __init__(self, prefix: str = "file"):
self.prefix = prefix
self.counter = 0
def generate(self, url: str) -> str:
self.counter += 1
ext = os.path.splitext(url.split('?')[0])[1] or ".tmp"
return f"{self.prefix}_{self.counter}{ext}"
class DownloadResult:
"""
下载结果数据类
"""
def __init__(self, url: str, success: bool, file_path: Optional[str] = None, error: Optional[str] = None):
self.url = url
self.success = success
self.file_path = file_path
self.error = error
class BaseDownloader(ABC):
"""
下载器抽象基类(模板方法模式)
"""
def __init__(self, output: str, filename_generator: Optional[FilenameGenerator] = None):
"""
初始化下载器
Args:
output: 输出目录路径
filename_generator: 文件名生成策略,默认为DefaultFilenameGenerator
"""
self.output = output
self.filename_generator = filename_generator or DefaultFilenameGenerator()
self._ensure_output_dir()
def _ensure_output_dir(self):
"""确保输出目录存在"""
os.makedirs(self.output, exist_ok=True)
@abstractmethod
def download(self, urls: List[str]) -> int:
"""
同步下载接口
Args:
urls: URL数组
Returns:
下载成功的文件数量
"""
pass
class ParallelDownloader(BaseDownloader):
"""
并行异步下载器(组合模式 + 策略模式)
"""
def __init__(
self,
output: str,
max_concurrent: int = 5,
timeout: int = 30,
filename_generator: Optional[FilenameGenerator] = None,
on_progress: Optional[Callable[[int, int], None]] = None
):
"""
初始化并行下载器
Args:
output: 输出目录路径
max_concurrent: 最大并发下载数,默认为5
timeout: 单个请求超时时间(秒),默认为30
filename_generator: 文件名生成策略
on_progress: 进度回调函数 (current, total)
"""
super().__init__(output, filename_generator)
self.max_concurrent = max_concurrent
self.timeout = timeout
self.on_progress = on_progress
self._downloaded_count = 0
self._total_count = 0
async def _download_single(self, session: aiohttp.ClientSession, url: str) -> DownloadResult:
"""
下载单个文件(私有方法)
Args:
session: aiohttp客户端会话
url: 文件URL
Returns:
下载结果
"""
try:
timeout = aiohttp.ClientTimeout(total=self.timeout)
async with session.get(url, timeout=timeout) as response:
if response.status != 200:
return DownloadResult(
url=url,
success=False,
error=f"HTTP {response.status}"
)
filename = self.filename_generator.generate(url)
file_path = os.path.join(self.output, filename)
with open(file_path, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
f.write(chunk)
file_path = self.filename_generator.modify(file_path)
logging.info(f"下载成功: {url} -> {file_path}")
return DownloadResult(url=url, success=True, file_path=file_path)
except Exception as e:
logging.error(f"下载失败 {url}: {str(e)}")
return DownloadResult(url=url, success=False, error=str(e))
async def _download_with_semaphore(self, session: aiohttp.ClientSession, url: str, semaphore: asyncio.Semaphore) -> DownloadResult:
"""
使用信号量控制并发的下载任务
Args:
session: aiohttp客户端会话
url: 文件URL
semaphore: 信号量对象
Returns:
下载结果
"""
async with semaphore:
result = await self._download_single(session, url)
self._downloaded_count += 1
if self.on_progress:
self.on_progress(self._downloaded_count, self._total_count)
return result
async def _async_download(self, urls: List[str]) -> int:
"""
异步下载核心方法
Args:
urls: URL数组
Returns:
下载成功的文件数量
"""
if not urls:
logging.warning("URL列表为空")
return 0
self._downloaded_count = 0
self._total_count = len(urls)
semaphore = asyncio.Semaphore(self.max_concurrent)
async with aiohttp.ClientSession() as session:
tasks = [
self._download_with_semaphore(session, url, semaphore)
for url in urls
]
results = await asyncio.gather(*tasks)
success_count = sum(1 for r in results if r.success)
logging.info(f"下载完成: {success_count}/{len(urls)} 成功")
return success_count
def download(self, urls: List[str]) -> int:
"""
同步下载接口(模板方法)
Args:
urls: URL数组
Returns:
下载成功的文件数量
"""
return asyncio.run(self._async_download(urls))# MIT License
#
# Copyright (c) 2026 ByteDance
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import hashlib
import hmac
import zlib
class HashUtils:
"""哈希计算工具"""
@staticmethod
def hmac_sha256(key: bytes, content: str) -> bytes:
h = hmac.new(key, content.encode("utf-8"), hashlib.sha256)
return h.digest()
@staticmethod
def hash_sha256(data: bytes) -> bytes:
h = hashlib.sha256()
h.update(data)
return h.digest()
@staticmethod
def file_hash(file_path: str):
file_md5_obj = hashlib.md5()
file_crc32 = 0
file_size = 0
with open(file_path, "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)
return file_md5_obj.hexdigest(), file_crc32 & 0xFFFFFFFF, file_size# MIT License
#
# Copyright (c) 2026 ByteDance
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from pydantic import BaseModel
class Matriel(BaseModel):
id: str
type: str
url: str
size: int
width: int
height: int
class ImageMatriel(Matriel):
pass
class VideoMatriel(Matriel):
duration: float
closest_ratio: str = ""# MIT License
#
# Copyright (c) 2026 ByteDance
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import click
from core import Result
from core.api.iccp.service import IccpService
# 查询&注册免费的Ark Claw 套餐
@click.command()
def main() -> None:
"""查询&注册免费的Ark Claw 套餐"""
try:
iccp_service = IccpService()
resp = iccp_service.post("RegisterArkClawCombo", b"")
click.echo(resp)
except Exception as e:
click.echo(Result(code="-1", message=str(e)), err=True)
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
opencv-python-headless>=4.13.0.92
aiohttp>=3.9.1