This commit is contained in:
commit
cbb2ea0054
|
|
@ -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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/jimeng.iml" filepath="$PROJECT_DIR$/.idea/jimeng.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
module jimeng
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
|
@ -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=
|
||||
|
|
@ -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)
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>即梦AI创作平台</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>即梦AI创作平台</h1>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="card" onclick="location.href='novel.html'" style="cursor:pointer">
|
||||
<div class="card-title">短剧/漫剧创作</div>
|
||||
<p style="color:var(--text-light);font-size:14px">多阶段流水线:剧本解析 → 角色/场景生成 → 分镜设计 → 视频生成 → 成片合成</p>
|
||||
<div style="margin-top:12px">
|
||||
<span class="badge badge-created">8个阶段</span>
|
||||
<span class="badge badge-queued" style="margin-left:8px">流水线</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" onclick="location.href='video.html'" style="cursor:pointer">
|
||||
<div class="card-title">视频生成</div>
|
||||
<p style="color:var(--text-light);font-size:14px">支持带货营销、剧情营销、短片创作三种模式</p>
|
||||
<div style="margin-top:12px">
|
||||
<span class="badge badge-success">带货营销</span>
|
||||
<span class="badge badge-success" style="margin-left:8px">剧情营销</span>
|
||||
<span class="badge badge-success" style="margin-left:8px">短片创作</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" onclick="location.href='tasks.html'" style="cursor:pointer">
|
||||
<div class="card-title">任务管理</div>
|
||||
<p style="color:var(--text-light);font-size:14px">查看所有任务状态、查看结果、下载视频</p>
|
||||
<div style="margin-top:12px">
|
||||
<span class="badge badge-running">实时状态</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<a href="index.html" class="nav-item active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
首页
|
||||
</a>
|
||||
<a href="novel.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
短剧
|
||||
</a>
|
||||
<a href="video.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
||||
视频
|
||||
</a>
|
||||
<a href="tasks.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
任务
|
||||
</a>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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': '<span class="badge badge-created">已创建</span>',
|
||||
'QUEUED': '<span class="badge badge-queued">排队中</span>',
|
||||
'RUNNING': '<span class="badge badge-running">执行中</span>',
|
||||
'SUCCESS': '<span class="badge badge-success">成功</span>',
|
||||
'FAILED': '<span class="badge badge-failed">失败</span>',
|
||||
'CANCELED': '<span class="badge badge-canceled">已取消</span>'
|
||||
};
|
||||
return map[status] || `<span class="badge">${status}</span>`;
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>短剧/漫剧创作</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>短剧/漫剧创作</h1>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- 阶段指示器 -->
|
||||
<div class="stages" id="stages">
|
||||
<div class="stage active" data-stage="script_analysis">1.剧本解析</div>
|
||||
<div class="stage" data-stage="narration_design">2.旁白改编</div>
|
||||
<div class="stage" data-stage="character_generate">3.角色生成</div>
|
||||
<div class="stage" data-stage="scene_generate">4.场景生成</div>
|
||||
<div class="stage" data-stage="storyboard_design">5.分镜设计</div>
|
||||
<div class="stage" data-stage="shot_video_generate">6.分镜短片</div>
|
||||
<div class="stage" data-stage="shot_video_compose">7.成片合成</div>
|
||||
</div>
|
||||
|
||||
<!-- 阶段1: 剧本解析 -->
|
||||
<div class="card" id="stage-script_analysis">
|
||||
<div class="card-title">剧本解析</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">视觉风格 *</label>
|
||||
<input type="text" class="form-input" id="visual_style" placeholder="如: 2D,国风,平涂 或 3D,CG动画,写实都市">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">视频比例 *</label>
|
||||
<select class="form-select" id="video_ratio">
|
||||
<option value="16:9">16:9 横屏</option>
|
||||
<option value="9:16">9:16 竖屏</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">剧本文件URL *</label>
|
||||
<input type="text" class="form-input" id="file_url" placeholder="公网可访问的剧本文件链接">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">文件类型 *</label>
|
||||
<select class="form-select" id="file_type">
|
||||
<option value="txt">TXT</option>
|
||||
<option value="docx">DOCX</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">文件名 *</label>
|
||||
<input type="text" class="form-input" id="file_name" placeholder="剧本文件名">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" onclick="submitScriptAnalysis()">提交剧本解析</button>
|
||||
<div id="result-script_analysis"></div>
|
||||
</div>
|
||||
|
||||
<!-- 后续阶段表单(需要thread_id和asset_id) -->
|
||||
<div class="card" id="stage-later" style="display:none">
|
||||
<div class="card-title" id="later-stage-title">下一阶段</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Thread ID</label>
|
||||
<input type="text" class="form-input" id="thread_id" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Asset ID</label>
|
||||
<input type="text" class="form-input" id="asset_id" readonly>
|
||||
</div>
|
||||
<div id="extra-fields"></div>
|
||||
<button class="btn btn-primary btn-block" id="submit-later-btn" onclick="submitLaterStage()">提交</button>
|
||||
<div id="result-later"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<a href="index.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
首页
|
||||
</a>
|
||||
<a href="novel.html" class="nav-item active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
短剧
|
||||
</a>
|
||||
<a href="video.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
||||
视频
|
||||
</a>
|
||||
<a href="tasks.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
任务
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
<script>
|
||||
let currentThreadID = '';
|
||||
let currentAssetID = '';
|
||||
let currentStage = 'script_analysis';
|
||||
|
||||
// 阶段顺序
|
||||
const stageOrder = [
|
||||
'script_analysis', 'narration_design', 'character_generate',
|
||||
'scene_generate', 'storyboard_design', 'shot_video_generate', 'shot_video_compose'
|
||||
];
|
||||
|
||||
function updateStages(completed) {
|
||||
document.querySelectorAll('.stage').forEach(el => {
|
||||
const s = el.dataset.stage;
|
||||
const idx = stageOrder.indexOf(s);
|
||||
const compIdx = stageOrder.indexOf(completed);
|
||||
if (idx < compIdx) el.className = 'stage completed';
|
||||
else if (s === completed) el.className = 'stage active';
|
||||
else el.className = 'stage';
|
||||
});
|
||||
}
|
||||
|
||||
async function submitScriptAnalysis() {
|
||||
const input = {
|
||||
action_key: 'script_analysis',
|
||||
visual_style: document.getElementById('visual_style').value,
|
||||
video_ratio: document.getElementById('video_ratio').value,
|
||||
file_url: document.getElementById('file_url').value,
|
||||
file_type: document.getElementById('file_type').value,
|
||||
file_name: document.getElementById('file_name').value
|
||||
};
|
||||
try {
|
||||
const result = await API.post('/api/novel/submit', input);
|
||||
if (result.data) {
|
||||
currentThreadID = result.data.thread_id || '';
|
||||
showAlert('剧本解析任务已提交!', 'success');
|
||||
// 开始轮询
|
||||
const taskID = result.data.task_id || '';
|
||||
const runID = result.data.run_id || '';
|
||||
pollTask(taskID || runID,
|
||||
(id) => API.post('/api/novel/query', {task_id: taskID, run_id: runID}),
|
||||
(res) => {
|
||||
const status = res.data?.status;
|
||||
if (status === 'SUCCESS') {
|
||||
const novelData = res.data.novel_data;
|
||||
if (novelData) {
|
||||
currentThreadID = novelData.thread_id || currentThreadID;
|
||||
try {
|
||||
const respData = JSON.parse(novelData.resp_data || '{}');
|
||||
currentAssetID = respData.OverviewAssetID || '';
|
||||
} catch(e) {}
|
||||
}
|
||||
document.getElementById('thread_id').value = currentThreadID;
|
||||
document.getElementById('asset_id').value = currentAssetID;
|
||||
updateStages('script_analysis');
|
||||
showNextStage('narration_design');
|
||||
showAlert('剧本解析完成!可以继续下一阶段', 'success');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch(err) {
|
||||
showAlert('提交失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showNextStage(stage) {
|
||||
currentStage = stage;
|
||||
const stageNames = {
|
||||
'narration_design': '旁白改编',
|
||||
'character_generate': '角色生成',
|
||||
'scene_generate': '场景生成',
|
||||
'storyboard_design': '分镜设计',
|
||||
'shot_video_generate': '分镜短片生成',
|
||||
'shot_video_compose': '成片合成'
|
||||
};
|
||||
document.getElementById('later-stage-title').textContent = stageNames[stage] || stage;
|
||||
document.getElementById('stage-later').style.display = 'block';
|
||||
|
||||
// 分镜短片需要额外的shot_ids
|
||||
let extra = '';
|
||||
if (stage === 'shot_video_generate' || stage === 'shot_video_compose') {
|
||||
extra = `<div class="form-group">
|
||||
<label class="form-label">分镜ID列表(逗号分隔)</label>
|
||||
<input type="text" class="form-input" id="shot_ids" placeholder="S1,S2,S3">
|
||||
</div>`;
|
||||
}
|
||||
document.getElementById('extra-fields').innerHTML = extra;
|
||||
updateStages(stage);
|
||||
}
|
||||
|
||||
async function submitLaterStage() {
|
||||
const input = {
|
||||
action_key: currentStage,
|
||||
thread_id: document.getElementById('thread_id').value,
|
||||
asset_id: document.getElementById('asset_id').value
|
||||
};
|
||||
// 分镜阶段需要shot_ids
|
||||
if (currentStage === 'shot_video_generate' || currentStage === 'shot_video_compose') {
|
||||
const shotIds = document.getElementById('shot_ids')?.value;
|
||||
if (shotIds) input.shot_ids = shotIds.split(',').map(s => s.trim());
|
||||
}
|
||||
try {
|
||||
const result = await API.post('/api/novel/submit', input);
|
||||
if (result.data) {
|
||||
showAlert(getActionName(currentStage) + ' 任务已提交!', 'success');
|
||||
const nTaskID = result.data.task_id || '';
|
||||
const nRunID = result.data.run_id || '';
|
||||
pollTask(nTaskID || nRunID,
|
||||
(id) => API.post('/api/novel/query', {task_id: nTaskID, run_id: nRunID}),
|
||||
(res) => {
|
||||
const status = res.data?.status;
|
||||
if (status === 'SUCCESS') {
|
||||
const novelData = res.data.novel_data;
|
||||
if (novelData) {
|
||||
try {
|
||||
const respData = JSON.parse(novelData.resp_data || '{}');
|
||||
if (respData.OverviewAssetID) currentAssetID = respData.OverviewAssetID;
|
||||
if (respData.StoryboardAssetID) currentAssetID = respData.StoryboardAssetID;
|
||||
} catch(e) {}
|
||||
document.getElementById('asset_id').value = currentAssetID;
|
||||
}
|
||||
updateStages(currentStage);
|
||||
const idx = stageOrder.indexOf(currentStage);
|
||||
if (idx < stageOrder.length - 1) {
|
||||
showNextStage(stageOrder[idx + 1]);
|
||||
}
|
||||
showAlert(getActionName(currentStage) + ' 完成!', 'success');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch(err) {
|
||||
showAlert('提交失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 405 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 577 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 403 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 469 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 504 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 495 KiB |
|
|
@ -0,0 +1,230 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>任务管理</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>任务管理</h1>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div id="task-list">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>加载任务列表...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务详情弹窗 -->
|
||||
<div id="modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:200;display:none">
|
||||
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:white;border-radius:12px;padding:20px;width:90%;max-width:500px;max-height:80vh;overflow-y:auto">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h3 style="font-size:16px">任务详情</h3>
|
||||
<button onclick="closeModal()" style="background:none;border:none;font-size:20px;cursor:pointer">×</button>
|
||||
</div>
|
||||
<div id="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<a href="index.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
首页
|
||||
</a>
|
||||
<a href="novel.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
短剧
|
||||
</a>
|
||||
<a href="video.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
||||
视频
|
||||
</a>
|
||||
<a href="tasks.html" class="nav-item active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
任务
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
<script>
|
||||
let pollTimers = {};
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const tasks = await API.get('/api/tasks');
|
||||
renderTasks(tasks);
|
||||
} catch(err) {
|
||||
document.getElementById('task-list').innerHTML =
|
||||
`<div class="empty"><p>加载失败: ${err.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTasks(tasks) {
|
||||
const container = document.getElementById('task-list');
|
||||
if (!tasks || tasks.length === 0) {
|
||||
container.innerHTML = `<div class="empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<p>暂无任务</p>
|
||||
<p style="font-size:13px;margin-top:8px">去首页创建你的第一个任务吧</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = tasks.map(t => `
|
||||
<div class="task-item" id="task-${t.id}">
|
||||
<div class="task-header">
|
||||
<span class="task-title">${getCapabilityName(t.capability)} - ${getActionName(t.action)}</span>
|
||||
${getStatusBadge(t.status)}
|
||||
</div>
|
||||
<div class="task-meta">
|
||||
任务ID: ${t.id}<br>
|
||||
创建时间: ${formatTime(t.created_at)}<br>
|
||||
${t.thread_id ? 'Thread ID: ' + t.thread_id + '<br>' : ''}
|
||||
${t.elapsed_sec ? '耗时: ' + t.elapsed_sec + '秒<br>' : ''}
|
||||
${t.error_code ? '错误: ' + t.error_code + ' ' + t.error_msg + '<br>' : ''}
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button class="btn btn-primary" onclick="refreshTask('${t.id}')">刷新状态</button>
|
||||
<button class="btn btn-success" onclick="viewTask('${t.id}')">查看详情</button>
|
||||
<button class="btn btn-danger" onclick="deleteTask('${t.id}')">删除</button>
|
||||
</div>
|
||||
<div id="result-${t.id}"></div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// 对运行中的任务自动轮询
|
||||
tasks.forEach(t => {
|
||||
if (['CREATED', 'QUEUED', 'RUNNING'].includes(t.status)) {
|
||||
startPolling(t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startPolling(task) {
|
||||
if (pollTimers[task.id]) return;
|
||||
const queryUrl = task.type === 'novel' ? '/api/novel/query' : '/api/video/query';
|
||||
pollTimers[task.id] = pollTask(
|
||||
task.id,
|
||||
(id) => API.post(queryUrl, {task_id: id}),
|
||||
(res) => {
|
||||
const status = res.data?.status;
|
||||
if (status) {
|
||||
const el = document.querySelector(`#task-${task.id} .task-header`);
|
||||
if (el) {
|
||||
el.innerHTML = `<span class="task-title">${getCapabilityName(task.capability)} - ${getActionName(task.action)}</span>${getStatusBadge(status)}`;
|
||||
}
|
||||
if (status === 'SUCCESS') {
|
||||
showTaskResult(task, res.data);
|
||||
}
|
||||
}
|
||||
},
|
||||
5000
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshTask(id) {
|
||||
try {
|
||||
const task = await API.get('/api/task?id=' + id);
|
||||
const queryUrl = task.type === 'novel' ? '/api/novel/query' : '/api/video/query';
|
||||
const result = await API.post(queryUrl, {task_id: id});
|
||||
if (result.data) {
|
||||
const el = document.querySelector(`#task-${id} .task-header`);
|
||||
if (el) {
|
||||
el.innerHTML = `<span class="task-title">${getCapabilityName(task.capability)} - ${getActionName(task.action)}</span>${getStatusBadge(result.data.status)}`;
|
||||
}
|
||||
if (result.data.status === 'SUCCESS') {
|
||||
showTaskResult(task, result.data);
|
||||
}
|
||||
showAlert('状态已更新', 'success');
|
||||
}
|
||||
} catch(err) {
|
||||
showAlert('刷新失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showTaskResult(task, data) {
|
||||
const container = document.getElementById('result-' + task.id);
|
||||
if (!container) return;
|
||||
if (task.type === 'video' && data.video_artifact) {
|
||||
const a = data.video_artifact;
|
||||
if (a.url) {
|
||||
container.innerHTML = `<div class="result">
|
||||
<div class="result-title">视频成品</div>
|
||||
<video class="result-video" controls src="${a.url}"></video>
|
||||
<p style="margin-top:8px;font-size:12px;color:var(--text-light)">
|
||||
时长: ${a.duration}s | 分辨率: ${a.width}x${a.height}
|
||||
</p>
|
||||
<a href="${a.url}" download class="btn btn-success" style="margin-top:8px;display:inline-block">下载视频</a>
|
||||
</div>`;
|
||||
}
|
||||
} else if (task.type === 'novel' && data.novel_data) {
|
||||
container.innerHTML = `<div class="result">
|
||||
<div class="result-title">短剧数据</div>
|
||||
<pre style="font-size:12px;overflow-x:auto;white-space:pre-wrap">${JSON.stringify(data.novel_data, null, 2)}</pre>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function viewTask(id) {
|
||||
try {
|
||||
const task = await API.get('/api/task?id=' + id);
|
||||
const modal = document.getElementById('modal');
|
||||
const content = document.getElementById('modal-content');
|
||||
content.innerHTML = `
|
||||
<div style="font-size:13px">
|
||||
<p><strong>任务ID:</strong> ${task.id}</p>
|
||||
<p><strong>类型:</strong> ${task.type}</p>
|
||||
<p><strong>能力:</strong> ${getCapabilityName(task.capability)}</p>
|
||||
<p><strong>动作:</strong> ${getActionName(task.action)}</p>
|
||||
<p><strong>状态:</strong> ${getStatusBadge(task.status)}</p>
|
||||
<p><strong>Run ID:</strong> ${task.run_id || '-'}</p>
|
||||
<p><strong>Thread ID:</strong> ${task.thread_id || '-'}</p>
|
||||
<p><strong>Asset ID:</strong> ${task.asset_id || '-'}</p>
|
||||
<p><strong>创建时间:</strong> ${formatTime(task.created_at)}</p>
|
||||
<p><strong>更新时间:</strong> ${formatTime(task.updated_at)}</p>
|
||||
${task.error_code ? `<p><strong>错误码:</strong> ${task.error_code}</p><p><strong>错误信息:</strong> ${task.error_msg}</p>` : ''}
|
||||
${task.usage ? `<p><strong>计费:</strong> <pre style="font-size:11px">${JSON.stringify(task.usage, null, 2)}</pre></p>` : ''}
|
||||
<p><strong>输入参数:</strong></p>
|
||||
<pre style="font-size:11px;background:#f8fafc;padding:8px;border-radius:4px;overflow-x:auto">${JSON.stringify(task.input, null, 2)}</pre>
|
||||
${task.output ? `<p style="margin-top:8px"><strong>输出结果:</strong></p>
|
||||
<pre style="font-size:11px;background:#f0fdf4;padding:8px;border-radius:4px;overflow-x:auto">${JSON.stringify(task.output, null, 2)}</pre>` : ''}
|
||||
</div>
|
||||
`;
|
||||
modal.style.display = 'block';
|
||||
} catch(err) {
|
||||
showAlert('获取详情失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
if (!confirm('确定要删除这个任务吗?')) return;
|
||||
try {
|
||||
await API.post('/api/task/delete', {id: id});
|
||||
document.getElementById('task-' + id)?.remove();
|
||||
showAlert('任务已删除', 'success');
|
||||
} catch(err) {
|
||||
showAlert('删除失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal').style.display = 'none';
|
||||
}
|
||||
|
||||
// 点击弹窗外部关闭
|
||||
document.getElementById('modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal();
|
||||
});
|
||||
|
||||
// 初始加载
|
||||
loadTasks();
|
||||
// 每30秒自动刷新
|
||||
setInterval(loadTasks, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>视频生成</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>视频生成</h1>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('marketing')">带货营销</div>
|
||||
<div class="tab" onclick="switchTab('story')">剧情营销</div>
|
||||
<div class="tab" onclick="switchTab('short')">短片创作</div>
|
||||
</div>
|
||||
|
||||
<!-- 带货营销 -->
|
||||
<div class="card" id="tab-marketing">
|
||||
<div class="card-title">带货营销视频</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">商品名称 *</label>
|
||||
<input type="text" class="form-input" id="m_product_name" placeholder="输入商品名称">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">商品图片URL(1-3张,逗号分隔)*</label>
|
||||
<textarea class="form-textarea" id="m_product_images" placeholder="https://example.com/img1.jpg,https://example.com/img2.jpg"></textarea>
|
||||
<button type="button" class="btn-testdata" onclick="showTestData('product','m_product_images')">📦 使用测试数据</button>
|
||||
<div id="testdata-m_product_images" class="testdata-picker" style="display:none"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">模特图片URL(最多1张)</label>
|
||||
<input type="text" class="form-input" id="m_model_image" placeholder="可选">
|
||||
<button type="button" class="btn-testdata" onclick="showTestData('model','m_model_image')">👤 使用测试数据</button>
|
||||
<div id="testdata-m_model_image" class="testdata-picker" style="display:none"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">营销Prompt</label>
|
||||
<textarea class="form-textarea" id="m_prompt" placeholder="自定义营销prompt(可选)"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:8px">
|
||||
<input type="checkbox" id="m_watermark"> 添加水印
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" onclick="submitMarketing()">提交带货营销视频</button>
|
||||
<div id="result-marketing"></div>
|
||||
</div>
|
||||
|
||||
<!-- 剧情营销 -->
|
||||
<div class="card" id="tab-story" style="display:none">
|
||||
<div class="card-title">剧情营销视频</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">商品名称 *</label>
|
||||
<input type="text" class="form-input" id="s_product_name" placeholder="输入商品名称">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">商品图片URL(1-9张,逗号分隔)*</label>
|
||||
<textarea class="form-textarea" id="s_product_images" placeholder="https://example.com/img1.jpg"></textarea>
|
||||
<button type="button" class="btn-testdata" onclick="showTestData('product','s_product_images')">📦 使用测试数据</button>
|
||||
<div id="testdata-s_product_images" class="testdata-picker" style="display:none"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">模特图片URL(最多1张)</label>
|
||||
<input type="text" class="form-input" id="s_model_image" placeholder="可选">
|
||||
<button type="button" class="btn-testdata" onclick="showTestData('model','s_model_image')">👤 使用测试数据</button>
|
||||
<div id="testdata-s_model_image" class="testdata-picker" style="display:none"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">模型</label>
|
||||
<select class="form-select" id="s_model">
|
||||
<option value="seedance20_fast">Seedance 2.0 Fast</option>
|
||||
<option value="seedance25">Seedance 2.5</option>
|
||||
<option value="seedance20">Seedance 2.0</option>
|
||||
<option value="seedance20_mini">Seedance 2.0 Mini</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">时长(秒)</label>
|
||||
<input type="number" class="form-input" id="s_duration" value="15" min="5" max="30">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">剧情Prompt</label>
|
||||
<textarea class="form-textarea" id="s_prompt" placeholder="自定义剧情prompt(可选)"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:8px">
|
||||
<input type="checkbox" id="s_watermark"> 添加水印
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" onclick="submitStory()">提交剧情营销视频</button>
|
||||
<div id="result-story"></div>
|
||||
</div>
|
||||
|
||||
<!-- 短片创作 -->
|
||||
<div class="card" id="tab-short" style="display:none">
|
||||
<div class="card-title">短片创作</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">提示词 *</label>
|
||||
<textarea class="form-textarea" id="sh_prompt" placeholder="描述你想创作的视频内容"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">参考图片(最多9张,逗号分隔)</label>
|
||||
<textarea class="form-textarea" id="sh_images" placeholder="https://example.com/ref1.jpg"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">模型</label>
|
||||
<select class="form-select" id="sh_model">
|
||||
<option value="seedance20_fast">Seedance 2.0 Fast</option>
|
||||
<option value="seedance25">Seedance 2.5</option>
|
||||
<option value="seedance20">Seedance 2.0</option>
|
||||
<option value="seedance20_mini">Seedance 2.0 Mini</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">时长(秒)</label>
|
||||
<input type="number" class="form-input" id="sh_duration" value="15" min="5" max="30">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">画面比例</label>
|
||||
<select class="form-select" id="sh_ratio">
|
||||
<option value="16:9">16:9</option>
|
||||
<option value="9:16">9:16</option>
|
||||
<option value="1:1">1:1</option>
|
||||
<option value="4:3">4:3</option>
|
||||
<option value="3:4">3:4</option>
|
||||
<option value="21:9">21:9</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">分辨率</label>
|
||||
<select class="form-select" id="sh_resolution">
|
||||
<option value="720P">720P</option>
|
||||
<option value="480P">480P</option>
|
||||
<option value="1080P">1080P</option>
|
||||
<option value="4K">4K</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:8px">
|
||||
<input type="checkbox" id="sh_watermark"> 添加水印
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" onclick="submitShort()">提交短片创作</button>
|
||||
<div id="result-short"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<a href="index.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
首页
|
||||
</a>
|
||||
<a href="novel.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
短剧
|
||||
</a>
|
||||
<a href="video.html" class="nav-item active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
||||
视频
|
||||
</a>
|
||||
<a href="tasks.html" class="nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
任务
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
<script>
|
||||
let testDataCache = null;
|
||||
|
||||
async function loadTestData() {
|
||||
if (testDataCache) return testDataCache;
|
||||
testDataCache = await API.get('/api/testdata');
|
||||
return testDataCache;
|
||||
}
|
||||
|
||||
async function showTestData(type, targetId) {
|
||||
const picker = document.getElementById('testdata-' + targetId);
|
||||
// 切换显示
|
||||
if (picker.style.display !== 'none') {
|
||||
picker.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const data = await loadTestData();
|
||||
const items = type === 'product' ? (data.test_product || []) : (data.test_model || []);
|
||||
if (items.length === 0) {
|
||||
picker.innerHTML = '<div class="testdata-empty">暂无测试数据</div>';
|
||||
picker.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
picker.innerHTML = items.map(item => `
|
||||
<div class="testdata-item" onclick="pickTestData('${targetId}','${item.url}','${item.name}',this)">
|
||||
<img src="${item.url}" class="testdata-thumb" onerror="this.style.display='none'">
|
||||
<div class="testdata-info">
|
||||
<span class="testdata-name">${item.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
picker.style.display = 'block';
|
||||
}
|
||||
|
||||
function pickTestData(targetId, url, name, el) {
|
||||
const target = document.getElementById(targetId);
|
||||
if (target.tagName === 'TEXTAREA') {
|
||||
// 商品图片: 追加到现有内容
|
||||
const cur = target.value.trim();
|
||||
if (cur) {
|
||||
target.value = cur + ',' + url;
|
||||
} else {
|
||||
target.value = url;
|
||||
}
|
||||
} else {
|
||||
// 模特图片: 直接替换
|
||||
target.value = url;
|
||||
}
|
||||
// 高亮选中
|
||||
el.parentElement.querySelectorAll('.testdata-item').forEach(i => i.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('[id^="tab-"]').forEach(c => c.style.display = 'none');
|
||||
event.target.classList.add('active');
|
||||
document.getElementById('tab-' + tab).style.display = 'block';
|
||||
}
|
||||
|
||||
async function submitMarketing() {
|
||||
const input = {
|
||||
product_name: document.getElementById('m_product_name').value,
|
||||
product_image_url_list: document.getElementById('m_product_images').value.split(',').map(s => s.trim()).filter(Boolean),
|
||||
enable_watermark: document.getElementById('m_watermark').checked
|
||||
};
|
||||
const modelImg = document.getElementById('m_model_image').value.trim();
|
||||
if (modelImg) input.model_image_url_list = [modelImg];
|
||||
const prompt = document.getElementById('m_prompt').value.trim();
|
||||
if (prompt) input.prompt = prompt;
|
||||
|
||||
try {
|
||||
const result = await API.post('/api/video/submit', {
|
||||
capability_key: 'pippit_avatar_marketing_agent',
|
||||
input: input
|
||||
});
|
||||
handleVideoResult(result, 'marketing');
|
||||
} catch(err) {
|
||||
showAlert('提交失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitStory() {
|
||||
const input = {
|
||||
product_name: document.getElementById('s_product_name').value,
|
||||
product_image_url_list: document.getElementById('s_product_images').value.split(',').map(s => s.trim()).filter(Boolean),
|
||||
model: document.getElementById('s_model').value,
|
||||
duration: parseInt(document.getElementById('s_duration').value),
|
||||
enable_watermark: document.getElementById('s_watermark').checked
|
||||
};
|
||||
const modelImg = document.getElementById('s_model_image').value.trim();
|
||||
if (modelImg) input.model_image_url_list = [modelImg];
|
||||
const prompt = document.getElementById('s_prompt').value.trim();
|
||||
if (prompt) input.prompt = prompt;
|
||||
|
||||
try {
|
||||
const result = await API.post('/api/video/submit', {
|
||||
capability_key: 'pippit_story_marketing_agent',
|
||||
input: input
|
||||
});
|
||||
handleVideoResult(result, 'story');
|
||||
} catch(err) {
|
||||
showAlert('提交失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitShort() {
|
||||
const input = {
|
||||
prompt: document.getElementById('sh_prompt').value,
|
||||
model: document.getElementById('sh_model').value,
|
||||
duration: parseInt(document.getElementById('sh_duration').value),
|
||||
ratio: document.getElementById('sh_ratio').value,
|
||||
resolution: document.getElementById('sh_resolution').value,
|
||||
enable_watermark: document.getElementById('sh_watermark').checked
|
||||
};
|
||||
const images = document.getElementById('sh_images').value.trim();
|
||||
if (images) input.image_url_list = images.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
try {
|
||||
const result = await API.post('/api/video/submit', {
|
||||
capability_key: 'pippit_video_part_agent',
|
||||
input: input
|
||||
});
|
||||
handleVideoResult(result, 'short');
|
||||
} catch(err) {
|
||||
showAlert('提交失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function handleVideoResult(result, tab) {
|
||||
if (result.data) {
|
||||
showAlert('视频任务已提交!', 'success');
|
||||
const taskID = result.data.task_id || '';
|
||||
const runID = result.data.run_id || '';
|
||||
pollTask(taskID || runID,
|
||||
(id) => API.post('/api/video/query', {task_id: taskID, run_id: runID}),
|
||||
(res) => {
|
||||
const status = res.data?.status;
|
||||
const container = document.getElementById('result-' + tab);
|
||||
container.innerHTML = `<div class="result"><div class="result-title">状态: ${getStatusBadge(status)}</div></div>`;
|
||||
if (status === 'SUCCESS') {
|
||||
const artifact = res.data.video_artifact;
|
||||
if (artifact && artifact.url) {
|
||||
container.innerHTML = `
|
||||
<div class="result">
|
||||
<div class="result-title">视频生成成功!</div>
|
||||
<video class="result-video" controls src="${artifact.url}"></video>
|
||||
<p style="margin-top:8px;font-size:12px;color:var(--text-light)">
|
||||
时长: ${artifact.duration}s | 分辨率: ${artifact.width}x${artifact.height}
|
||||
</p>
|
||||
<a href="${artifact.url}" download class="btn btn-success" style="margin-top:8px;display:inline-block">下载视频</a>
|
||||
</div>`;
|
||||
}
|
||||
} else if (status === 'FAILED') {
|
||||
container.innerHTML = `<div class="result" style="background:#fee2e2;border-color:#fca5a5">
|
||||
<div class="result-title" style="color:#991b1b">生成失败</div>
|
||||
<p style="font-size:13px">错误码: ${res.data.err_code || '-'}<br>错误信息: ${res.data.err_msg || '-'}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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<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` 记录下来,供反馈排查。
|
||||
Loading…
Reference in New Issue