588 lines
22 KiB
Markdown
588 lines
22 KiB
Markdown
# 即梦 API · Golang 对接最小参考
|
||
|
||
## 0. 全局须知(对接前必读)
|
||
|
||
- 生成任务消耗 **token**(仅超级会员限时可用);token 包独立购买,**与即梦网页端积分不通用**。
|
||
- 任务提交按能力策略**预扣** token;成功按实际用量结算(多退少补);**失败全额返还**。
|
||
- 产物链接**有效期为 24 小时**,生成后请在 24h 内下载保存,逾期失效。
|
||
- 限流:任务提交 10 qps(用户级),任务查询 20 qps(用户级)。
|
||
- 所有接口均为 `POST`,`Content-Type: application/json`。
|
||
|
||
---
|
||
|
||
## 1. 鉴权(AK/SK 签名)★必须完整实现
|
||
|
||
调用方持一对 `AccessKey(AK)` / `SecretKey(SK)`。**SK 只在创建时明文返回一次,服务端不提供二次查询**,自行妥善保存。
|
||
|
||
### 1.1 请求头(8 个字段全部必填)
|
||
|
||
| Header | 必填 | 说明 |
|
||
|---|---|---|
|
||
| `X-Agent-Access-Key` | 是 | AK,服务端签发字符串 |
|
||
| `X-Agent-Timestamp` | 是 | 请求发起 Unix 时间戳(秒)。服务端允许 ±300 秒偏差,超出视为重放 |
|
||
| `X-Agent-Nonce` | 是 | 本次随机串(推荐 UUID)。同一 AK+Nonce 在时间窗口内去重,防重放 |
|
||
| `X-Agent-Body-SHA256` | 是 | HTTP Body 的 SHA-256 摘要(**小写十六进制**);空 Body 也要传空字符串的 SHA-256 |
|
||
| `X-Agent-Signature-Method` | 是 | 固定 `HMAC-SHA256` |
|
||
| `X-Agent-Version` | 是 | 固定 `v1` |
|
||
| `X-Agent-Signature` | 是 | 签名结果,Base64URL 编码(**不含 padding**),长度 = HMAC-SHA256 输出 32 字节 |
|
||
| `Content-Type` | 是 | 固定 `application/json`(**该字段参与签名**) |
|
||
|
||
### 1.2 签名算法
|
||
|
||
按顺序拼接 8 个字段,字段间用换行符 `\n` 分隔构造 `StringToSign`,再用 SK 做 HMAC-SHA256:
|
||
|
||
```
|
||
StringToSign = METHOD + "\n"
|
||
+ PATH + "\n"
|
||
+ CANONICAL_QUERY + "\n"
|
||
+ CONTENT_TYPE + "\n"
|
||
+ SHA256(RequestBody) + "\n"
|
||
+ TIMESTAMP + "\n"
|
||
+ NONCE + "\n"
|
||
+ ACCESS_KEY
|
||
Signature = Base64URL( HMAC-SHA256(SK, StringToSign) )
|
||
```
|
||
|
||
各字段规则:
|
||
|
||
- `METHOD`:HTTP 方法全大写,本 API 均为 `POST`。
|
||
- `PATH`:请求路径含前缀,如 `/agent_openapi/v1/video/submit`。
|
||
- `CANONICAL_QUERY`:query string 按参数名 **ASCII 升序**排序,每个 key/value 分别按 **RFC3986** 做 URL Encode(空格编码为 `%20`,`~` 保留不编码),最后用 `&` 连接;无 query 时为空字符串。
|
||
- `CONTENT_TYPE`:与请求头完全一致,即 `application/json`。
|
||
- `SHA256(RequestBody)`:Body 的**小写** SHA-256 十六进制,与 `X-Agent-Body-SHA256` 头一致。
|
||
- `TIMESTAMP / NONCE / ACCESS_KEY`:与对应请求头完全一致。
|
||
|
||
### 1.3 Golang 签名实现(标准库,无第三方依赖)
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/hmac"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
host = "https://jimeng.jianying.com"
|
||
path = "/agent_openapi/v1/video/submit"
|
||
method = "POST"
|
||
contentType = "application/json"
|
||
accessKey = "your_access_key"
|
||
secretKey = "your_secret_key"
|
||
)
|
||
|
||
func main() {
|
||
body := []byte(`{"capability_key":"pippit_avatar_marketing_agent","action_key":"generate_video","run_id":"biz-avatar-005","avatar_marketing_input":{"product_name":"【高级美容护肤品拍一发三】虾青素抗皱面霜紧致修护补水保湿身体","product_image_url_list":["https://lf3-static.bytednsdoc.com/obj/eden-cn/upseh7fkuhm/product1.jpeg"],"model_image_url_list":["https://lf3-static.bytednsdoc.com/obj/eden-cn/upseh7fkuhm/model.jpeg"]}}`)
|
||
query := url.Values{}
|
||
|
||
bodySHA256 := sha256Hex(body)
|
||
canonicalQuery := canonicalQueryString(query)
|
||
timestamp := fmt.Sprintf("%d", time.Now().Unix())
|
||
nonce, err := randomNonce()
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
|
||
stringToSign := method + "\n" +
|
||
path + "\n" +
|
||
canonicalQuery + "\n" +
|
||
contentType + "\n" +
|
||
bodySHA256 + "\n" +
|
||
timestamp + "\n" +
|
||
nonce + "\n" +
|
||
accessKey
|
||
|
||
signature := hmacSHA256Base64URL(secretKey, stringToSign)
|
||
|
||
requestURL := host + path
|
||
if canonicalQuery != "" {
|
||
requestURL += "?" + canonicalQuery
|
||
}
|
||
|
||
req, err := http.NewRequest(method, requestURL, bytes.NewReader(body))
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
req.Header.Set("Content-Type", contentType)
|
||
req.Header.Set("X-Agent-Access-Key", accessKey)
|
||
req.Header.Set("X-Agent-Timestamp", timestamp)
|
||
req.Header.Set("X-Agent-Nonce", nonce)
|
||
req.Header.Set("X-Agent-Body-SHA256", bodySHA256)
|
||
req.Header.Set("X-Agent-Signature-Method", "HMAC-SHA256")
|
||
req.Header.Set("X-Agent-Version", "v1")
|
||
req.Header.Set("X-Agent-Signature", signature)
|
||
|
||
client := &http.Client{Timeout: 30 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
rspBody, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
fmt.Println("status:", resp.StatusCode)
|
||
fmt.Println(string(rspBody))
|
||
}
|
||
|
||
func canonicalQueryString(query url.Values) string {
|
||
keys := make([]string, 0, len(query))
|
||
for key := range query {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
parts := make([]string, 0)
|
||
for _, key := range keys {
|
||
values := append([]string(nil), query[key]...)
|
||
sort.Strings(values)
|
||
for _, value := range values {
|
||
parts = append(parts, urlEncode(key)+"="+urlEncode(value))
|
||
}
|
||
}
|
||
return strings.Join(parts, "&")
|
||
}
|
||
|
||
func urlEncode(value string) string {
|
||
encoded := url.QueryEscape(value)
|
||
encoded = strings.ReplaceAll(encoded, "+", "%20")
|
||
encoded = strings.ReplaceAll(encoded, "%7E", "~")
|
||
return encoded
|
||
}
|
||
|
||
func sha256Hex(data []byte) string {
|
||
sum := sha256.Sum256(data)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func hmacSHA256Base64URL(secret, payload string) string {
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
_, _ = mac.Write([]byte(payload))
|
||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
func randomNonce() (string, error) {
|
||
buf := make([]byte, 16)
|
||
_, err := rand.Read(buf)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(buf), nil
|
||
}
|
||
```
|
||
|
||
### 1.4 常见签名失败原因(报错 `10006` 时逐条排查)
|
||
|
||
- 本地时钟偏差超过 300 秒。
|
||
- `X-Agent-Body-SHA256` 不是小写十六进制。
|
||
- Base64URL 带了 `=` padding。
|
||
- 多个同名 query 参数未分别 encode 后再字典序拼接。
|
||
- 计算时用的 `Content-Type` 与实际发送的不一致。
|
||
- 同一 AK 的 nonce 在 300 秒窗口内重复。
|
||
|
||
---
|
||
|
||
## 2. 通用请求 / 响应结构
|
||
|
||
### 2.1 提交任务请求体通参
|
||
|
||
| 字段 | 类型 | 必填 | 说明 |
|
||
|---|---|---|---|
|
||
| `capability_key` | string | 是 | 能力标识,见「能力对照」 |
|
||
| `action_key` | string | 是 | 具体动作标识,须与 capability_key 匹配 |
|
||
| `run_id` | string | 否 | 业务侧幂等键。同一 AK 下重复提交同一 run_id 返回同一任务;未传由系统生成 |
|
||
| `input` | object | 是 | 能力相关业务入参,不同能力结构不同(见各能力章节) |
|
||
|
||
> 注:营销视频/剧情营销 使用字段名 `avatar_marketing_input` 而非 `input`;短剧与短片创作使用 `input`。
|
||
|
||
### 2.2 通用响应结构
|
||
|
||
```json
|
||
{ "code": "0", "message": "success", "request_id": "20260708xxxxxxxx", "data": { ... } }
|
||
```
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `code` | string/int64 | 业务错误码,`0` 表示成功;非零见「错误码」 |
|
||
| `message` | string | 错误信息或成功描述 |
|
||
| `request_id` | string | 请求追踪 ID(LogID),**排障必须提供该字段** |
|
||
| `data` | object | 业务数据,不同接口结构不同 |
|
||
|
||
提交接口 `data` 通常含:`task_id`(后续查询主键)、`run_id`、`thread_id`(短剧会话 ID,链路串联依赖)、`status`(一般为 `QUEUED`)、`capability_key`、`action_key`、`created_at`(Unix 秒)。
|
||
|
||
### 2.3 任务状态机
|
||
|
||
| status | 含义 |
|
||
|---|---|
|
||
| `CREATED` | 已创建,等待入队 |
|
||
| `QUEUED` | 已入队,等待调度 |
|
||
| `RUNNING` | Agent 任务执行中 |
|
||
| `SUCCESS` | 成功终态,从 `data` 读取产物 |
|
||
| `FAILED` | 失败终态,`err_code`/`err_msg` 给出原因 |
|
||
| `CANCELED` | 已取消终态 |
|
||
|
||
### 2.4 计费字段(query 返回 `data.usage`)
|
||
|
||
| 字段 | 说明 |
|
||
|---|---|
|
||
| `withhold_token` | 预扣 Token(执行时冻结) |
|
||
| `actual_token` | 实际结算 Token(成功终态生效) |
|
||
| `refund_token` | 返还 Token(失败或结算预扣差额返还) |
|
||
| `withhold_quota` / `actual_quota` | 短片/剧情营销额外返回:视频=预估/实际总时长,图片=张数 |
|
||
|
||
---
|
||
|
||
## 3. 接口清单
|
||
|
||
Base:`https://jimeng.jianying.com`,全部 `POST` + 鉴权头。
|
||
|
||
| 用途 | 接口 | 能力 |
|
||
|---|---|---|
|
||
| 短剧提交 | `/agent_openapi/v1/novel/submit` | `pippit_novel_agent` |
|
||
| 短剧查询 | `/agent_openapi/v1/novel/query` | `pippit_novel_agent` |
|
||
| 视频提交 | `/agent_openapi/v1/video/submit` | 带货营销 / 剧情营销 / 短片创作 |
|
||
| 视频查询 | `/agent_openapi/v1/video/query` | 带货营销 / 剧情营销 / 短片创作 |
|
||
|
||
---
|
||
|
||
## 4. 能力对照(capability_key / action_key)
|
||
|
||
| 业务能力 | capability_key | 支持的 action_key |
|
||
|---|---|---|
|
||
| 短剧/漫剧 | `pippit_novel_agent` | `script_analysis`(剧本解析)、`narration_design`(旁白改编·可选)、`character_generate`(角色生成)、`scene_generate`(场景生成)、`storyboard_design`(分镜设计)、`shot_video_generate`(分镜短片 Seedance2.0)、`shot_video_generate_fast`(Seedance2.0 fast)、`shot_video_compose`(成片合成) |
|
||
| 带货营销视频 | `pippit_avatar_marketing_agent` | `generate_video`(另有 `avatar_marketing_agent_v2` 待开放) |
|
||
| 剧情营销视频 | `pippit_story_marketing_agent` | `generate_video` |
|
||
| 短片创作 | `pippit_video_part_agent` | `generate_video` |
|
||
|
||
---
|
||
|
||
## 5. 短剧 Agent(novel,多阶段流水线)
|
||
|
||
短剧是一串串联的阶段任务,用 `thread_id`(会话)+ `asset_id`(资产)把链路串起来:
|
||
|
||
```
|
||
script_analysis ─→ narration_design(可选) ─→ character_generate / scene_generate ─→ storyboard_design ─→ shot_video_generate(_fast) ─→ shot_video_compose
|
||
```
|
||
|
||
### 5.1 提交任务 `POST /agent_openapi/v1/novel/submit`
|
||
|
||
Body:
|
||
|
||
```json
|
||
{
|
||
"capability_key": "pippit_novel_agent",
|
||
"action_key": "<各阶段动作>",
|
||
"run_id": "biz-novel-003",
|
||
"input": { ... }
|
||
}
|
||
```
|
||
|
||
`input` 字段(是否必填取决于 action_key):
|
||
|
||
| 字段 | 类型 | 必填时机 | 说明 |
|
||
|---|---|---|---|
|
||
| `thread_id` | string | 除 `script_analysis` 外均需回填 | 剧本分析返回的会话 ID |
|
||
| `asset_id` | string | 除 `script_analysis` 外均需回填 | `narration_design/character_generate/scene_generate` 传 **OverviewAssetID**(蓝图资产);`storyboard_design/shot_video_generate/shot_video_generate_fast/shot_video_compose` 传 **StoryboardAssetID**(分镜资产) |
|
||
| `visual_style` | string | 是(script_analysis) | 视觉风格,如 `"2D,国风,平涂"`、`"3D,CG动画,写实都市"`、`"真人写实,电影风格,冷色调"` |
|
||
| `video_ratio` | string | 是(script_analysis) | `"16:9"` / `"9:16"` |
|
||
| `file_url` | string | 是(script_analysis) | 剧本文件 URL,需公网可访问 |
|
||
| `file_type` | string | 是(script_analysis) | `"txt"` / `"docx"` |
|
||
| `file_name` | string | 是(script_analysis) | 剧本文件名 |
|
||
| `shot_ids` | string[] | 是(shot_video_generate / _fast) | 分镜脚本编码列表,如 `["S1","S2"]` |
|
||
| `enable_watermark` | bool | 否 | 是否加水印,默认 false |
|
||
|
||
### 5.2 查询任务 `POST /agent_openapi/v1/novel/query`
|
||
|
||
请求 Body:`task_id` 或 `run_id` **二选一**。
|
||
|
||
响应关键字段(`data`):
|
||
|
||
- `status` / `usage` / `thread_id` / `start_at` / `elapsed_seconds`:同通用结构。
|
||
- `novel_data.overview_asset_id`:蓝图资产 ID,后续角色/场景/分镜/视频链路的关键入口。
|
||
- `novel_data.resp_data`:**JSON 字符串,需再次反序列化**才能读取内部字段。
|
||
|
||
`resp_data` 二次反序列化后的关键字段(对接必须会用):
|
||
|
||
| 字段 | 说明 |
|
||
|---|---|
|
||
| `OverviewAssetID` | 蓝图资产 ID → 旁白/角色/场景阶段回填为 `asset_id` |
|
||
| `StoryboardAssetID` | 分镜资产 ID(在 `StoryboardBriefs[]` 内)→ 分镜/短片/成片阶段回填为 `asset_id` |
|
||
| `Settings.VideoRatio` / `Settings.VisualStyle` | 比例与风格(与提交一致) |
|
||
| `EpisodeAssets[]` | 每集:`EpisodeID`、`EpisodeAssetID`、`StoryboardAssetID`、`CharacterAssetIDs`、`SceneAssetIDs` |
|
||
| `CharacterAssets[]` | 角色:`CharacterID/Name`、`CharacterAssetID`、`IsMainCharacter` |
|
||
| `SceneAssets[]` | 场景:`SceneAssetID`、`EpisodeAssetIDs` |
|
||
| `ScriptAssetID` / `CoreElement` | 剧本资产与核心要素 |
|
||
|
||
> 链路取 `asset_id` 的规则:`narration_design`、`character_generate`、`scene_generate` 取 **OverviewAssetID**;`storyboard_design`、`shot_video_generate(_fast)`、`shot_video_compose` 取对应集的 **StoryboardAssetID**。
|
||
|
||
---
|
||
|
||
## 6. 视频类能力(video:带货营销 / 剧情营销 / 短片创作)
|
||
|
||
三类共用 `POST /agent_openapi/v1/video/submit` 与 `/agent_openapi/v1/video/query`,仅 `capability_key` 与入参对象不同。
|
||
|
||
### 6.1 带货营销 `pippit_avatar_marketing_agent`
|
||
|
||
Body 使用 `avatar_marketing_input`:
|
||
|
||
| 字段 | 类型 | 必填 | 说明 |
|
||
|---|---|---|---|
|
||
| `prompt` | string | 否 | 自定义营销 prompt |
|
||
| `product_name` | string | 是 | 商品名称,用于文案/素材语义理解 |
|
||
| `product_image_url_list` | array<string> | 是 | 商品图 1~3 张 |
|
||
| `model_image_url_list` | array<string> | 否 | 模特图,最多 1 张 |
|
||
| `enable_watermark` | bool | 否 | 默认无水印 |
|
||
|
||
### 6.2 剧情营销 `pippit_story_marketing_agent`
|
||
|
||
Body 使用 `avatar_marketing_input`:
|
||
|
||
| 字段 | 类型 | 必填 | 说明 |
|
||
|---|---|---|---|
|
||
| `prompt` | string | 否 | 自定义剧情营销 prompt |
|
||
| `model` | string | 否 | 默认 `seedance20_fast`;可选 `seedance25` / `seedance20` / `seedance20_fast` / `seedance20_mini` |
|
||
| `duration` | int | 否 | 秒,默认 15s;`seedance25` 最长 30s |
|
||
| `product_name` | string | 是 | 商品名称 |
|
||
| `product_image_url_list` | array<string> | 是 | 商品图 1~9 张 |
|
||
| `model_image_url_list` | array<string> | 否 | 模特图,最多 1 张 |
|
||
| `enable_watermark` | bool | 否 | 默认无水印 |
|
||
|
||
### 6.3 短片创作 `pippit_video_part_agent`
|
||
|
||
Body 使用 `input`:
|
||
|
||
| 字段 | 类型 | 必填 | 说明 |
|
||
|---|---|---|---|
|
||
| `prompt` | string | 是 | 提示词 |
|
||
| `image_url_list` | string[] | 否 | 参考图,最多 9 张,公网可访问 |
|
||
| `video_url_list` | string[] | 否 | 参考视频,最多 3 条;`seedance25` 总时长 ≤30s,其他 ≤15s |
|
||
| `audio_url_list` | string[] | 否 | 参考音频,最多 3 条;时长限制同上 |
|
||
| `model` | string | 否 | 默认 `seedance20_fast`,可选同剧情营销 |
|
||
| `duration` | int32 | 否 | ≥5s,默认 15s;`seedance25` 最大 30s,其他 15s |
|
||
| `ratio` | string | 否 | `21:9` / `16:9` / `4:3` / `1:1` / `3:4` / `9:16` |
|
||
| `resolution` | string | 否 | 默认 720P;`seedance25`:480/720/1080;`seedance20`:480/720/1080/4K;fast/mini:480/720 |
|
||
| `seed` | int32 | 否 | 随机种子,不传由下游生成 |
|
||
| `enable_watermark` | bool | 否 | 默认 false |
|
||
|
||
### 6.4 图片通用规格(所有引用图片,带货营销 ≤3 张,剧情/短片 ≤9 张)
|
||
|
||
- 格式:JPEG、PNG、WebP、BMP、TIFF、GIF、HEIC、HEIF。
|
||
- Seedance 2.0:宽高比 `(0.4, 2.5)` 不含边界;宽高各 `(300, 6000)` px 不含边界。
|
||
- Seedance 2.5:宽高比 `[0.4, 2.5]` 含边界;宽高各 `[300, 6000]` px 含边界。
|
||
- 单张 < 30 MB,URL 需公网可访问。
|
||
|
||
### 6.5 查询任务 `POST /agent_openapi/v1/video/query`
|
||
|
||
请求 Body:`task_id` 或 `run_id` **二选一**。
|
||
|
||
响应关键字段(`data`):
|
||
|
||
| 字段 | 说明 |
|
||
|---|---|
|
||
| `status` | 状态机取值 |
|
||
| `usage.*` | 计费明细(短片/剧情营销含 `withhold_quota` / `actual_quota`) |
|
||
| `video_artifact.url` | **成品视频下载 URL**(24h 有效) |
|
||
| `video_artifact.cover_url` | 封面图 |
|
||
| `video_artifact.duration` | 成片时长(秒) |
|
||
| `video_artifact.width` / `height` | 成片像素 |
|
||
| `start_at` / `elapsed_seconds` | 时间信息 |
|
||
| `err_code` / `err_msg` | 终态 `FAILED` 时才有 |
|
||
|
||
---
|
||
|
||
## 7. 错误码(对接需处理的分段与关键码)
|
||
|
||
分段:`1xxxx`=参数/鉴权/账号风控(可校对重试);`2xxxx`=业务规则(配额/审核/下游终态);`5xxxx`=系统内部(稍后重试)。
|
||
|
||
| code | 含义 | 建议 |
|
||
|---|---|---|
|
||
| `0` | 成功 | — |
|
||
| `10001` | 参数非法 | 校验请求体字段 |
|
||
| `10002` | AK 不存在 | 确认 AK,重新签发 |
|
||
| `10003` | AK 被禁用/冻结 | 联系管理员 |
|
||
| `10004` | 未授权/UserID 缺失 | 确认 AK 已开通 |
|
||
| `10005` | 未知能力/动作 | 核对 capability_key/action_key |
|
||
| `10006` | 签名校验失败 | 见 §1.4 逐项排查 |
|
||
| `10007` | 身份权限不满足 | 确认账号具备能力权限(如即梦 Ultra 会员) |
|
||
| `10008` | 请求频率超限 | 降并发(提交 10qps / 查询 20qps) |
|
||
| `10020` | 账号风控拦截 | 联系管理员,提供 request_id |
|
||
| `20001` | 任务不存在 | 核对 task_id / run_id |
|
||
| `20002` | Token 余额不足/预扣失败 | 充值或稍后重试 |
|
||
| `20004` | 计费失败 | 稍后重试,持续失败给 request_id |
|
||
| `20010`~`20016` | 输入/输出音频/文本/图片/视频审核未通过 | 更换合规素材 / 调整 prompt,可重试 |
|
||
| `20020` | 下游任务执行失败 | 确定性终态;重试仍失败给 request_id |
|
||
| `20021` | 下游任务已取消 | 确定性终态 |
|
||
| `50000` | 系统内部错误 | 稍后重试 |
|
||
| `50001` | 下游任务轮询超时 | 稍后重试 |
|
||
|
||
---
|
||
|
||
## 8. Golang 对接骨架(提交 → 轮询)
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"time"
|
||
|
||
"yourmod/signer" // §1.3 的签名包
|
||
)
|
||
|
||
const (
|
||
submitVideoPath = "/agent_openapi/v1/video/submit"
|
||
queryVideoPath = "/agent_openapi/v1/video/query"
|
||
)
|
||
|
||
type Client struct {
|
||
signer *signer.Config
|
||
hc *http.Client
|
||
}
|
||
|
||
func NewClient(ak, sk string) *Client {
|
||
return &Client{
|
||
signer: &signer.Config{AccessKey: ak, SecretKey: sk, Host: "https://jimeng.jianying.com"},
|
||
hc: &http.Client{Timeout: 30 * time.Second},
|
||
}
|
||
}
|
||
|
||
// post 统一发送带签名的 POST 请求
|
||
func (c *Client) post(path string, body any, out any) error {
|
||
b, err := json.Marshal(body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
hdrs, err := c.signer.Headers(&signer.Request{
|
||
Method: "POST",
|
||
Path: path,
|
||
Body: b,
|
||
ContentType: "application/json",
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req, err := http.NewRequest("POST", c.signer.Host+path, bytes.NewReader(b))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
for k, v := range hdrs {
|
||
req.Header.Set(k, v)
|
||
}
|
||
resp, err := c.hc.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
rb, _ := io.ReadAll(resp.Body)
|
||
if err := json.Unmarshal(rb, out); err != nil {
|
||
return fmt.Errorf("decode resp: %w, body=%s", err, string(rb))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---- 提交:带货营销视频 ----
|
||
type SubmitResp struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
Data struct {
|
||
TaskID string `json:"task_id"`
|
||
RunID string `json:"run_id"`
|
||
ThreadID string `json:"thread_id"`
|
||
Status string `json:"status"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
func (c *Client) SubmitMarketingVideo(productName string, productImages []string) (*SubmitResp, error) {
|
||
req := map[string]any{
|
||
"capability_key": "pippit_avatar_marketing_agent",
|
||
"action_key": "generate_video",
|
||
"run_id": fmt.Sprintf("biz-%d", time.Now().UnixNano()),
|
||
"avatar_marketing_input": map[string]any{
|
||
"product_name": productName,
|
||
"product_image_url_list": productImages,
|
||
"model_image_url_list": []string{},
|
||
"enable_watermark": false,
|
||
},
|
||
}
|
||
var out SubmitResp
|
||
if err := c.post(submitVideoPath, req, &out); err != nil {
|
||
return nil, err
|
||
}
|
||
if out.Code != "0" {
|
||
return nil, fmt.Errorf("submit failed code=%s msg=%s", out.Code, out.Message)
|
||
}
|
||
return &out, nil
|
||
}
|
||
|
||
// ---- 查询 + 轮询 ----
|
||
type QueryVideoResp struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
Data struct {
|
||
Status string `json:"status"`
|
||
ErrCode string `json:"err_code"`
|
||
ErrMsg string `json:"err_msg"`
|
||
VideoArtifact struct {
|
||
URL string `json:"url"`
|
||
CoverURL string `json:"cover_url"`
|
||
Duration float64 `json:"duration"`
|
||
Width int64 `json:"width"`
|
||
Height int64 `json:"height"`
|
||
} `json:"video_artifact"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
// PollVideo 提交后轮询直到终态;SUCCESS 返回下载 URL,FAILED 返回错误
|
||
func (c *Client) PollVideo(taskID, runID string, timeout time.Duration) (string, error) {
|
||
deadline := time.Now().Add(timeout)
|
||
for time.Now().Before(deadline) {
|
||
var out QueryVideoResp
|
||
body := map[string]string{}
|
||
if taskID != "" {
|
||
body["task_id"] = taskID
|
||
} else {
|
||
body["run_id"] = runID
|
||
}
|
||
if err := c.post(queryVideoPath, body, &out); err != nil {
|
||
return "", err
|
||
}
|
||
switch out.Data.Status {
|
||
case "SUCCESS":
|
||
return out.Data.VideoArtifact.URL, nil
|
||
case "FAILED", "CANCELED":
|
||
return "", fmt.Errorf("task %s err=%s msg=%s", out.Data.Status, out.Data.ErrCode, out.Data.ErrMsg)
|
||
}
|
||
time.Sleep(3 * time.Second)
|
||
}
|
||
return "", fmt.Errorf("timeout after %v", timeout)
|
||
}
|
||
```
|
||
|
||
### 8.1 对接注意点(代码里务必处理)
|
||
|
||
1. **时钟同步**:本地时间偏差 >300s 会直接 `10006`;部署前校准服务器时钟。
|
||
2. **幂等**:`run_id` 自行生成(建议 `biz-<毫秒/纳秒>` 或业务单号);重复提交同 run_id 返回同一任务。
|
||
3. **产物时效**:`video_artifact.url` / `novel_data` 里的下载 URL **24h 过期**,成功终态后立即下载落库。
|
||
4. **轮询**:提交返回 `QUEUED` 后开始轮询 query,间隔建议 3~5s,注意查询接口限流 20qps。
|
||
5. **短剧多阶段**:每阶段是独立 submit+query,从上一阶段 `resp_data` 里取 `OverviewAssetID` / `StoryboardAssetID` 作为下一阶段 `asset_id`,`thread_id` 全程回填。
|
||
6. **图片规格**:提交前校验宽高比、尺寸、格式,避免 `10001` 参数非法。
|
||
7. **排障**:所有报错都把 `request_id` 记录下来,供反馈排查。
|