commit cbb2ea005486de98e72cb563dee41a2f9718291d
Author: renzhiyuan <465386466@qq.com>
Date: Mon Sep 28 08:25:44 2026 +0800
11
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/jimeng.iml b/.idea/jimeng.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/jimeng.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..5f10538
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/client.go b/client/client.go
new file mode 100644
index 0000000..d7cf09a
--- /dev/null
+++ b/client/client.go
@@ -0,0 +1,133 @@
+package client
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "time"
+
+ "jimeng/signer"
+)
+
+// Client 即梦API客户端
+type Client struct {
+ signer *signer.Config
+ hc *http.Client
+}
+
+// NewClient 创建客户端
+func NewClient(ak, sk, host string) *Client {
+ return &Client{
+ signer: &signer.Config{
+ AccessKey: ak,
+ SecretKey: sk,
+ Host: host,
+ },
+ 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
+ }
+ log.Printf("[HTTP] POST %s Body=%s", path, string(b))
+ 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)
+ log.Printf("[HTTP] Response status=%d body=%s", resp.StatusCode, string(rb))
+ if err := json.Unmarshal(rb, out); err != nil {
+ return fmt.Errorf("decode resp: %w, body=%s", err, string(rb))
+ }
+ return nil
+}
+
+// SubmitNovelTask 提交短剧任务
+func (c *Client) SubmitNovelTask(input map[string]interface{}) (map[string]interface{}, error) {
+ req := map[string]interface{}{
+ "capability_key": "pippit_novel_agent",
+ "action_key": input["action_key"],
+ "run_id": fmt.Sprintf("biz-%d", time.Now().UnixNano()),
+ "input": input,
+ }
+ var out map[string]interface{}
+ if err := c.post("/agent_openapi/v1/novel/submit", req, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// QueryNovelTask 查询短剧任务
+func (c *Client) QueryNovelTask(taskID, runID string) (map[string]interface{}, error) {
+ body := map[string]string{}
+ if taskID != "" {
+ body["task_id"] = taskID
+ } else {
+ body["run_id"] = runID
+ }
+ var out map[string]interface{}
+ if err := c.post("/agent_openapi/v1/novel/query", body, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// SubmitVideoTask 提交视频任务(带货营销/剧情营销/短片创作)
+func (c *Client) SubmitVideoTask(capabilityKey string, input map[string]interface{}) (map[string]interface{}, error) {
+ req := map[string]interface{}{
+ "capability_key": capabilityKey,
+ "action_key": "generate_video",
+ "run_id": fmt.Sprintf("biz-%d", time.Now().UnixNano()),
+ }
+ // 带货营销和剧情营销使用 avatar_marketing_input,短片创作使用 input
+ if capabilityKey == "pippit_video_part_agent" {
+ req["input"] = input
+ } else {
+ req["avatar_marketing_input"] = input
+ }
+ var out map[string]interface{}
+ if err := c.post("/agent_openapi/v1/video/submit", req, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// QueryVideoTask 查询视频任务
+func (c *Client) QueryVideoTask(taskID, runID string) (map[string]interface{}, error) {
+ body := map[string]string{}
+ if taskID != "" {
+ body["task_id"] = taskID
+ } else {
+ body["run_id"] = runID
+ }
+ var out map[string]interface{}
+ if err := c.post("/agent_openapi/v1/video/query", body, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
diff --git a/config.yaml b/config.yaml
new file mode 100644
index 0000000..e254667
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,26 @@
+server:
+ port: 8080
+
+api:
+ access_key: "ak_Oh19gpPv0po9ZFM45LBHdN2B"
+ secret_key: "sk_nF8beggPmNiDfwQxVD1oRfCdW3jtOwBLay8b0DSLuho"
+ host: "https://jimeng.jianying.com"
+
+store:
+ data_file: "data/tasks.json"
+
+test_product:
+ - name: "化妆品"
+ url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png"
+ - name: "礼盒"
+ url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/lihe.png"
+ - name: "蛋糕"
+ url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/dangao.png"
+
+test_model:
+ - name: "女模特1"
+ url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/woman2.png"
+ - name: "女模特2"
+ url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/woman1.png"
+ - name: "男模特1"
+ url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png"
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..64fbe64
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,65 @@
+package config
+
+import (
+ "os"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Config 全局配置
+type Config struct {
+ Server ServerConfig `yaml:"server"`
+ API APIConfig `yaml:"api"`
+ Store StoreConfig `yaml:"store"`
+ TestProduct []TestDataItem `yaml:"test_product"`
+ TestModel []TestDataItem `yaml:"test_model"`
+}
+
+// TestDataItem 测试数据项
+type TestDataItem struct {
+ Name string `yaml:"name" json:"name"`
+ URL string `yaml:"url" json:"url"`
+}
+
+// ServerConfig 服务器配置
+type ServerConfig struct {
+ Port int `yaml:"port"`
+}
+
+// APIConfig API配置
+type APIConfig struct {
+ AccessKey string `yaml:"access_key"`
+ SecretKey string `yaml:"secret_key"`
+ Host string `yaml:"host"`
+}
+
+// StoreConfig 存储配置
+type StoreConfig struct {
+ DataFile string `yaml:"data_file"`
+}
+
+// Load 加载配置文件
+func Load(path string) (*Config, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var cfg Config
+ if err := yaml.Unmarshal(data, &cfg); err != nil {
+ return nil, err
+ }
+
+ // 设置默认值
+ if cfg.Server.Port == 0 {
+ cfg.Server.Port = 8080
+ }
+ if cfg.API.Host == "" {
+ cfg.API.Host = "https://jimeng.jianying.com"
+ }
+ if cfg.Store.DataFile == "" {
+ cfg.Store.DataFile = "data/tasks.json"
+ }
+
+ return &cfg, nil
+}
diff --git a/data/tasks.json b/data/tasks.json
new file mode 100644
index 0000000..6a739de
--- /dev/null
+++ b/data/tasks.json
@@ -0,0 +1,251 @@
+[
+ {
+ "id": "1790352548052288100",
+ "type": "video",
+ "capability": "pippit_avatar_marketing_agent",
+ "action": "generate_video",
+ "status": "",
+ "thread_id": "",
+ "asset_id": "",
+ "run_id": "",
+ "input": {
+ "enable_watermark": false,
+ "model_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png"
+ ],
+ "product_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/dangao.png"
+ ],
+ "product_name": "蛋糕",
+ "prompt": "好吃"
+ },
+ "output": null,
+ "error_code": "",
+ "error_msg": "",
+ "created_at": "2026-09-26T00:09:08.0522881+08:00",
+ "updated_at": "2026-09-26T00:09:59.1266034+08:00",
+ "elapsed_sec": 0,
+ "usage": null
+ },
+ {
+ "id": "1790554523724154000",
+ "type": "video",
+ "capability": "pippit_avatar_marketing_agent",
+ "action": "generate_video",
+ "status": "QUEUED",
+ "thread_id": "",
+ "asset_id": "",
+ "run_id": "biz-1790554523337556900",
+ "input": {
+ "enable_watermark": false,
+ "model_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/woman2.png"
+ ],
+ "product_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/lihe.png"
+ ],
+ "product_name": "礼盒",
+ "prompt": "非常便宜"
+ },
+ "output": null,
+ "error_code": "",
+ "error_msg": "",
+ "created_at": "2026-09-28T08:15:23.724154+08:00",
+ "updated_at": "2026-09-28T08:15:23.724154+08:00",
+ "elapsed_sec": 0,
+ "usage": null
+ },
+ {
+ "id": "1790352614814653300",
+ "type": "video",
+ "capability": "pippit_avatar_marketing_agent",
+ "action": "generate_video",
+ "status": "",
+ "thread_id": "",
+ "asset_id": "",
+ "run_id": "",
+ "input": {
+ "enable_watermark": false,
+ "model_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png"
+ ],
+ "product_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png"
+ ],
+ "product_name": "化妆品",
+ "prompt": "11"
+ },
+ "output": null,
+ "error_code": "",
+ "error_msg": "",
+ "created_at": "2026-09-26T00:10:14.8146533+08:00",
+ "updated_at": "2026-09-26T00:11:05.8792352+08:00",
+ "elapsed_sec": 0,
+ "usage": null
+ },
+ {
+ "id": "1790542259382038400",
+ "type": "video",
+ "capability": "pippit_avatar_marketing_agent",
+ "action": "generate_video",
+ "status": "",
+ "thread_id": "",
+ "asset_id": "",
+ "run_id": "",
+ "input": {
+ "enable_watermark": false,
+ "model_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png"
+ ],
+ "product_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png"
+ ],
+ "product_name": ""
+ },
+ "output": null,
+ "error_code": "",
+ "error_msg": "",
+ "created_at": "2026-09-28T04:50:59.3820384+08:00",
+ "updated_at": "2026-09-28T04:51:41.252295+08:00",
+ "elapsed_sec": 0,
+ "usage": null
+ },
+ {
+ "id": "1790542379408153800",
+ "type": "video",
+ "capability": "pippit_avatar_marketing_agent",
+ "action": "generate_video",
+ "status": "QUEUED",
+ "thread_id": "",
+ "asset_id": "",
+ "run_id": "biz-1790542379225626100",
+ "input": {
+ "enable_watermark": true,
+ "model_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png"
+ ],
+ "product_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png"
+ ],
+ "product_name": "测试"
+ },
+ "output": null,
+ "error_code": "",
+ "error_msg": "",
+ "created_at": "2026-09-28T04:52:59.4081538+08:00",
+ "updated_at": "2026-09-28T04:52:59.4081538+08:00",
+ "elapsed_sec": 0,
+ "usage": null
+ },
+ {
+ "id": "1790542424748799600",
+ "type": "video",
+ "capability": "pippit_avatar_marketing_agent",
+ "action": "generate_video",
+ "status": "QUEUED",
+ "thread_id": "",
+ "asset_id": "",
+ "run_id": "biz-1790542424496945300",
+ "input": {
+ "enable_watermark": true,
+ "model_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/woman1.png"
+ ],
+ "product_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png"
+ ],
+ "product_name": "测试"
+ },
+ "output": null,
+ "error_code": "",
+ "error_msg": "",
+ "created_at": "2026-09-28T04:53:44.7487996+08:00",
+ "updated_at": "2026-09-28T04:53:44.7487996+08:00",
+ "elapsed_sec": 0,
+ "usage": null
+ },
+ {
+ "id": "1790351272455941700",
+ "type": "video",
+ "capability": "pippit_avatar_marketing_agent",
+ "action": "generate_video",
+ "status": "",
+ "thread_id": "",
+ "asset_id": "",
+ "run_id": "",
+ "input": {
+ "enable_watermark": false,
+ "model_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png"
+ ],
+ "product_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png"
+ ],
+ "product_name": "",
+ "prompt": "111"
+ },
+ "output": null,
+ "error_code": "",
+ "error_msg": "",
+ "created_at": "2026-09-25T23:47:52.4559417+08:00",
+ "updated_at": "2026-09-28T04:52:20.7042357+08:00",
+ "elapsed_sec": 0,
+ "usage": null
+ },
+ {
+ "id": "1790351604073897100",
+ "type": "video",
+ "capability": "pippit_avatar_marketing_agent",
+ "action": "generate_video",
+ "status": "",
+ "thread_id": "",
+ "asset_id": "",
+ "run_id": "",
+ "input": {
+ "enable_watermark": false,
+ "model_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png"
+ ],
+ "product_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/dangao.png"
+ ],
+ "product_name": "蛋糕",
+ "prompt": "好吃"
+ },
+ "output": null,
+ "error_code": "",
+ "error_msg": "",
+ "created_at": "2026-09-25T23:53:24.0738971+08:00",
+ "updated_at": "2026-09-28T04:52:20.7185271+08:00",
+ "elapsed_sec": 0,
+ "usage": null
+ },
+ {
+ "id": "1790351753610019500",
+ "type": "video",
+ "capability": "pippit_avatar_marketing_agent",
+ "action": "generate_video",
+ "status": "",
+ "thread_id": "",
+ "asset_id": "",
+ "run_id": "",
+ "input": {
+ "enable_watermark": false,
+ "model_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png"
+ ],
+ "product_image_url_list": [
+ "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/dangao.png"
+ ],
+ "product_name": "蛋糕",
+ "prompt": "好吃"
+ },
+ "output": null,
+ "error_code": "",
+ "error_msg": "",
+ "created_at": "2026-09-25T23:55:53.6100195+08:00",
+ "updated_at": "2026-09-28T04:52:20.71+08:00",
+ "elapsed_sec": 0,
+ "usage": null
+ }
+]
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..fa7d006
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module jimeng
+
+go 1.26.4
+
+require gopkg.in/yaml.v3 v3.0.1
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..a62c313
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,4 @@
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/handler/handler.go b/handler/handler.go
new file mode 100644
index 0000000..55c467a
--- /dev/null
+++ b/handler/handler.go
@@ -0,0 +1,295 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "time"
+
+ "jimeng/client"
+ "jimeng/config"
+ "jimeng/model"
+ "jimeng/store"
+)
+
+// Handler HTTP处理器
+type Handler struct {
+ client *client.Client
+ store *store.Store
+ cfg *config.Config
+}
+
+// NewHandler 创建处理器
+func NewHandler(c *client.Client, s *store.Store, cfg *config.Config) *Handler {
+ return &Handler{client: c, store: s, cfg: cfg}
+}
+
+// RegisterRoutes 注册路由
+func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("/api/novel/submit", h.submitNovel)
+ mux.HandleFunc("/api/novel/query", h.queryNovel)
+ mux.HandleFunc("/api/video/submit", h.submitVideo)
+ mux.HandleFunc("/api/video/query", h.queryVideo)
+ mux.HandleFunc("/api/tasks", h.listTasks)
+ mux.HandleFunc("/api/task", h.getTask)
+ mux.HandleFunc("/api/task/delete", h.deleteTask)
+ mux.HandleFunc("/api/testdata", h.getTestData)
+}
+
+func (h *Handler) submitNovel(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var input map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ result, err := h.client.SubmitNovelTask(input)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ // 创建本地任务记录
+ task := &model.Task{
+ ID: store.GenerateID(),
+ Type: "novel",
+ Capability: "pippit_novel_agent",
+ Action: getString(input, "action_key"),
+ Status: "CREATED",
+ ThreadID: getString(input, "thread_id"),
+ AssetID: getString(input, "asset_id"),
+ RunID: getString(result, "run_id"),
+ Input: input,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ if data, ok := result["data"].(map[string]interface{}); ok {
+ task.Status = getString(data, "status")
+ if task.RunID == "" {
+ task.RunID = getString(data, "run_id")
+ }
+ if task.ThreadID == "" {
+ task.ThreadID = getString(data, "thread_id")
+ }
+ }
+ if err := h.store.CreateTask(task); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ // 将本地 task_id 和 run_id 注入响应
+ if data, ok := result["data"].(map[string]interface{}); ok {
+ if getString(data, "task_id") == "" {
+ data["task_id"] = task.ID
+ }
+ if getString(data, "run_id") == "" {
+ data["run_id"] = task.RunID
+ }
+ }
+ json.NewEncoder(w).Encode(result)
+}
+
+func (h *Handler) queryNovel(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var req struct {
+ TaskID string `json:"task_id"`
+ RunID string `json:"run_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ result, err := h.client.QueryNovelTask(req.TaskID, req.RunID)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ // 更新本地任务状态
+ if data, ok := result["data"].(map[string]interface{}); ok {
+ taskID := req.TaskID
+ if taskID == "" {
+ // 通过run_id查找任务
+ tasks := h.store.ListTasks()
+ for _, t := range tasks {
+ if t.RunID == req.RunID {
+ taskID = t.ID
+ break
+ }
+ }
+ }
+ if task, ok := h.store.GetTask(taskID); ok {
+ task.Status = getString(data, "status")
+ task.ThreadID = getString(data, "thread_id")
+ task.UpdatedAt = time.Now()
+ if usage, ok := data["usage"].(map[string]interface{}); ok {
+ task.Usage = usage
+ }
+ if novelData, ok := data["novel_data"].(map[string]interface{}); ok {
+ task.Output = novelData
+ }
+ h.store.UpdateTask(task)
+ }
+ }
+ json.NewEncoder(w).Encode(result)
+}
+
+func (h *Handler) submitVideo(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var req struct {
+ CapabilityKey string `json:"capability_key"`
+ Input map[string]interface{} `json:"input"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ result, err := h.client.SubmitVideoTask(req.CapabilityKey, req.Input)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ // 创建本地任务记录
+ task := &model.Task{
+ ID: store.GenerateID(),
+ Type: "video",
+ Capability: req.CapabilityKey,
+ Action: "generate_video",
+ Status: "CREATED",
+ RunID: getString(result, "run_id"),
+ Input: req.Input,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ if data, ok := result["data"].(map[string]interface{}); ok {
+ task.Status = getString(data, "status")
+ if task.RunID == "" {
+ task.RunID = getString(data, "run_id")
+ }
+ }
+ if err := h.store.CreateTask(task); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ // 将本地 task_id 和 run_id 注入响应,确保前端能拿到
+ if data, ok := result["data"].(map[string]interface{}); ok {
+ if getString(data, "task_id") == "" {
+ data["task_id"] = task.ID
+ }
+ if getString(data, "run_id") == "" {
+ data["run_id"] = task.RunID
+ }
+ }
+ json.NewEncoder(w).Encode(result)
+}
+
+func (h *Handler) queryVideo(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var req struct {
+ TaskID string `json:"task_id"`
+ RunID string `json:"run_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ result, err := h.client.QueryVideoTask(req.TaskID, req.RunID)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ // 更新本地任务状态
+ if data, ok := result["data"].(map[string]interface{}); ok {
+ taskID := req.TaskID
+ if taskID == "" {
+ tasks := h.store.ListTasks()
+ for _, t := range tasks {
+ if t.RunID == req.RunID {
+ taskID = t.ID
+ break
+ }
+ }
+ }
+ if task, ok := h.store.GetTask(taskID); ok {
+ task.Status = getString(data, "status")
+ task.UpdatedAt = time.Now()
+ if usage, ok := data["usage"].(map[string]interface{}); ok {
+ task.Usage = usage
+ }
+ if artifact, ok := data["video_artifact"].(map[string]interface{}); ok {
+ task.Output = artifact
+ }
+ if errCode, ok := data["err_code"].(string); ok {
+ task.ErrorCode = errCode
+ }
+ if errMsg, ok := data["err_msg"].(string); ok {
+ task.ErrorMsg = errMsg
+ }
+ h.store.UpdateTask(task)
+ }
+ }
+ json.NewEncoder(w).Encode(result)
+}
+
+func (h *Handler) listTasks(w http.ResponseWriter, r *http.Request) {
+ tasks := h.store.ListTasks()
+ json.NewEncoder(w).Encode(tasks)
+}
+
+func (h *Handler) getTask(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("id")
+ if id == "" {
+ http.Error(w, "id required", http.StatusBadRequest)
+ return
+ }
+ task, ok := h.store.GetTask(id)
+ if !ok {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ json.NewEncoder(w).Encode(task)
+}
+
+func (h *Handler) deleteTask(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var req struct {
+ ID string `json:"id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if err := h.store.DeleteTask(req.ID); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Write([]byte(`{"success":true}`))
+}
+
+func getString(m map[string]interface{}, key string) string {
+ if v, ok := m[key]; ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ return ""
+}
+
+func (h *Handler) getTestData(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]interface{}{
+ "test_product": h.cfg.TestProduct,
+ "test_model": h.cfg.TestModel,
+ }
+ json.NewEncoder(w).Encode(resp)
+}
diff --git a/jimeng.exe b/jimeng.exe
new file mode 100644
index 0000000..65f83f5
Binary files /dev/null and b/jimeng.exe differ
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..8a5dc0c
--- /dev/null
+++ b/main.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+ "embed"
+ "flag"
+ "fmt"
+ "io/fs"
+ "log"
+ "net/http"
+
+ "jimeng/client"
+ "jimeng/config"
+ "jimeng/handler"
+ "jimeng/store"
+)
+
+//go:embed web/*
+var webFS embed.FS
+
+func main() {
+ configPath := flag.String("config", "config.yaml", "配置文件路径")
+ flag.Parse()
+
+ // 加载配置
+ cfg, err := config.Load(*configPath)
+ if err != nil {
+ log.Fatalf("加载配置失败: %v", err)
+ }
+
+ // 创建存储
+ s, err := store.NewStore(cfg.Store.DataFile)
+ if err != nil {
+ log.Fatalf("创建存储失败: %v", err)
+ }
+
+ // 创建API客户端
+ c := client.NewClient(cfg.API.AccessKey, cfg.API.SecretKey, cfg.API.Host)
+
+ // 创建处理器
+ h := handler.NewHandler(c, s, cfg)
+
+ // 创建路由
+ mux := http.NewServeMux()
+
+ // 注册API路由
+ h.RegisterRoutes(mux)
+
+ // 注册静态文件服务
+ webContent, _ := fs.Sub(webFS, "web")
+ fileServer := http.FileServer(http.FS(webContent))
+ mux.Handle("/", fileServer)
+
+ // 启动服务器
+ addr := fmt.Sprintf(":%d", cfg.Server.Port)
+ log.Printf("服务器启动在 http://localhost%s", addr)
+ if err := http.ListenAndServe(addr, mux); err != nil {
+ log.Fatalf("服务器启动失败: %v", err)
+ }
+}
diff --git a/model/model.go b/model/model.go
new file mode 100644
index 0000000..1acbd2c
--- /dev/null
+++ b/model/model.go
@@ -0,0 +1,31 @@
+package model
+
+import "time"
+
+// Task 任务信息
+type Task struct {
+ ID string `json:"id"`
+ Type string `json:"type"` // novel, video
+ Capability string `json:"capability"` // capability_key
+ Action string `json:"action"` // action_key
+ Status string `json:"status"` // CREATED, QUEUED, RUNNING, SUCCESS, FAILED, CANCELED
+ ThreadID string `json:"thread_id"` // 短剧会话ID
+ AssetID string `json:"asset_id"` // 资产ID
+ RunID string `json:"run_id"` // 幂等键
+ Input map[string]interface{} `json:"input"` // 输入参数
+ Output map[string]interface{} `json:"output"` // 输出结果
+ ErrorCode string `json:"error_code"` // 错误码
+ ErrorMsg string `json:"error_msg"` // 错误信息
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ ElapsedSec int64 `json:"elapsed_sec"` // 耗时秒
+ Usage map[string]interface{} `json:"usage"` // 计费信息
+}
+
+// APIResponse 通用API响应
+type APIResponse struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ RequestID string `json:"request_id"`
+ Data map[string]interface{} `json:"data"`
+}
diff --git a/signer/signer.go b/signer/signer.go
new file mode 100644
index 0000000..5c81230
--- /dev/null
+++ b/signer/signer.go
@@ -0,0 +1,109 @@
+package signer
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "log"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+)
+
+// Config 签名配置
+type Config struct {
+ AccessKey string
+ SecretKey string
+ Host string // https://jimeng.jianying.com
+}
+
+// Request 签名请求信息
+type Request struct {
+ Method string // 恒为 POST
+ Path string // 如 /agent_openapi/v1/video/submit
+ Query url.Values // 通常为空
+ Body []byte // JSON 序列化后的请求体
+ ContentType string // 恒为 application/json
+}
+
+// Headers 生成鉴权相关请求头
+func (c *Config) Headers(req *Request) (map[string]string, error) {
+ timestamp := fmt.Sprintf("%d", time.Now().Unix())
+ nonce, err := randomNonce()
+ if err != nil {
+ return nil, err
+ }
+ bodySHA := sha256Hex(req.Body)
+ canonicalQuery := canonicalQueryString(req.Query)
+ contentType := req.ContentType
+ if contentType == "" {
+ contentType = "application/json"
+ }
+
+ stringToSign := strings.Join([]string{
+ req.Method, req.Path, canonicalQuery, contentType,
+ bodySHA, timestamp, nonce, c.AccessKey,
+ }, "\n")
+
+ signature := hmacSHA256Base64URL(c.SecretKey, stringToSign)
+
+ // 调试日志:打印签名详情,便于排查 10006 错误
+ log.Printf("[SIGN] Path=%s BodyLen=%d BodySHA256=%s", req.Path, len(req.Body), bodySHA)
+ log.Printf("[SIGN] StringToSign=\n%s", stringToSign)
+ log.Printf("[SIGN] Signature=%s", signature)
+
+ return map[string]string{
+ "X-Agent-Access-Key": c.AccessKey,
+ "X-Agent-Timestamp": timestamp,
+ "X-Agent-Nonce": nonce,
+ "X-Agent-Body-SHA256": bodySHA,
+ "X-Agent-Signature-Method": "HMAC-SHA256",
+ "X-Agent-Version": "v1",
+ "X-Agent-Signature": signature,
+ }, nil
+}
+
+func randomNonce() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b), nil
+}
+
+func sha256Hex(data []byte) string {
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:]) // 小写
+}
+
+// query 按参数名 ASCII 升序,key/value 各自 RFC3986 编码后 & 连接
+func canonicalQueryString(v url.Values) string {
+ if len(v) == 0 {
+ return ""
+ }
+ keys := make([]string, 0, len(v))
+ for k := range v {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ var parts []string
+ for _, k := range keys {
+ for _, val := range v[k] {
+ parts = append(parts, escape(k)+"="+escape(val))
+ }
+ }
+ return strings.Join(parts, "&")
+}
+
+// RFC3986:空格 -> %20,~ 不编码
+func escape(s string) string { return url.QueryEscape(s) }
+
+func hmacSHA256Base64URL(key, data string) string {
+ mac := hmac.New(sha256.New, []byte(key))
+ mac.Write([]byte(data))
+ return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) // Raw 即无 padding
+}
diff --git a/store/store.go b/store/store.go
new file mode 100644
index 0000000..f60dca1
--- /dev/null
+++ b/store/store.go
@@ -0,0 +1,126 @@
+package store
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "jimeng/model"
+)
+
+// Store 任务存储
+type Store struct {
+ mu sync.RWMutex
+ tasks map[string]*model.Task
+ dataFile string
+}
+
+// NewStore 创建存储实例
+func NewStore(dataFile string) (*Store, error) {
+ s := &Store{
+ tasks: make(map[string]*model.Task),
+ dataFile: dataFile,
+ }
+ // 确保目录存在
+ dir := filepath.Dir(dataFile)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return nil, err
+ }
+ // 加载已有数据
+ if err := s.load(); err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+ return s, nil
+}
+
+// load 从文件加载任务
+func (s *Store) load() error {
+ data, err := os.ReadFile(s.dataFile)
+ if err != nil {
+ return err
+ }
+ var tasks []*model.Task
+ if err := json.Unmarshal(data, &tasks); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, t := range tasks {
+ s.tasks[t.ID] = t
+ }
+ return nil
+}
+
+// save 保存任务到文件
+func (s *Store) save() error {
+ s.mu.RLock()
+ tasks := make([]*model.Task, 0, len(s.tasks))
+ for _, t := range s.tasks {
+ tasks = append(tasks, t)
+ }
+ s.mu.RUnlock()
+ data, err := json.MarshalIndent(tasks, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(s.dataFile, data, 0644)
+}
+
+// CreateTask 创建任务
+func (s *Store) CreateTask(t *model.Task) error {
+ s.mu.Lock()
+ s.tasks[t.ID] = t
+ s.mu.Unlock()
+ return s.save()
+}
+
+// GetTask 获取任务
+func (s *Store) GetTask(id string) (*model.Task, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ t, ok := s.tasks[id]
+ return t, ok
+}
+
+// UpdateTask 更新任务
+func (s *Store) UpdateTask(t *model.Task) error {
+ s.mu.Lock()
+ s.tasks[t.ID] = t
+ s.mu.Unlock()
+ return s.save()
+}
+
+// ListTasks 列出所有任务
+func (s *Store) ListTasks() []*model.Task {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ tasks := make([]*model.Task, 0, len(s.tasks))
+ for _, t := range s.tasks {
+ tasks = append(tasks, t)
+ }
+ // 按创建时间倒序
+ for i := 0; i < len(tasks); i++ {
+ for j := i + 1; j < len(tasks); j++ {
+ if tasks[i].CreatedAt.Before(tasks[j].CreatedAt) {
+ tasks[i], tasks[j] = tasks[j], tasks[i]
+ }
+ }
+ }
+ return tasks
+}
+
+// DeleteTask 删除任务
+func (s *Store) DeleteTask(id string) error {
+ s.mu.Lock()
+ delete(s.tasks, id)
+ s.mu.Unlock()
+ return s.save()
+}
+
+// GenerateID 生成任务ID
+func GenerateID() string {
+ return fmt.Sprintf("%d", time.Now().UnixNano())
+}
diff --git a/web/css/style.css b/web/css/style.css
new file mode 100644
index 0000000..f040151
--- /dev/null
+++ b/web/css/style.css
@@ -0,0 +1,478 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root {
+ --primary: #6366f1;
+ --primary-dark: #4f46e5;
+ --success: #10b981;
+ --warning: #f59e0b;
+ --danger: #ef4444;
+ --bg: #f8fafc;
+ --card: #ffffff;
+ --text: #1e293b;
+ --text-light: #64748b;
+ --border: #e2e8f0;
+ --radius: 12px;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.6;
+ padding-bottom: 70px;
+}
+
+/* Header */
+.header {
+ background: linear-gradient(135deg, var(--primary), var(--primary-dark));
+ color: white;
+ padding: 20px;
+ text-align: center;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+}
+
+.header h1 {
+ font-size: 20px;
+ font-weight: 600;
+}
+
+/* Navigation */
+.nav {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: var(--card);
+ display: flex;
+ justify-content: space-around;
+ padding: 10px 0;
+ box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
+ z-index: 100;
+}
+
+.nav-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-decoration: none;
+ color: var(--text-light);
+ font-size: 12px;
+ padding: 5px 10px;
+ transition: color 0.2s;
+}
+
+.nav-item.active {
+ color: var(--primary);
+}
+
+.nav-item svg {
+ width: 24px;
+ height: 24px;
+ margin-bottom: 4px;
+}
+
+/* Container */
+.container {
+ padding: 20px;
+ max-width: 600px;
+ margin: 0 auto;
+}
+
+/* Card */
+.card {
+ background: var(--card);
+ border-radius: var(--radius);
+ padding: 20px;
+ margin-bottom: 16px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+
+.card-title {
+ font-size: 16px;
+ font-weight: 600;
+ margin-bottom: 16px;
+ color: var(--text);
+}
+
+/* Form */
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-label {
+ display: block;
+ font-size: 14px;
+ font-weight: 500;
+ margin-bottom: 6px;
+ color: var(--text);
+}
+
+.form-input,
+.form-select,
+.form-textarea {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font-size: 14px;
+ transition: border-color 0.2s;
+}
+
+.form-input:focus,
+.form-select:focus,
+.form-textarea:focus {
+ outline: none;
+ border-color: var(--primary);
+}
+
+.form-textarea {
+ resize: vertical;
+ min-height: 80px;
+}
+
+/* Button */
+.btn {
+ display: inline-block;
+ padding: 12px 24px;
+ border: none;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ text-align: center;
+ text-decoration: none;
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--primary-dark);
+}
+
+.btn-success {
+ background: var(--success);
+ color: white;
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+}
+
+.btn-block {
+ display: block;
+ width: 100%;
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+/* Status Badge */
+.badge {
+ display: inline-block;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.badge-created {
+ background: #e0e7ff;
+ color: #3730a3;
+}
+
+.badge-queued {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.badge-running {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
+.badge-success {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.badge-failed {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.badge-canceled {
+ background: #f3f4f6;
+ color: #374151;
+}
+
+/* Task List */
+.task-item {
+ background: var(--card);
+ border-radius: var(--radius);
+ padding: 16px;
+ margin-bottom: 12px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+
+.task-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.task-title {
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.task-meta {
+ font-size: 12px;
+ color: var(--text-light);
+ margin-bottom: 8px;
+}
+
+.task-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.task-actions .btn {
+ padding: 8px 16px;
+ font-size: 13px;
+}
+
+/* Result */
+.result {
+ background: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ border-radius: var(--radius);
+ padding: 16px;
+ margin-top: 16px;
+}
+
+.result-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #065f46;
+ margin-bottom: 8px;
+}
+
+.result-video {
+ width: 100%;
+ border-radius: 8px;
+ margin-top: 12px;
+}
+
+/* Loading */
+.loading {
+ text-align: center;
+ padding: 40px;
+ color: var(--text-light);
+}
+
+.spinner {
+ border: 3px solid var(--border);
+ border-top: 3px solid var(--primary);
+ border-radius: 50%;
+ width: 40px;
+ height: 40px;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 16px;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+/* Empty State */
+.empty {
+ text-align: center;
+ padding: 60px 20px;
+ color: var(--text-light);
+}
+
+.empty svg {
+ width: 80px;
+ height: 80px;
+ margin-bottom: 16px;
+ opacity: 0.5;
+}
+
+/* Alert */
+.alert {
+ padding: 12px 16px;
+ border-radius: 8px;
+ margin-bottom: 16px;
+ font-size: 14px;
+}
+
+.alert-success {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.alert-error {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.alert-info {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
+/* Tabs */
+.tabs {
+ display: flex;
+ background: var(--card);
+ border-radius: var(--radius);
+ padding: 4px;
+ margin-bottom: 20px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+
+.tab {
+ flex: 1;
+ padding: 10px;
+ text-align: center;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ transition: all 0.2s;
+ color: var(--text-light);
+}
+
+.tab.active {
+ background: var(--primary);
+ color: white;
+}
+
+/* Stage indicator */
+.stages {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.stage {
+ padding: 6px 12px;
+ border-radius: 16px;
+ font-size: 12px;
+ background: var(--border);
+ color: var(--text-light);
+}
+
+.stage.active {
+ background: var(--primary);
+ color: white;
+}
+
+.stage.completed {
+ background: var(--success);
+ color: white;
+}
+
+/* Test Data Picker */
+.btn-testdata {
+ display: inline-block;
+ margin-top: 8px;
+ padding: 6px 12px;
+ background: #f0f9ff;
+ border: 1px solid #bae6fd;
+ border-radius: 6px;
+ font-size: 13px;
+ color: #0369a1;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-testdata:hover {
+ background: #e0f2fe;
+ border-color: #7dd3fc;
+}
+
+.testdata-picker {
+ margin-top: 10px;
+ padding: 12px;
+ background: #fafafa;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.testdata-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px;
+ margin-bottom: 8px;
+ background: white;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.testdata-item:hover {
+ border-color: var(--primary);
+ background: #f5f3ff;
+}
+
+.testdata-item.selected {
+ border-color: var(--primary);
+ background: #ede9fe;
+}
+
+.testdata-item:last-child {
+ margin-bottom: 0;
+}
+
+.testdata-thumb {
+ width: 50px;
+ height: 50px;
+ object-fit: cover;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+}
+
+.testdata-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.testdata-name {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text);
+}
+
+.testdata-url {
+ font-size: 11px;
+ color: var(--text-light);
+}
+
+.testdata-empty {
+ text-align: center;
+ padding: 20px;
+ color: var(--text-light);
+ font-size: 13px;
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..292440c
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,62 @@
+
+
+
+
+
+ 即梦AI创作平台
+
+
+
+
+
+
+
+
短剧/漫剧创作
+
多阶段流水线:剧本解析 → 角色/场景生成 → 分镜设计 → 视频生成 → 成片合成
+
+ 8个阶段
+ 流水线
+
+
+
+
+
视频生成
+
支持带货营销、剧情营销、短片创作三种模式
+
+ 带货营销
+ 剧情营销
+ 短片创作
+
+
+
+
+
任务管理
+
查看所有任务状态、查看结果、下载视频
+
+ 实时状态
+
+
+
+
+
+
+
diff --git a/web/js/app.js b/web/js/app.js
new file mode 100644
index 0000000..88f62b3
--- /dev/null
+++ b/web/js/app.js
@@ -0,0 +1,100 @@
+// API调用工具
+const API = {
+ async post(url, data) {
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ if (!resp.ok) {
+ const err = await resp.text();
+ throw new Error(err);
+ }
+ return resp.json();
+ },
+ async get(url) {
+ const resp = await fetch(url);
+ if (!resp.ok) {
+ const err = await resp.text();
+ throw new Error(err);
+ }
+ return resp.json();
+ }
+};
+
+// 状态显示
+function getStatusBadge(status) {
+ const map = {
+ 'CREATED': '已创建',
+ 'QUEUED': '排队中',
+ 'RUNNING': '执行中',
+ 'SUCCESS': '成功',
+ 'FAILED': '失败',
+ 'CANCELED': '已取消'
+ };
+ return map[status] || `${status}`;
+}
+
+// 格式化时间
+function formatTime(ts) {
+ if (!ts) return '-';
+ const d = new Date(ts);
+ return d.toLocaleString('zh-CN');
+}
+
+// 显示提示
+function showAlert(msg, type = 'info') {
+ const alert = document.createElement('div');
+ alert.className = `alert alert-${type}`;
+ alert.textContent = msg;
+ const container = document.querySelector('.container');
+ container.insertBefore(alert, container.firstChild);
+ setTimeout(() => alert.remove(), 5000);
+}
+
+// 轮询任务状态
+function pollTask(taskId, queryFn, onUpdate, interval = 3000) {
+ let timer;
+ const poll = async () => {
+ try {
+ const result = await queryFn(taskId);
+ onUpdate(result);
+ const status = result?.data?.status;
+ if (status === 'SUCCESS' || status === 'FAILED' || status === 'CANCELED') {
+ clearInterval(timer);
+ }
+ } catch (err) {
+ console.error('轮询失败:', err);
+ }
+ };
+ poll();
+ timer = setInterval(poll, interval);
+ return () => clearInterval(timer);
+}
+
+// 获取能力名称
+function getCapabilityName(key) {
+ const map = {
+ 'pippit_novel_agent': '短剧/漫剧',
+ 'pippit_avatar_marketing_agent': '带货营销',
+ 'pippit_story_marketing_agent': '剧情营销',
+ 'pippit_video_part_agent': '短片创作'
+ };
+ return map[key] || key;
+}
+
+// 获取动作名称
+function getActionName(key) {
+ const map = {
+ 'script_analysis': '剧本解析',
+ 'narration_design': '旁白改编',
+ 'character_generate': '角色生成',
+ 'scene_generate': '场景生成',
+ 'storyboard_design': '分镜设计',
+ 'shot_video_generate': '分镜短片',
+ 'shot_video_generate_fast': '分镜短片(Fast)',
+ 'shot_video_compose': '成片合成',
+ 'generate_video': '生成视频'
+ };
+ return map[key] || key;
+}
diff --git a/web/novel.html b/web/novel.html
new file mode 100644
index 0000000..ccff0ff
--- /dev/null
+++ b/web/novel.html
@@ -0,0 +1,234 @@
+
+
+
+
+
+ 短剧/漫剧创作
+
+
+
+
+
+
+
+
+
1.剧本解析
+
2.旁白改编
+
3.角色生成
+
4.场景生成
+
5.分镜设计
+
6.分镜短片
+
7.成片合成
+
+
+
+
+
剧本解析
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
下一阶段
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/static/model/女模特1.png b/web/static/model/女模特1.png
new file mode 100644
index 0000000..5eceeda
Binary files /dev/null and b/web/static/model/女模特1.png differ
diff --git a/web/static/model/女模特2.png b/web/static/model/女模特2.png
new file mode 100644
index 0000000..49b1cec
Binary files /dev/null and b/web/static/model/女模特2.png differ
diff --git a/web/static/model/男模特1.png b/web/static/model/男模特1.png
new file mode 100644
index 0000000..4fddd98
Binary files /dev/null and b/web/static/model/男模特1.png differ
diff --git a/web/static/product/化妆品.png b/web/static/product/化妆品.png
new file mode 100644
index 0000000..0eddd91
Binary files /dev/null and b/web/static/product/化妆品.png differ
diff --git a/web/static/product/礼盒.png b/web/static/product/礼盒.png
new file mode 100644
index 0000000..dc76f65
Binary files /dev/null and b/web/static/product/礼盒.png differ
diff --git a/web/static/product/蛋糕.png b/web/static/product/蛋糕.png
new file mode 100644
index 0000000..27f65b5
Binary files /dev/null and b/web/static/product/蛋糕.png differ
diff --git a/web/tasks.html b/web/tasks.html
new file mode 100644
index 0000000..e522dd0
--- /dev/null
+++ b/web/tasks.html
@@ -0,0 +1,230 @@
+
+
+
+
+
+ 任务管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/video.html b/web/video.html
new file mode 100644
index 0000000..7446191
--- /dev/null
+++ b/web/video.html
@@ -0,0 +1,337 @@
+
+
+
+
+
+ 视频生成
+
+
+
+
+
+
+
+
+
+
+
带货营销视频
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
剧情营销视频
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
短片创作
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/work.md b/work.md
new file mode 100644
index 0000000..8f8a14f
--- /dev/null
+++ b/work.md
@@ -0,0 +1,587 @@
+# 即梦 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 | 是 | 商品图 1~3 张 |
+| `model_image_url_list` | array | 否 | 模特图,最多 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 | 是 | 商品图 1~9 张 |
+| `model_image_url_list` | array | 否 | 模特图,最多 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` 记录下来,供反馈排查。