
Chanjing Video Compose
- 51 installs
- 18 repo stars
- Updated March 28, 2026
- chanjing-ai/chan-skills
Creates digital-human videos from text or audio via Chanjing video-synthesis APIs, with optional background upload and polling.
About
Calls Chanjing video-synthesis APIs to list avatars, drive a digital human from text or audio, poll tasks, and optionally download the finished video. A developer uses it to compose a digital-human video from a script or audio track.
- Text- or audio-driven digital-human video synthesis via Open API
- Lists public/custom avatars, uploads material, polls, optional download
Chanjing Video Compose by the numbers
- 51 all-time installs (skills.sh)
- Ranked #891 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/chanjing-ai/chan-skills --skill chanjing-video-composeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 51 |
|---|---|
| repo stars | ★ 18 |
| Last updated | March 28, 2026 |
| Repository | chanjing-ai/chan-skills ↗ |
What it does
Creates digital-human videos from text or audio via Chanjing video-synthesis APIs, with optional background upload and polling.
Files
Chanjing Video Compose
功能说明
调用蝉镜视频合成 Open API:列举公共/定制形象、上传素材、文本或音频驱动数字人、轮询任务;用户明确要求时用脚本下载成片。本 skill 脚本不依赖 ffmpeg/ffprobe(与一键成片编排不同)。凭据与权限边界见同目录 `manifest.yaml`。
运行依赖
- python3 与同仓库
scripts/*.py - 无 ffmpeg/ffprobe 门控
环境变量与机器可读声明
- 环境变量键名与说明:`manifest.yaml`(
environment段)及本文 - 变量、凭据模型、合规 `permissions`、`clientPermissions`、`agentPolicy`:`manifest.yaml`
使用命令
- ClawHub(slug 以注册表为准):
clawhub run chanjing-video-compose - 本仓库:
python skills/chanjing-video-compose/scripts/create_task.py …(见 Standard Workflow)
---
登记与审稿(单一事实来源)
凭据、primaryEnv 省略、可选 env、无 ffmpeg 门控(本 skill 独立使用场景)等:以 `manifest.yaml` 为准。本篇从 When to Use 起写流程与 API 说明。
When to Use This Skill
当用户要做这些事时使用本 Skill:
- 创建数字人视频合成任务
- 用文本驱动数字人出镜
- 用本地音频驱动数字人视频
- 查询公共数字人或定制数字人形象
- 轮询视频合成结果
- 在用户明确要求时下载最终视频到本地
如果需求更接近“上传一段真人视频做对口型驱动”,优先使用 chanjing-avatar,不要混用。
Preconditions
执行本 Skill 前,必须先通过 chanjing-credentials-guard 完成 AK/SK 与 Token 校验。
本 Skill 与 guard 共用:
~/.chanjing/credentials.jsonhttps://open-api.chanjing.cc
无凭证时,脚本会自动打开蝉镜登录页,并提示配置命令。
审阅与安全(凭据与边界)
与 Purpose / Credentials / Persistence 相关的逐项说明见 `manifest.yaml`。以下仅 SKILL 正文补充:
- `create_task.py --callback`:若传入 URL,蝉镜可能向该端点推送任务结果;须自行评估信任与可达性。
Standard Workflow
1. 先让用户明确选择数字人来源:common(公共数字人)或 customised(定制数字人) 2. 调用 list_figures.py --source <common|customised>(建议 --json,公共源可加大 --page-size 或翻页)获取可用形象;在候选内对比 name、各 figure 的 type 与分辨率、audio_man_id、audio_name(若有)与任务人设后再选定 person.id。禁止未比较就默认列表最前几项。 3. 如果选择公共数字人,还要再确认 figure_type(与所选 figures[].type 一致),例如 sit_body / whole_body / circle_view。无用户特殊要求时,默认优先年轻、有活力的形象(名称/audio_name 偏青年、学生、元气等);题材需要成熟或中老年气质时再改选。 4. 若使用文本驱动,确定 audio_man_id 5. 在创建任务前,必须明确询问用户字幕偏好:show(保留字幕)或 hide(隐藏字幕) 6. 如果用户选择 show 但没有提出自定义样式或位置需求,直接使用官方文档推荐默认值;只有在用户明确想调整字幕位置或样式时,才继续追问 subtitle_config 参数 7. 若用户要定制字幕位置,说明坐标以左上角为原点,再补充 subtitle_config 相关参数 8. 若使用本地音频或背景图,先调用 upload_file.py 获取 file_id 9. 调用 create_task.py 创建视频合成任务,得到 video_id 10. 调用 poll_task.py 轮询直到成功,得到 video_url 11. 只有在用户明确要求保存到本地时,才调用 download_result.py
Covered APIs
本 Skill 当前覆盖:
GET /open/v1/list_common_dpPOST /open/v1/list_customised_personPOST /open/v1/create_videoGET /open/v1/videoGET /open/v1/common/create_upload_urlGET /open/v1/common/file_detail
Scripts
脚本目录:
skills/chanjing-video-compose/scripts/
| 脚本 | 说明 |
|---|---|
_auth.py | 读取凭证、获取或刷新 access_token |
list_figures.py | 按 `--source common |
upload_file.py | 上传音频或背景素材,轮询到文件可用后输出 file_id |
create_task.py | 创建视频合成任务;使用公共数字人时可补充 --figure-type ...,字幕支持 `--subtitle show |
poll_task.py | 轮询视频详情直到完成,默认输出 video_url |
download_result.py | 下载最终视频到 outputs/video-compose/ |
Usage Examples
示例 1:公共数字人文本驱动
# 1. 先列公共数字人
python skills/chanjing-video-compose/scripts/list_figures.py --source common
# 2. 用公共数字人创建文本驱动视频
VIDEO_ID=$(python skills/chanjing-video-compose/scripts/create_task.py \
--person-id "C-ef91f3a6db3144ffb5d6c581ff13c7ec" \
--figure-type "sit_body" \
--audio-man "C-0ae461135d8a4eb2b59c853162ea9848" \
--subtitle "show" \
--subtitle-x 31 \
--subtitle-y 1521 \
--subtitle-width 1000 \
--subtitle-height 200 \
--subtitle-font-size 64 \
--subtitle-stroke-width 7 \
--text "你好,这是一个蝉镜视频合成测试。")
# 3. 轮询到完成,拿到 video_url
python skills/chanjing-video-compose/scripts/poll_task.py --id "$VIDEO_ID"示例 2:定制数字人上传本地音频驱动
python skills/chanjing-video-compose/scripts/list_figures.py --source customised
AUDIO_FILE_ID=$(python skills/chanjing-video-compose/scripts/upload_file.py \
--service make_video_audio \
--file ./input.wav)
VIDEO_ID=$(python skills/chanjing-video-compose/scripts/create_task.py \
--person-id "C-ef91f3a6db3144ffb5d6c581ff13c7ec" \
--subtitle "hide" \
--audio-file-id "$AUDIO_FILE_ID")
python skills/chanjing-video-compose/scripts/poll_task.py --id "$VIDEO_ID"示例 3:显式下载最终视频
python skills/chanjing-video-compose/scripts/download_result.py \
--url "https://example.com/output.mp4"Download Rule
下载是显式动作,不是默认动作:
poll_task.py成功后应先返回video_url- 不要自动下载结果文件
- 只有当用户明确表达“下载到本地”“保存到 outputs”“帮我落盘”时,才执行
download_result.py
Figure Selection Rule
选择数字人时遵循这条规则:
- 如果用户要用平台已有人物库,先走公共数字人:
list_figures.py --source common - 如果用户要用自己训练或上传生成的人物,先走定制数字人:
list_figures.py --source customised - 使用公共数字人创建视频时,可按所选形态传
--figure-type <type> - 使用定制数字人时,不需要
figure_type
Subtitle Rule
字幕遵循这条规则:
- 不要默认假设用户要字幕或不要字幕
- 创建任务前,必须先明确询问用户选择:
show或hide - 若由 `chanjing-one-click-video-creation` 的 `run_render.py` 调用
create_task.py,以当次 `workflow.json` 根级 `subtitle_required` 为准(默认 false →--subtitle hide;true →show及推荐样式),无需为该一键成片路径再单独追问字幕开关,除非用户在需求里明确要求改字幕策略 - 用户选择保留字幕时,调用
create_task.py --subtitle show - 若用户未指定字幕位置或样式,直接使用官方推荐默认值;
create_task.py在未传--subtitle-color时默认白字color=#FFFFFF:1080p 为x=31 y=1521 width=1000 height=200 font_size=64 stroke_width=7 asr_type=0;4K 画布为x=80 y=2840 width=2000 height=1000 font_size=150 stroke_width=7 asr_type=0(两组均含color=#FFFFFF) - 用户选择隐藏字幕时,调用
create_task.py --subtitle hide或兼容旧用法--hide-subtitle - 若用户要求调整字幕位置或样式,可继续传
--subtitle-x/--subtitle-y/--subtitle-width/--subtitle-height/--subtitle-font-size/--subtitle-color/--subtitle-stroke-color/--subtitle-stroke-width/--subtitle-font-id/--subtitle-asr-type - 坐标基于左上角原点;字幕区域不能超出
screen_width/screen_height - 如果用户只说“要字幕”但没指定位置,不必再追问具体数值;除非用户明确要调位置,否则直接走默认值
Output Convention
默认本地输出目录:
outputs/video-compose/
Additional Resources
更多接口细节见:
skills/chanjing-video-compose/reference.mdskills/chanjing-video-compose/examples.md
Examples
Natural Language Triggers
这些说法通常应该触发本 skill:
- “帮我做一个蝉镜数字人视频”
- “用这段文案生成一个数字人口播视频”
- “先列一下公共数字人”
- “先列一下我自己的定制数字人”
- “把这段 wav 上传后做成数字人视频”
- “帮我轮询视频合成任务状态”
- “把生成好的视频下载到本地”
Minimal CLI Flows
1. 公共数字人文本驱动
python skills/chanjing-video-compose/scripts/list_figures.py --source common
VIDEO_ID=$(python skills/chanjing-video-compose/scripts/create_task.py \
--person-id "C-ef91f3a6db3144ffb5d6c581ff13c7ec" \
--figure-type "sit_body" \
--audio-man "C-0ae461135d8a4eb2b59c853162ea9848" \
--subtitle "show" \
--text "你好,这是一个蝉镜视频合成测试。")
python skills/chanjing-video-compose/scripts/poll_task.py --id "$VIDEO_ID"说明:
- 仅传
--subtitle show时,脚本会自动补官方推荐字幕参数 - 如需自定义位置或样式,再继续补充
--subtitle-x/--subtitle-y/--subtitle-width/--subtitle-height/--subtitle-font-size等参数
2. 公共数字人文本驱动,自定义字幕位置
VIDEO_ID=$(python skills/chanjing-video-compose/scripts/create_task.py \
--person-id "C-ef91f3a6db3144ffb5d6c581ff13c7ec" \
--figure-type "sit_body" \
--audio-man "C-0ae461135d8a4eb2b59c853162ea9848" \
--subtitle "show" \
--subtitle-x 31 \
--subtitle-y 1521 \
--subtitle-width 1000 \
--subtitle-height 200 \
--subtitle-font-size 64 \
--subtitle-stroke-width 7 \
--text "你好,这是一个蝉镜视频合成测试。")
python skills/chanjing-video-compose/scripts/poll_task.py --id "$VIDEO_ID"3. 定制数字人本地音频驱动
python skills/chanjing-video-compose/scripts/list_figures.py --source customised
AUDIO_FILE_ID=$(python skills/chanjing-video-compose/scripts/upload_file.py \
--service make_video_audio \
--file ./demo.wav)
VIDEO_ID=$(python skills/chanjing-video-compose/scripts/create_task.py \
--person-id "C-ef91f3a6db3144ffb5d6c581ff13c7ec" \
--audio-file-id "$AUDIO_FILE_ID")
python skills/chanjing-video-compose/scripts/poll_task.py --id "$VIDEO_ID"4. 带背景图
BG_FILE_ID=$(python skills/chanjing-video-compose/scripts/upload_file.py \
--service make_video_background \
--file ./background.png)
VIDEO_ID=$(python skills/chanjing-video-compose/scripts/create_task.py \
--person-id "C-ef91f3a6db3144ffb5d6c581ff13c7ec" \
--figure-type "whole_body" \
--audio-man "C-0ae461135d8a4eb2b59c853162ea9848" \
--text "欢迎来到我的频道。" \
--bg-file-id "$BG_FILE_ID")5. 显式下载
python skills/chanjing-video-compose/scripts/download_result.py \
--url "https://example.com/output.mp4"Expected Outputs
list_figures.py默认输出表格,便于挑选person.id,公共数字人还会显示figure_typeupload_file.py输出file_idcreate_task.py输出video_idpoll_task.py默认输出video_urldownload_result.py输出本地文件路径
# 合规:根目录 合规规则.md §1–§2
name: chanjing-video-compose
version: 0.1.0
vendor: chanjing
runtime:
interpreter: python3
dependencies: []
env:
required: []
optional:
- CHANJING_OPENAPI_CREDENTIALS_DIR
- CHANJING_OPENAPI_BASE_URL
permissions:
network_mode: allowlist
allowed_hosts:
- open-api.chanjing.cc
- www.chanjing.cc
filesystem:
read_roots:
- "${WORKSPACE_ROOT}"
- "${SKILL_DIR}"
- "${CHANJING_OPENAPI_CREDENTIALS_DIR}"
write_roots:
- "${WORKSPACE_ROOT}"
- "${CHANJING_OPENAPI_CREDENTIALS_DIR}"
allowed_commands:
- python3
schemaVersion: 1
skill:
id: chanjing-video-compose
author: chan-skills
category: 媒体处理
tags:
- 视频合成
- 数字人
- ChanjingAPI
- 蝉镜
summary: >-
从文本或音频合成数字人视频;可选背景上传、轮询;可按需下载成片。
skillDoc: SKILL.md
siblingSkills:
- chanjing-credentials-guard
environment:
variables:
- name: CHANJING_OPENAPI_CREDENTIALS_DIR
required: false
description: 存放 credentials.json 的目录(兼容 CHANJING_CONFIG_DIR),默认 ~/.chanjing
- name: CHANJING_OPENAPI_BASE_URL
required: false
description: Open API 基址(兼容 CHANJING_API_BASE),默认 https://open-api.chanjing.cc
credentials:
model: credentials_json
defaultPath: "~/.chanjing/credentials.json"
directoryEnv: CHANJING_OPENAPI_CREDENTIALS_DIR
fileName: credentials.json
sensitiveFields:
- app_id
- secret_key
- access_token
- expire_in
persistAccessTokenOnDisk: true
primaryEnvIntentionallyOmitted: true
doNotCommitToVcs:
- credentials.json
clientPermissions:
network:
httpsOutbound: true
documentedHosts:
- open-api.chanjing.cc
filesystem:
read:
- "${CHANJING_OPENAPI_CREDENTIALS_DIR or CHANJING_CONFIG_DIR or ~/.chanjing}/credentials.json"
- user_provided_background_media_paths
write:
- "${CHANJING_OPENAPI_CREDENTIALS_DIR or CHANJING_CONFIG_DIR or ~/.chanjing}/credentials.json"
browser:
mayOpenForAuth: true
documentedHosts:
- www.chanjing.cc
subprocess:
allowedPatterns:
- python3
userContent:
mayDownloadFromApiResponseUrls: true
optionalCallbackUrlForTaskResults: true
metadata:
openclaw:
homepage: https://doc.chanjing.cc
agentPolicy:
alwaysSkill: false
modifiesOtherSkillsOrGlobalAgent: false
Reference
Covered APIs
本 skill 当前覆盖这些接口:
GET /open/v1/list_common_dpPOST /open/v1/list_customised_personPOST /open/v1/create_videoGET /open/v1/videoGET /open/v1/common/create_upload_urlGET /open/v1/common/file_detail
Figure List Notes
定制数字人接口
POST /open/v1/list_customised_person典型用途:
- 获取
person.id - 获取默认可复用的
audio_man_id - 查看是否支持
support_4k - 获取
preview_url便于人工选择形象
脚本默认关注这些字段:
idnameaudio_man_idsupport_4kpreview_url
公共数字人接口
GET /open/v1/list_common_dp?page=<page>&size=<size>典型用途:
- 获取公共数字人
person.id - 获取该人物可用的
figures[].type - 获取默认可复用的
audio_man_id - 获取
figures[].preview_video_url便于人工选择形象
脚本默认关注这些字段:
idnamefigures[].typefigures[].widthfigures[].heightaudio_man_idaudio_namefigures[].preview_video_url
Create Task Notes
接口:
POST /open/v1/create_video这是异步任务接口,响应 data 即视频任务 id,需要继续调用详情接口轮询。
Minimum body for TTS mode
{
"person": {
"id": "C-figure-id",
"x": 0,
"y": 0,
"width": 1080,
"height": 1920
},
"audio": {
"type": "tts",
"volume": 100,
"language": "cn",
"tts": {
"text": ["你好,这是一个测试。"],
"speed": 1,
"audio_man": "C-audio-man-id",
"pitch": 1
}
},
"bg_color": "#EDEDED",
"screen_width": 1080,
"screen_height": 1920
}Minimum body for audio mode
{
"person": {
"id": "C-figure-id",
"x": 0,
"y": 0,
"width": 1080,
"height": 1920
},
"audio": {
"type": "audio",
"file_id": "uploaded-audio-file-id",
"volume": 100,
"language": "cn"
},
"bg_color": "#EDEDED",
"screen_width": 1080,
"screen_height": 1920
}Common request fields
person.id: 形象 id,来自list_common_dp或list_customised_personperson.figure_type: 公共数字人形态,如whole_body/sit_body/circle_view;使用公共数字人时必传audio.type:tts或audioaudio.tts.text: 文本数组,建议把所有文本放进一个字符串audio.tts.audio_man: 声音 id,优先使用该形象返回的audio_man_idaudio.file_id: 本地上传音频的 file idaudio.wav_url: 远端音频链接bg.file_id: 背景素材 file idbg.src_url: 背景图片地址drive_mode:random表示随机帧动作;不传表示正常顺序驱动backway: 人物素材播放顺序,1正放,2倒放is_rgba_mode: 是否生成四通道 webmmodel:0基础版,1高质版resolution_rate:0为 1080p,1为 4Ksubtitle_config.show: 是否显示字幕subtitle_config.x: 字幕区域起始 x 坐标,默认推荐31(4K 推荐80)subtitle_config.y: 字幕区域起始 y 坐标,默认推荐1521(4K 推荐2840)subtitle_config.width: 字幕显示范围宽度,默认推荐1000(4K 推荐2000)subtitle_config.height: 字幕显示范围高度,默认推荐200(4K 推荐1000)subtitle_config.font_size: 字幕字号,默认推荐64(4K 推荐150)subtitle_config.color: 字幕颜色,格式#RRGGBBsubtitle_config.stroke_color: 字幕描边颜色,格式#RRGGBBsubtitle_config.stroke_width: 字幕描边宽度,推荐7subtitle_config.font_id: 字幕字体 IDsubtitle_config.asr_type: 字幕时间戳来源,0自动生成,1用户输入callback: 任务完成回调 URL
脚本约定:
create_task.py --subtitle show会传subtitle_config.show=true- 若未额外传字幕位置和样式参数,
create_task.py --subtitle show会自动补齐官方推荐默认值(含白字color=#FFFFFF):1080p 为31/1521/1000/200/64/#FFFFFF/7/0,4K 画布为80/2840/2000/1000/150/#FFFFFF/7/0 create_task.py --subtitle hide会传subtitle_config.show=falsecreate_task.py --hide-subtitle兼容旧用法,也会传subtitle_config.show=falsecreate_task.py支持通过--subtitle-x/--subtitle-y/--subtitle-width/--subtitle-height/--subtitle-font-size/--subtitle-color/--subtitle-stroke-color/--subtitle-stroke-width/--subtitle-font-id/--subtitle-asr-type覆盖默认字幕配置中的任意字段- 若用户只确认“显示字幕”而未指定位置,代理应直接使用默认值;若用户要求“字幕更高一点”“靠左一点”等,再结合左上角原点规则追问具体坐标或给出建议值
Constraints and caveats
- 文本长度应小于 4000 字符
- 音频驱动目前适合 wav / mp3 / m4a;如需字幕,建议上传 8000 Hz 或 16000 Hz 单声道音频
- 背景图仅支持
jpg/png - 使用公共数字人时,先从
figures[]中选定具体type,再把对应的宽高映射到person.width/person.height - 开启
resolution_rate=1时,最好先确认数字人support_4k=true - 字幕坐标以左上角为原点;若传
subtitle_config.x/y/width/height,应确保字幕区域不超出屏幕范围 - 下载不应由创建或轮询脚本自动触发
Poll Detail Notes
接口:
GET /open/v1/video?id=<video_id>轮询关注字段:
idstatusprogressmsgvideo_urlsubtitle_data_urlpreview_urlduration
状态流转:
10: 生成中,继续轮询30: 成功,返回video_url4X: 参数异常,视为失败5X: 服务异常,视为失败
File Upload Notes
接口流程:
1. GET /open/v1/common/create_upload_url?service=<service>&name=<filename> 2. 用返回的 sign_url 执行 PUT 3. GET /open/v1/common/file_detail?id=<file_id> 直到文件可用
视频合成常用 service:
make_video_audiomake_video_background
文件就绪状态:
status = 1: 文件可用
失败状态:
status = 98: 内容安全检测失败status = 99: 文件标记删除status = 100: 文件已清理
Script Mapping
| 脚本 | 对应接口 |
|---|---|
list_figures.py | GET /open/v1/list_common_dp 或 POST /open/v1/list_customised_person |
upload_file.py | GET /open/v1/common/create_upload_url + PUT sign_url + GET /open/v1/common/file_detail |
create_task.py | POST /open/v1/create_video |
poll_task.py | GET /open/v1/video |
download_result.py | 下载 video_url 到本地 |
Download Rule
download_result.py是显式动作,不应在poll_task.py成功后自动执行- 优先先把
video_url返回给用户 - 只有在用户确认下载时,才保存到
outputs/video-compose/
# 鉴权:与 chanjing-credentials-guard 使用同一配置文件(CONFIG_DIR/credentials.json)
# 无 AK/SK 时执行 open_login_page.py 打开注册/登录页
import json
import os
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
_DEFAULT_OPENAPI_BASE = "https://open-api.chanjing.cc"
def credentials_config_dir() -> Path:
raw = os.environ.get("CHANJING_OPENAPI_CREDENTIALS_DIR") or os.environ.get("CHANJING_CONFIG_DIR")
return Path(raw).expanduser() if raw else Path.home() / ".chanjing"
def openapi_base_url() -> str:
return (
os.environ.get("CHANJING_OPENAPI_BASE_URL")
or os.environ.get("CHANJING_API_BASE")
or _DEFAULT_OPENAPI_BASE
).rstrip("/")
CONFIG_DIR = credentials_config_dir()
CONFIG_FILE = CONFIG_DIR / "credentials.json"
API_BASE = openapi_base_url()
BUFFER_SECONDS = 300
LOGIN_URL = "https://www.chanjing.cc/openapi/login"
NO_CREDENTIALS_MSG = """已在浏览器打开蝉镜登录/注册页。
获取秘钥后请执行:
python skills/chanjing-credentials-guard/scripts/chanjing_config.py --ak <你的app_id> --sk <你的secret_key>
设置完毕后请重新执行您之前的操作。"""
def _run_open_login_page():
"""执行 credentials-guard 的 open_login_page.py,在默认浏览器打开注册/登录页。"""
try:
skills_dir = Path(__file__).resolve().parent.parent.parent
script = skills_dir / "chanjing-credentials-guard" / "scripts" / "open_login_page.py"
if script.exists():
subprocess.run([sys.executable, str(script)], check=False, timeout=5)
else:
import webbrowser
webbrowser.open(LOGIN_URL)
except Exception:
try:
import webbrowser
webbrowser.open(LOGIN_URL)
except Exception:
pass
def read_config():
if CONFIG_FILE.exists():
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def write_config(data):
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def get_token():
"""返回 (token, None) 或 (None, error_msg)。"""
data = read_config()
app_id = (data.get("app_id") or "").strip()
secret_key = (data.get("secret_key") or "").strip()
if not app_id or not secret_key:
_run_open_login_page()
return None, NO_CREDENTIALS_MSG
now = int(time.time())
token = data.get("access_token")
expire_in = data.get("expire_in")
try:
expire_in = int(expire_in) if expire_in is not None else 0
except (ValueError, TypeError):
expire_in = 0
if token and expire_in > now + BUFFER_SECONDS:
return token, None
url = API_BASE + "/open/v1/access_token"
req = urllib.request.Request(
url,
data=json.dumps({"app_id": app_id, "secret_key": secret_key}).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read().decode("utf-8"))
except Exception as e:
return None, str(e)
if body.get("code") != 0:
return None, body.get("msg", "获取 Token 失败")
d = body.get("data", {})
new_token = d.get("access_token")
if not new_token:
return None, "API 返回无 access_token"
data["access_token"] = new_token
data["expire_in"] = d.get("expire_in")
write_config(data)
return new_token, None
#!/usr/bin/env python3
"""
创建蝉镜视频合成任务。
用法:
公共数字人文本驱动:
create_task --person-id <id> --figure-type sit_body --text "台词" --audio-man <声音id> --subtitle show
定制数字人文本驱动:
create_task --person-id <id> --text "台词" --audio-man <声音id> --subtitle hide
音频驱动:
create_task --person-id <id> --audio-file-id <file_id>
create_task --person-id <id> --wav-url https://example.com/demo.wav
输出: video_id(一行)或错误到 stderr
"""
import argparse
import json
import re
import sys
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _auth import get_token
API_BASE = (__import__("os").environ.get("CHANJING_OPENAPI_BASE_URL") or __import__("os").environ.get("CHANJING_API_BASE") or "https://open-api.chanjing.cc").rstrip("/")
def validate_hex_color(value, arg_name):
if value is None:
return
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", value):
raise ValueError(f"{arg_name} 格式不正确,应为 #RRGGBB")
def get_default_subtitle_fields(args):
# 官方文档给出了 1080p 和 4K 两组推荐值;只有在画布已放大到 4K 尺寸时才使用 4K 组。
use_4k_defaults = args.resolution_rate == 1 and args.screen_width >= 2160 and args.screen_height >= 3840
if use_4k_defaults:
return {
"x": 80,
"y": 2840,
"width": 2000,
"height": 1000,
"font_size": 150,
"color": "#FFFFFF",
"stroke_width": 7,
"asr_type": 0,
}
return {
"x": 31,
"y": 1521,
"width": 1000,
"height": 200,
"font_size": 64,
"color": "#FFFFFF",
"stroke_width": 7,
"asr_type": 0,
}
def build_subtitle_config(args):
subtitle_fields = {
"x": args.subtitle_x,
"y": args.subtitle_y,
"width": args.subtitle_width,
"height": args.subtitle_height,
"font_size": args.subtitle_font_size,
"color": args.subtitle_color,
"stroke_color": args.subtitle_stroke_color,
"stroke_width": args.subtitle_stroke_width,
"font_id": args.subtitle_font_id,
"asr_type": args.subtitle_asr_type,
}
has_style_fields = any(value is not None for value in subtitle_fields.values())
if args.hide_subtitle:
if has_style_fields:
raise ValueError("隐藏字幕时,不能同时传字幕位置或样式参数")
return {"show": False}
if args.subtitle is None:
if has_style_fields:
raise ValueError("传字幕位置或样式参数时,必须同时传 --subtitle show")
return None
config = {"show": args.subtitle == "show"}
if not config["show"]:
if has_style_fields:
raise ValueError("使用 --subtitle hide 时,不能同时传字幕位置或样式参数")
return config
validate_hex_color(args.subtitle_color, "--subtitle-color")
validate_hex_color(args.subtitle_stroke_color, "--subtitle-stroke-color")
config.update(get_default_subtitle_fields(args))
for key, value in subtitle_fields.items():
if value is not None:
config[key] = value
if config["width"] > args.screen_width:
raise ValueError("字幕宽度不能超过屏幕宽度")
if config["height"] > args.screen_height:
raise ValueError("字幕高度不能超过屏幕高度")
if config["x"] + config["width"] > args.screen_width:
raise ValueError("字幕区域超出屏幕宽度,请检查 --subtitle-x 和 --subtitle-width")
if config["y"] + config["height"] > args.screen_height:
raise ValueError("字幕区域超出屏幕高度,请检查 --subtitle-y 和 --subtitle-height")
return config
def build_body(args):
if args.audio_file_id and args.wav_url:
raise ValueError("--audio-file-id 和 --wav-url 只能二选一")
person = {
"id": args.person_id,
"x": args.person_x,
"y": args.person_y,
"width": args.person_width,
"height": args.person_height,
}
if args.figure_type:
person["figure_type"] = args.figure_type
if args.audio_file_id or args.wav_url:
audio = {
"type": "audio",
"volume": args.volume,
"language": args.language,
}
if args.audio_file_id:
audio["file_id"] = args.audio_file_id
if args.wav_url:
audio["wav_url"] = args.wav_url
else:
if not args.text or not args.audio_man:
raise ValueError("文本驱动需同时提供 --text 和 --audio-man")
if len(args.text) > 4000:
raise ValueError("文本长度不能超过 4000 字符")
audio = {
"type": "tts",
"volume": args.volume,
"language": args.language,
"tts": {
"text": [args.text],
"speed": args.speed,
"audio_man": args.audio_man,
"pitch": args.pitch,
},
}
body = {
"person": person,
"audio": audio,
"bg_color": args.bg_color,
"screen_width": args.screen_width,
"screen_height": args.screen_height,
"model": args.model,
"backway": args.backway,
"add_compliance_watermark": args.add_compliance_watermark,
"compliance_watermark_position": args.compliance_watermark_position,
"resolution_rate": args.resolution_rate,
}
if args.drive_mode:
body["drive_mode"] = args.drive_mode
if args.callback:
body["callback"] = args.callback
if args.rgba_mode:
body["is_rgba_mode"] = True
subtitle_config = build_subtitle_config(args)
if subtitle_config is not None:
body["subtitle_config"] = subtitle_config
if args.bg_file_id or args.bg_src_url:
bg = {
"x": args.bg_x,
"y": args.bg_y,
"width": args.bg_width,
"height": args.bg_height,
}
if args.bg_file_id:
bg["file_id"] = args.bg_file_id
if args.bg_src_url:
bg["src_url"] = args.bg_src_url
body["bg"] = bg
return body
def main():
parser = argparse.ArgumentParser(description="创建蝉镜视频合成任务")
parser.add_argument("--person-id", required=True, help="数字人形象 ID(来自 list_figures)")
parser.add_argument("--text", help="文本驱动时的台词")
parser.add_argument("--audio-man", help="文本驱动时的声音 ID,默认可用 list_figures 返回的 audio_man_id")
parser.add_argument("--audio-file-id", help="音频驱动文件 ID(来自 upload_file)")
parser.add_argument("--wav-url", help="音频驱动的远端音频链接")
parser.add_argument("--person-x", type=int, default=0, help="人物 x 坐标")
parser.add_argument("--person-y", type=int, default=0, help="人物 y 坐标")
parser.add_argument("--person-width", type=int, default=1080, help="人物宽度")
parser.add_argument("--person-height", type=int, default=1920, help="人物高度")
parser.add_argument("--figure-type", help="公共数字人形态,如 sit_body / whole_body")
parser.add_argument("--drive-mode", choices=["random"], help="驱动模式;不传表示正常驱动")
parser.add_argument("--backway", type=int, choices=[1, 2], default=1, help="人物素材播放顺序")
parser.add_argument("--rgba-mode", action="store_true", help="生成四通道 webm 视频")
parser.add_argument("--screen-width", type=int, default=1080, help="画布宽度")
parser.add_argument("--screen-height", type=int, default=1920, help="画布高度")
parser.add_argument("--model", type=int, choices=[0, 1], default=0, help="0 基础版,1 高质版")
parser.add_argument("--resolution-rate", type=int, choices=[0, 1], default=0, help="0 为 1080p,1 为 4K")
parser.add_argument("--speed", type=float, default=1, help="TTS 语速 0.5-2")
parser.add_argument("--pitch", type=float, default=1, help="TTS 音调")
parser.add_argument("--volume", type=int, default=100, help="音量 1-100")
parser.add_argument("--language", default="cn", help="语言类型,默认 cn")
parser.add_argument("--bg-color", default="#EDEDED", help="纯色背景,默认 #EDEDED")
parser.add_argument("--bg-file-id", help="背景素材 file_id(来自 upload_file)")
parser.add_argument("--bg-src-url", help="背景图片远端链接,仅支持 jpg/png")
parser.add_argument("--bg-x", type=int, default=0, help="背景 x 坐标")
parser.add_argument("--bg-y", type=int, default=0, help="背景 y 坐标")
parser.add_argument("--bg-width", type=int, default=1080, help="背景宽度")
parser.add_argument("--bg-height", type=int, default=1920, help="背景高度")
subtitle_group = parser.add_mutually_exclusive_group()
subtitle_group.add_argument(
"--subtitle",
choices=["show", "hide"],
help="显式设置字幕开关;show 时可继续配合字幕位置和样式参数",
)
subtitle_group.add_argument("--hide-subtitle", action="store_true", help="隐藏字幕(兼容旧用法)")
parser.add_argument("--subtitle-x", type=int, help="字幕区域起始 x 坐标,基于左上角原点")
parser.add_argument("--subtitle-y", type=int, help="字幕区域起始 y 坐标,基于左上角原点")
parser.add_argument("--subtitle-width", type=int, help="字幕区域宽度")
parser.add_argument("--subtitle-height", type=int, help="字幕区域高度")
parser.add_argument("--subtitle-font-size", type=int, help="字幕字号")
parser.add_argument("--subtitle-color", help="字幕颜色,格式 #RRGGBB")
parser.add_argument("--subtitle-stroke-color", help="字幕描边颜色,格式 #RRGGBB")
parser.add_argument("--subtitle-stroke-width", type=int, help="字幕描边宽度")
parser.add_argument("--subtitle-font-id", help="字幕字体 ID")
parser.add_argument(
"--subtitle-asr-type",
type=int,
choices=[0, 1],
help="字幕时间戳来源:0 自动生成,1 用户输入",
)
parser.add_argument("--callback", help="任务完成回调 URL")
parser.add_argument("--add-compliance-watermark", action="store_true", help="添加 AI 合规水印")
parser.add_argument(
"--compliance-watermark-position",
type=int,
choices=[0, 1, 2, 3],
default=0,
help="合规水印位置:0 左上,1 右上,2 左下,3 右下",
)
args = parser.parse_args()
try:
body = build_body(args)
except ValueError as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
token, err = get_token()
if err:
print(err, file=sys.stderr)
sys.exit(1)
req = urllib.request.Request(
f"{API_BASE}/open/v1/create_video",
data=json.dumps(body).encode("utf-8"),
headers={"access_token": token, "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
res = json.loads(resp.read().decode("utf-8"))
if res.get("code") != 0:
print(res.get("msg", res), file=sys.stderr)
sys.exit(1)
video_id = res.get("data")
if not video_id:
print("响应无 data", file=sys.stderr)
sys.exit(1)
print(video_id)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
下载视频合成结果到本地。
用法:
download_result --url https://example.com/output.mp4
download_result --url https://example.com/output.mp4 --output outputs/video-compose/demo.mp4
输出: 本地文件路径
"""
from __future__ import annotations
import argparse
import os
import sys
import urllib.parse
import urllib.request
from pathlib import Path
def infer_filename(url: str) -> str:
parsed = urllib.parse.urlparse(url)
name = Path(parsed.path).name or "video-compose.bin"
if "." not in name:
name += ".bin"
return name
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="下载蝉镜视频合成结果到本地目录")
parser.add_argument("--url", required=True, help="video_url")
parser.add_argument(
"--output",
help="输出文件路径;默认保存到 outputs/video-compose/<文件名>",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
default_dir = Path("outputs") / "video-compose"
output_path = Path(args.output) if args.output else default_dir / infer_filename(args.url)
output_path.parent.mkdir(parents=True, exist_ok=True)
req = urllib.request.Request(
args.url,
headers={"User-Agent": "chanjing-video-compose-downloader"},
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp, open(output_path, "wb") as handle:
handle.write(resp.read())
except Exception as exc:
print(f"下载失败: {exc}", file=sys.stderr)
raise SystemExit(1)
if not output_path.exists() or output_path.stat().st_size == 0:
print("下载失败: 输出文件为空", file=sys.stderr)
raise SystemExit(1)
print(os.fspath(output_path))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
列出可用于视频合成的数字人形象。
用法: list_figures.py [--source customised|common] [--page 1] [--page-size 20] [--json]
输出: 默认打印摘要表;--json 时输出完整 data
"""
import argparse
import json
import sys
import urllib.parse
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _auth import get_token
API_BASE = (__import__("os").environ.get("CHANJING_OPENAPI_BASE_URL") or __import__("os").environ.get("CHANJING_API_BASE") or "https://open-api.chanjing.cc").rstrip("/")
def fetch_customised(token, page, page_size):
body = json.dumps({"page": page, "page_size": page_size}).encode("utf-8")
req = urllib.request.Request(
f"{API_BASE}/open/v1/list_customised_person",
data=body,
headers={"access_token": token, "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def fetch_common(token, page, page_size):
params = urllib.parse.urlencode({"page": page, "size": page_size})
req = urllib.request.Request(
f"{API_BASE}/open/v1/list_common_dp?{params}",
headers={"access_token": token, "Content-Type": "application/json"},
method="GET",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def build_rows(source, items):
rows = []
if source == "customised":
for item in items:
width = item.get("width")
height = item.get("height")
size = f"{width}x{height}" if width and height else "-"
rows.append(
{
"source": "customised",
"person_id": item.get("id", ""),
"name": item.get("name", ""),
"figure_type": "-",
"size": size,
"audio_man_id": item.get("audio_man_id", ""),
"note": f"support_4k={item.get('support_4k', '')}",
"preview_url": item.get("preview_url", ""),
}
)
return rows
for item in items:
for figure in item.get("figures", []):
rows.append(
{
"source": "common",
"person_id": item.get("id", ""),
"name": item.get("name", ""),
"figure_type": figure.get("type", ""),
"size": f"{figure.get('width', '')}x{figure.get('height', '')}",
"audio_man_id": item.get("audio_man_id", ""),
"note": f"audio_name={item.get('audio_name', '')}",
"preview_url": figure.get("preview_video_url", ""),
}
)
return rows
def main():
parser = argparse.ArgumentParser(description="列出蝉镜视频合成可用数字人形象")
parser.add_argument(
"--source",
choices=["customised", "common"],
default="customised",
help="数字人来源:customised 为定制数字人,common 为公共数字人",
)
parser.add_argument("--page", type=int, default=1, help="页码")
parser.add_argument("--page-size", type=int, default=20, help="每页数量")
parser.add_argument("--json", action="store_true", help="输出完整 JSON")
args = parser.parse_args()
token, err = get_token()
if err:
print(err, file=sys.stderr)
sys.exit(1)
if args.source == "customised":
res = fetch_customised(token, args.page, args.page_size)
else:
res = fetch_common(token, args.page, args.page_size)
if res.get("code") != 0:
print(res.get("msg", res), file=sys.stderr)
sys.exit(1)
data = res.get("data", {})
if args.json:
print(json.dumps({"source": args.source, "data": data}, ensure_ascii=False, indent=2))
return
items = data.get("list", [])
page_info = data.get("page_info", {})
rows = build_rows(args.source, items)
print(f"# source={args.source} 共 {page_info.get('total_count', len(items))} 个形象 (page={args.page})")
print(
f"{'source':<10} {'person_id':<36} {'name':<14} {'figure_type':<12} "
f"{'size':<11} {'audio_man_id':<36} {'note':<24} preview_url"
)
print("-" * 220)
for row in rows:
print(
f"{row['source']:<10} "
f"{row['person_id']:<36} "
f"{row['name']:<14} "
f"{row['figure_type']:<12} "
f"{row['size']:<11} "
f"{row['audio_man_id']:<36} "
f"{row['note']:<24} "
f"{row['preview_url']}"
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
轮询视频合成任务状态,直到完成或失败。
用法: poll_task.py --id <video_id> [--interval 10] [--json]
输出: 默认成功时打印 video_url;--json 时输出完整 data
状态: 10-生成中, 30-成功, 4X 参数异常, 5X 服务异常
"""
import argparse
import json
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _auth import get_token
API_BASE = (__import__("os").environ.get("CHANJING_OPENAPI_BASE_URL") or __import__("os").environ.get("CHANJING_API_BASE") or "https://open-api.chanjing.cc").rstrip("/")
def main():
parser = argparse.ArgumentParser(description="轮询蝉镜视频合成任务直到完成")
parser.add_argument("--id", required=True, help="视频任务 ID(来自 create_task)")
parser.add_argument("--interval", type=int, default=10, help="轮询间隔秒数,默认 10")
parser.add_argument("--json", action="store_true", help="成功后输出完整 JSON data")
args = parser.parse_args()
token, err = get_token()
if err:
print(err, file=sys.stderr)
sys.exit(1)
url = f"{API_BASE}/open/v1/video?id={urllib.parse.quote(args.id)}"
while True:
req = urllib.request.Request(url, headers={"access_token": token}, method="GET")
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read().decode("utf-8"))
if body.get("code") != 0:
print(body.get("msg", body), file=sys.stderr)
sys.exit(1)
data = body.get("data", {})
status = data.get("status")
if status == 30:
if args.json:
print(json.dumps(data, ensure_ascii=False, indent=2))
return 0
video_url = data.get("video_url")
if video_url:
print(video_url)
return 0
print("任务成功但无 video_url", file=sys.stderr)
sys.exit(1)
if isinstance(status, int) and status >= 40:
err_msg = data.get("msg") or f"status={status}"
print(f"任务失败: {err_msg}", file=sys.stderr)
sys.exit(1)
time.sleep(args.interval)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
上传视频合成所需文件并轮询直到就绪,最后输出 file_id。
与 chanjing-credentials-guard 使用同一配置文件获取 Token。
用法:
upload_file --service make_video_audio --file /path/to/input.wav
upload_file --service make_video_background --file /path/to/bg.png
输出: file_id(一行)或错误到 stderr
"""
import argparse
import json
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _auth import get_token
API_BASE = (__import__("os").environ.get("CHANJING_OPENAPI_BASE_URL") or __import__("os").environ.get("CHANJING_API_BASE") or "https://open-api.chanjing.cc").rstrip("/")
FILE_READY_STATUSES = {1}
FILE_FAILED_STATUSES = {98, 99, 100}
POLL_INTERVAL_DEFAULT = 5
POLL_TIMEOUT_DEFAULT = 300
def get_file_detail(token, file_id):
url = f"{API_BASE}/open/v1/common/file_detail?id={urllib.parse.quote(file_id)}"
req = urllib.request.Request(url, headers={"access_token": token}, method="GET")
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read().decode("utf-8"))
if body.get("code") != 0:
return None, body.get("msg", "file_detail failed")
return body.get("data"), None
def poll_file_ready(token, file_id, interval=5, timeout=300):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
data, err = get_file_detail(token, file_id)
if err:
return False, err
if data is None:
return False, "no data"
status = data.get("status")
if status in FILE_READY_STATUSES:
return True, None
if status in FILE_FAILED_STATUSES:
return False, data.get("msg") or f"file status={status}"
time.sleep(interval)
return False, "poll timeout"
def main():
parser = argparse.ArgumentParser(description="上传视频合成文件并轮询直到就绪,返回 file_id")
parser.add_argument(
"--service",
required=True,
choices=["make_video_audio", "make_video_background", "ai_creation"],
help="文件用途:音频、背景素材或 AI 创作",
)
parser.add_argument("--file", required=True, help="本地文件路径")
parser.add_argument("--poll-interval", type=int, default=POLL_INTERVAL_DEFAULT, help="轮询间隔秒数")
parser.add_argument("--poll-timeout", type=int, default=POLL_TIMEOUT_DEFAULT, help="轮询超时秒数")
args = parser.parse_args()
path = Path(args.file)
if not path.is_file():
print(f"文件不存在: {path}", file=sys.stderr)
sys.exit(1)
token, err = get_token()
if err:
print(err, file=sys.stderr)
sys.exit(1)
qs = urllib.parse.urlencode({"service": args.service, "name": path.name})
url = f"{API_BASE}/open/v1/common/create_upload_url?{qs}"
req = urllib.request.Request(url, headers={"access_token": token}, method="GET")
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read().decode("utf-8"))
if body.get("code") != 0:
print(body.get("msg", body), file=sys.stderr)
sys.exit(1)
data = body.get("data", {})
sign_url = data.get("sign_url")
mime_type = data.get("mime_type", "application/octet-stream")
file_id = data.get("file_id")
if not sign_url or not file_id:
print("响应缺少 sign_url 或 file_id", file=sys.stderr)
sys.exit(1)
with open(path, "rb") as f:
content = f.read()
put_req = urllib.request.Request(
sign_url,
data=content,
headers={"Content-Type": mime_type},
method="PUT",
)
try:
with urllib.request.urlopen(put_req, timeout=120) as put_resp:
if put_resp.status not in (200, 204):
print(f"上传返回状态: {put_resp.status}", file=sys.stderr)
sys.exit(1)
except Exception as exc:
print(f"上传失败: {exc}", file=sys.stderr)
sys.exit(1)
ready, err = poll_file_ready(token, file_id, interval=args.poll_interval, timeout=args.poll_timeout)
if not ready:
print(f"文件未就绪: {err}", file=sys.stderr)
sys.exit(1)
print(file_id)
if __name__ == "__main__":
main()