@pippit-dev/cli 1.0.25 → 1.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/checksums.txt +6 -6
- package/cmd/generate_image/generate_image.go +1 -0
- package/cmd/generate_video/generate_video.go +7 -6
- package/cmd/generate_video_test.go +57 -55
- package/cmd/model.go +74 -0
- package/cmd/model_test.go +81 -0
- package/cmd/root.go +1 -0
- package/cmd/short_drama/short_drama.go +1 -0
- package/cmd/source_test.go +179 -0
- package/cmd/submit_run.go +3 -0
- package/cmd/update/update.go +1 -1
- package/cmd/video_tool/video_tool.go +2 -0
- package/dist/checksums.txt +6 -6
- package/internal/common/submit_run.go +38 -1
- package/internal/config/config.go +13 -10
- package/internal/config/config_test.go +3 -0
- package/internal/generate_image/generate_image.go +2 -1
- package/internal/generate_video/generate_video.go +12 -18
- package/internal/generate_video/generate_video_test.go +126 -0
- package/internal/models/describe.go +281 -0
- package/internal/models/describe_test.go +199 -0
- package/internal/models/models.go +220 -0
- package/internal/models/models_test.go +200 -0
- package/internal/short_drama/submit_run.go +2 -0
- package/internal/video_tool/video_tool.go +4 -2
- package/package.json +1 -1
- package/skills/short-drama/SKILL.md +4 -0
- package/skills/xyq-nest-skill/SKILL.md +10 -0
- package/skills/xyq-nest-skill/commands/erase-video-subtitle.md +2 -0
- package/skills/xyq-nest-skill/commands/generate-image.md +2 -0
- package/skills/xyq-nest-skill/commands/generate-video.md +34 -10
- package/skills/xyq-nest-skill/commands/model.md +59 -0
- package/skills/xyq-nest-skill/commands/video-super-resolution.md +2 -0
- package/skills/xyq-nest-skill/scripts/ensure-cli.js +2 -2
- package/skills/xyq-nest-skill/scripts/install.md +1 -1
|
@@ -2,7 +2,10 @@ package common
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"encoding/json"
|
|
5
6
|
"fmt"
|
|
7
|
+
"net/url"
|
|
8
|
+
"strings"
|
|
6
9
|
|
|
7
10
|
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
8
11
|
)
|
|
@@ -37,11 +40,36 @@ type SubmitRunResponseRun struct {
|
|
|
37
40
|
|
|
38
41
|
// SubmitRun sends a submit_run request and validates its shared response envelope.
|
|
39
42
|
func SubmitRun(ctx context.Context, command string, body any, runner *Runner) (*SubmitRunResult, error) {
|
|
43
|
+
return submitRun(ctx, command, SubmitRunPath(runner), body, runner)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// SubmitRunWithBabiParam carries client attribution in the same query field as Web.
|
|
47
|
+
func SubmitRunWithBabiParam(ctx context.Context, command string, body any, runner *Runner, babiParam map[string]string) (*SubmitRunResult, error) {
|
|
48
|
+
path, err := url.Parse(SubmitRunPath(runner))
|
|
49
|
+
if err != nil {
|
|
50
|
+
return nil, fmt.Errorf("解析 submit_run 路径失败: %w", err)
|
|
51
|
+
}
|
|
52
|
+
query, err := url.ParseQuery(path.RawQuery)
|
|
53
|
+
if err != nil {
|
|
54
|
+
return nil, fmt.Errorf("解析 submit_run 查询参数失败: %w", err)
|
|
55
|
+
}
|
|
56
|
+
raw, err := json.Marshal(babiParam)
|
|
57
|
+
if err != nil {
|
|
58
|
+
return nil, fmt.Errorf("序列化 babi_param 失败: %w", err)
|
|
59
|
+
}
|
|
60
|
+
if !query.Has("babi_param") {
|
|
61
|
+
query.Set("babi_param", string(raw))
|
|
62
|
+
}
|
|
63
|
+
path.RawQuery = query.Encode()
|
|
64
|
+
return submitRun(ctx, command, path.String(), body, runner)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func submitRun(ctx context.Context, command, path string, body any, runner *Runner) (*SubmitRunResult, error) {
|
|
40
68
|
if runner == nil || runner.Client == nil {
|
|
41
69
|
return nil, fmt.Errorf("%s 运行器客户端缺失", command)
|
|
42
70
|
}
|
|
43
71
|
var resp SubmitRunResponse
|
|
44
|
-
if err := runner.Client.SendRequest(ctx,
|
|
72
|
+
if err := runner.Client.SendRequest(ctx, path, body, &resp); err != nil {
|
|
45
73
|
return nil, fmt.Errorf("提交 %s 请求失败: %w", command, err)
|
|
46
74
|
}
|
|
47
75
|
if resp.Ret != "0" {
|
|
@@ -71,3 +99,12 @@ func SubmitRunPath(runner *Runner) string {
|
|
|
71
99
|
}
|
|
72
100
|
return config.SubmitRunPath
|
|
73
101
|
}
|
|
102
|
+
|
|
103
|
+
// WithSubmitRunSource adds optional host attribution to a newly constructed skill request.
|
|
104
|
+
// The API consumes this metadata as platform; it is not part of the creative input.
|
|
105
|
+
func WithSubmitRunSource(body map[string]any, source string) map[string]any {
|
|
106
|
+
if source = strings.TrimSpace(source); source != "" {
|
|
107
|
+
body["platform"] = source
|
|
108
|
+
}
|
|
109
|
+
return body
|
|
110
|
+
}
|
|
@@ -13,6 +13,7 @@ const (
|
|
|
13
13
|
DefaultAuthStoreServiceName = "pippit-cli"
|
|
14
14
|
SubmitRunPath = "/api/biz/v1/skill/submit_run"
|
|
15
15
|
GetCreditBalancePath = "/api/biz/v1/skill/get_credit_balance"
|
|
16
|
+
GetAvailableModelListPath = "/api/biz/v1/skill/get_available_model_list"
|
|
16
17
|
GetThreadPath = "/api/biz/v1/skill/get_thread"
|
|
17
18
|
UploadFilePath = "/api/biz/v1/skill/upload_file"
|
|
18
19
|
ListThreadFilePath = "/api/biz/v1/skill/list_thread_file"
|
|
@@ -30,11 +31,12 @@ type Config struct {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
type Paths struct {
|
|
33
|
-
SubmitRun
|
|
34
|
-
GetCreditBalance
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
SubmitRun string
|
|
35
|
+
GetCreditBalance string
|
|
36
|
+
GetAvailableModelList string
|
|
37
|
+
GetThread string
|
|
38
|
+
UploadFile string
|
|
39
|
+
ListThreadFile string
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
// Load resolves the built-in runtime config.
|
|
@@ -45,11 +47,12 @@ func Load() *Config {
|
|
|
45
47
|
AuthTTL: DefaultAuthTTL,
|
|
46
48
|
AccessKey: strings.TrimSpace(os.Getenv(EnvXYQAccessKey)),
|
|
47
49
|
Paths: &Paths{
|
|
48
|
-
SubmitRun:
|
|
49
|
-
GetCreditBalance:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
SubmitRun: SubmitRunPath,
|
|
51
|
+
GetCreditBalance: GetCreditBalancePath,
|
|
52
|
+
GetAvailableModelList: GetAvailableModelListPath,
|
|
53
|
+
GetThread: GetThreadPath,
|
|
54
|
+
UploadFile: UploadFilePath,
|
|
55
|
+
ListThreadFile: ListThreadFilePath,
|
|
53
56
|
},
|
|
54
57
|
}
|
|
55
58
|
}
|
|
@@ -23,6 +23,9 @@ func TestLoadUsesDefaultConfig(t *testing.T) {
|
|
|
23
23
|
if cfg.Paths.GetCreditBalance != GetCreditBalancePath {
|
|
24
24
|
t.Fatalf("GetCreditBalance path = %q, want %q", cfg.Paths.GetCreditBalance, GetCreditBalancePath)
|
|
25
25
|
}
|
|
26
|
+
if cfg.Paths.GetAvailableModelList != GetAvailableModelListPath {
|
|
27
|
+
t.Fatalf("GetAvailableModelList path = %q, want %q", cfg.Paths.GetAvailableModelList, GetAvailableModelListPath)
|
|
28
|
+
}
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
func TestLoadReadsAccessKey(t *testing.T) {
|
|
@@ -23,6 +23,7 @@ const ratioUsage = "enum values: 0=原始比例/自动, 2=16:9(横屏), 13=21:9(
|
|
|
23
23
|
|
|
24
24
|
// Options is the stable command-facing request shape for generate-image.
|
|
25
25
|
type Options struct {
|
|
26
|
+
Source string
|
|
26
27
|
Prompt string
|
|
27
28
|
ImagePaths []string
|
|
28
29
|
Model string
|
|
@@ -151,7 +152,7 @@ func buildSubmitRunBody(opts *Options, imageAssetIDs []string) map[string]any {
|
|
|
151
152
|
if len(imageAssetIDs) > 0 {
|
|
152
153
|
body["asset_ids"] = imageAssetIDs
|
|
153
154
|
}
|
|
154
|
-
return body
|
|
155
|
+
return common.WithSubmitRunSource(body, opts.Source)
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
func parseRatio(raw string) (*int, error) {
|
|
@@ -9,12 +9,6 @@ import (
|
|
|
9
9
|
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
-
const (
|
|
13
|
-
maxReferenceImages = 9
|
|
14
|
-
maxReferenceVideos = 3
|
|
15
|
-
maxReferenceAudios = 3
|
|
16
|
-
)
|
|
17
|
-
|
|
18
12
|
var (
|
|
19
13
|
allowedImageExtensionList = []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"}
|
|
20
14
|
allowedAudioExtensionList = []string{".mp3", ".wav"}
|
|
@@ -24,6 +18,7 @@ var (
|
|
|
24
18
|
|
|
25
19
|
// Options is the stable command-facing request shape for generate-video.
|
|
26
20
|
type Options struct {
|
|
21
|
+
Source string
|
|
27
22
|
Prompt string
|
|
28
23
|
ImagePaths []string
|
|
29
24
|
VideoPaths []string
|
|
@@ -60,7 +55,15 @@ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, er
|
|
|
60
55
|
}
|
|
61
56
|
|
|
62
57
|
body := buildSubmitRunBody(opts, imageAssetIDs, videoAssetIDs, audioAssetIDs)
|
|
63
|
-
|
|
58
|
+
// Business attribution belongs to this command, regardless of the selected model.
|
|
59
|
+
return common.SubmitRunWithBabiParam(ctx, "generate-video", body, runner, map[string]string{
|
|
60
|
+
"scene_lv1": "ai_agent",
|
|
61
|
+
"scene_lv2": "front_tool",
|
|
62
|
+
"tool_id": "instant_video",
|
|
63
|
+
"tab_name": "other",
|
|
64
|
+
"edit_type": "instant_video",
|
|
65
|
+
"enter_from": "skill",
|
|
66
|
+
})
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
func ValidateOptions(opts *Options) error {
|
|
@@ -70,15 +73,6 @@ func ValidateOptions(opts *Options) error {
|
|
|
70
73
|
if strings.TrimSpace(opts.Prompt) == "" {
|
|
71
74
|
return fmt.Errorf("缺少必填参数 --prompt")
|
|
72
75
|
}
|
|
73
|
-
if len(opts.ImagePaths) > maxReferenceImages {
|
|
74
|
-
return fmt.Errorf("参考图片最多支持 %d 个,当前传入 %d 个", maxReferenceImages, len(opts.ImagePaths))
|
|
75
|
-
}
|
|
76
|
-
if len(opts.VideoPaths) > maxReferenceVideos {
|
|
77
|
-
return fmt.Errorf("参考视频最多支持 %d 个,当前传入 %d 个", maxReferenceVideos, len(opts.VideoPaths))
|
|
78
|
-
}
|
|
79
|
-
if len(opts.AudioPaths) > maxReferenceAudios {
|
|
80
|
-
return fmt.Errorf("参考音频最多支持 %d 个,当前传入 %d 个", maxReferenceAudios, len(opts.AudioPaths))
|
|
81
|
-
}
|
|
82
76
|
if err := validateMediaExtensions("图片", opts.ImagePaths, allowedImageExtensions, allowedImageExtensionList); err != nil {
|
|
83
77
|
return err
|
|
84
78
|
}
|
|
@@ -131,11 +125,11 @@ func buildSubmitRunBody(opts *Options, imageAssetIDs []string, videoAssetIDs []s
|
|
|
131
125
|
GenerateType: opts.GenerateType,
|
|
132
126
|
}
|
|
133
127
|
|
|
134
|
-
return map[string]any{
|
|
128
|
+
return common.WithSubmitRunSource(map[string]any{
|
|
135
129
|
"agent_name": common.AgentNameVideoPart,
|
|
136
130
|
"message": prompt,
|
|
137
131
|
"video_part_tool_param": param,
|
|
138
|
-
}
|
|
132
|
+
}, opts.Source)
|
|
139
133
|
}
|
|
140
134
|
|
|
141
135
|
func assetRefs(assetIDs []string) []*common.MediaAsset {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
package generate_video
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"encoding/json"
|
|
6
|
+
"net/url"
|
|
5
7
|
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
10
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
6
11
|
)
|
|
7
12
|
|
|
8
13
|
func TestBuildSubmitRunBodyPreservesEmptyPrompt(t *testing.T) {
|
|
@@ -15,3 +20,124 @@ func TestBuildSubmitRunBodyPreservesEmptyPrompt(t *testing.T) {
|
|
|
15
20
|
t.Fatalf("video_part_tool_param = %s, want explicit empty prompt", got)
|
|
16
21
|
}
|
|
17
22
|
}
|
|
23
|
+
|
|
24
|
+
type videoSubmitRecordingClient struct {
|
|
25
|
+
common.Client
|
|
26
|
+
path string
|
|
27
|
+
body any
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func (c *videoSubmitRecordingClient) SendRequest(_ context.Context, path string, body, out any) error {
|
|
31
|
+
c.path, c.body = path, body
|
|
32
|
+
return json.Unmarshal([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread_123","run_id":"run_123"}}}`), out)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func TestRunCarriesVideoROIQuery(t *testing.T) {
|
|
36
|
+
for _, model := range []string{
|
|
37
|
+
"MiniMax-H3", "MiniMax-H3-Max", "wan3.0", "happyhorse-1.1",
|
|
38
|
+
"Seedance_2.5", "seedance2.0_vision", "seedance2.0_fast_vision",
|
|
39
|
+
"Seedance_2.0_mini", "Seedance_2.0_mini_lite", "", "future-model",
|
|
40
|
+
} {
|
|
41
|
+
t.Run(model, func(t *testing.T) {
|
|
42
|
+
client := &videoSubmitRecordingClient{}
|
|
43
|
+
runner := &common.Runner{
|
|
44
|
+
Client: client,
|
|
45
|
+
Config: &config.Config{Paths: &config.Paths{SubmitRun: "/custom/submit_run?source=a%2Bb&item=1&item=2"}},
|
|
46
|
+
}
|
|
47
|
+
if _, err := Run(context.Background(), &Options{Prompt: "cat walking", Model: model}, runner); err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
path, err := url.Parse(client.path)
|
|
51
|
+
if err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
query := path.Query()
|
|
55
|
+
if path.Path != "/custom/submit_run" || query.Get("source") != "a+b" || len(query["item"]) != 2 {
|
|
56
|
+
t.Fatalf("configured path/query lost: %s", client.path)
|
|
57
|
+
}
|
|
58
|
+
var babi map[string]string
|
|
59
|
+
if err := json.Unmarshal([]byte(query.Get("babi_param")), &babi); err != nil {
|
|
60
|
+
t.Fatal(err)
|
|
61
|
+
}
|
|
62
|
+
for key, want := range map[string]string{
|
|
63
|
+
"scene_lv1": "ai_agent", "scene_lv2": "front_tool", "tool_id": "instant_video",
|
|
64
|
+
"tab_name": "other", "edit_type": "instant_video", "enter_from": "skill",
|
|
65
|
+
} {
|
|
66
|
+
if babi[key] != want {
|
|
67
|
+
t.Fatalf("babi_param[%s] = %q, want %q", key, babi[key], want)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
body := client.body.(map[string]any)
|
|
71
|
+
if body["agent_name"] != common.AgentNameVideoPart || body["message"] != "cat walking" || len(body) != 3 {
|
|
72
|
+
t.Fatalf("ROI must not change the request body contract: %#v", body)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func TestRunPreservesExplicitROIQuery(t *testing.T) {
|
|
79
|
+
for _, model := range []string{"MiniMax-H3", "Seedance_2.0_mini"} {
|
|
80
|
+
t.Run(model, func(t *testing.T) {
|
|
81
|
+
client := &videoSubmitRecordingClient{}
|
|
82
|
+
raw := `{"scene_lv1":"ai_agent","scene_lv2":"front_tool","tool_id":"custom_video"}`
|
|
83
|
+
runner := &common.Runner{Client: client, Config: &config.Config{Paths: &config.Paths{
|
|
84
|
+
SubmitRun: "/custom/submit_run?babi_param=" + url.QueryEscape(raw),
|
|
85
|
+
}}}
|
|
86
|
+
if _, err := Run(context.Background(), &Options{Prompt: "cat walking", Model: model}, runner); err != nil {
|
|
87
|
+
t.Fatal(err)
|
|
88
|
+
}
|
|
89
|
+
path, err := url.Parse(client.path)
|
|
90
|
+
if err != nil || path.Query().Get("babi_param") != raw {
|
|
91
|
+
t.Fatalf("explicit attribution overwritten: %s, %v", client.path, err)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func TestRunRejectsMalformedQueryBeforeSubmit(t *testing.T) {
|
|
98
|
+
client := &videoSubmitRecordingClient{}
|
|
99
|
+
runner := &common.Runner{Client: client, Config: &config.Config{Paths: &config.Paths{
|
|
100
|
+
SubmitRun: "/custom/submit_run?source=%invalid",
|
|
101
|
+
}}}
|
|
102
|
+
if _, err := Run(context.Background(), &Options{Prompt: "cat walking", Model: "MiniMax-H3"}, runner); err == nil {
|
|
103
|
+
t.Fatal("expected malformed URL query error")
|
|
104
|
+
}
|
|
105
|
+
if client.path != "" {
|
|
106
|
+
t.Fatal("malformed URL must not submit a request")
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func TestConfiguredVideoModelParametersPassThrough(t *testing.T) {
|
|
111
|
+
for _, model := range []string{"MiniMax-H3", "MiniMax-H3-Max", "wan3.0", "happyhorse-1.1"} {
|
|
112
|
+
t.Run(model, func(t *testing.T) {
|
|
113
|
+
opts := &Options{Prompt: "cat walking", Model: model, Ratio: "16:9"}
|
|
114
|
+
if err := ValidateOptions(opts); err != nil {
|
|
115
|
+
t.Fatal(err)
|
|
116
|
+
}
|
|
117
|
+
data, err := json.Marshal(buildSubmitRunBody(opts, nil, nil, nil))
|
|
118
|
+
if err != nil {
|
|
119
|
+
t.Fatal(err)
|
|
120
|
+
}
|
|
121
|
+
var body struct {
|
|
122
|
+
AgentName string `json:"agent_name"`
|
|
123
|
+
Param map[string]any `json:"video_part_tool_param"`
|
|
124
|
+
}
|
|
125
|
+
if err := json.Unmarshal(data, &body); err != nil {
|
|
126
|
+
t.Fatal(err)
|
|
127
|
+
}
|
|
128
|
+
if body.AgentName != "pippit_video_part_agent" || body.Param["model"] != model || body.Param["ratio"] != "16:9" {
|
|
129
|
+
t.Fatalf("unexpected body: %s", data)
|
|
130
|
+
}
|
|
131
|
+
for _, field := range []string{"duration_sec", "resolution", "generate_type"} {
|
|
132
|
+
if _, exists := body.Param[field]; exists {
|
|
133
|
+
t.Fatalf("CLI must leave %s defaults to the API: %s", field, data)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// API owns the Max 21:9 rejection; the CLI must not add an enum gate.
|
|
137
|
+
opts.Ratio = "21:9"
|
|
138
|
+
if err := ValidateOptions(opts); err != nil {
|
|
139
|
+
t.Fatal(err)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
package models
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"slices"
|
|
7
|
+
"strconv"
|
|
8
|
+
"strings"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Mirrors capcut_business_common.Ratio and the Skill API's skillVideoRatio.
|
|
12
|
+
// Custom (1) has no corresponding generate-video --ratio value.
|
|
13
|
+
var ratioValues = map[int64]string{
|
|
14
|
+
0: "adaptive", 1: "", 2: "16:9", 3: "9:16", 4: "4:3", 5: "3:4", 6: "1:1",
|
|
15
|
+
7: "2:1", 8: "2.35:1", 9: "1.85:1", 10: "1.125:2.436", 11: "3:2", 12: "2:3", 13: "21:9",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type dimensionConfig struct {
|
|
19
|
+
Key string `json:"key"`
|
|
20
|
+
DefaultValue *string `json:"default_value"`
|
|
21
|
+
OptionList []struct {
|
|
22
|
+
Value string `json:"value"`
|
|
23
|
+
Disabled bool `json:"disabled"`
|
|
24
|
+
} `json:"option_list"`
|
|
25
|
+
RangeConfig *struct {
|
|
26
|
+
Min *int64 `json:"min_value"`
|
|
27
|
+
Max *int64 `json:"max_value"`
|
|
28
|
+
Step int64 `json:"step"`
|
|
29
|
+
} `json:"range_config"`
|
|
30
|
+
ActiveWhenAny json.RawMessage `json:"active_when_any"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Normalize only fields with an explicit CLI contract. RawMessage preserves
|
|
34
|
+
// unknown fields and large integers; the cached catalog is never mutated.
|
|
35
|
+
func describeModel(raw json.RawMessage) (json.RawMessage, error) {
|
|
36
|
+
var fields map[string]json.RawMessage
|
|
37
|
+
var source struct {
|
|
38
|
+
Key string `json:"key"`
|
|
39
|
+
Ratios []int64 `json:"supported_ratio_list"`
|
|
40
|
+
Default *int64 `json:"default_ratio"`
|
|
41
|
+
Parameter struct {
|
|
42
|
+
Dimensions []*dimensionConfig `json:"dimensions"`
|
|
43
|
+
} `json:"parameter_config"`
|
|
44
|
+
Creation *struct {
|
|
45
|
+
Modes []json.RawMessage `json:"modes"`
|
|
46
|
+
} `json:"creation_mode_config"`
|
|
47
|
+
}
|
|
48
|
+
if err := json.Unmarshal(raw, &fields); err != nil {
|
|
49
|
+
return nil, err
|
|
50
|
+
}
|
|
51
|
+
if err := json.Unmarshal(raw, &source); err != nil {
|
|
52
|
+
return nil, fmt.Errorf("模型参数配置无法解析,请 --refresh 重试或升级 CLI: %w", err)
|
|
53
|
+
}
|
|
54
|
+
out := make(map[string]any, len(fields))
|
|
55
|
+
for key, value := range fields {
|
|
56
|
+
out[key] = value
|
|
57
|
+
}
|
|
58
|
+
delete(out, "config_key")
|
|
59
|
+
warnings := []string{}
|
|
60
|
+
warn := func(message string) { warnings = append(warnings, message) }
|
|
61
|
+
ratios := make([]string, 0, len(source.Ratios))
|
|
62
|
+
for _, value := range source.Ratios {
|
|
63
|
+
// Unknown enums and Custom cannot be submitted as CLI ratio strings.
|
|
64
|
+
if value := ratioValues[value]; value != "" && !slices.Contains(ratios, value) {
|
|
65
|
+
ratios = append(ratios, value)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
ratio := map[string]any{"options": ratios}
|
|
69
|
+
if source.Default != nil {
|
|
70
|
+
if value := ratioValues[*source.Default]; value != "" {
|
|
71
|
+
if slices.Contains(ratios, value) {
|
|
72
|
+
ratio["default"] = value
|
|
73
|
+
} else {
|
|
74
|
+
warn("默认比例不在可用比例中,未输出默认值;请 --refresh 重试")
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if _, exists := fields["supported_ratio_list"]; exists || source.Default != nil {
|
|
79
|
+
out["ratio"] = ratio
|
|
80
|
+
}
|
|
81
|
+
delete(out, "supported_ratio_list")
|
|
82
|
+
delete(out, "default_ratio")
|
|
83
|
+
|
|
84
|
+
seen := make(map[string]bool)
|
|
85
|
+
for _, dimension := range source.Parameter.Dimensions {
|
|
86
|
+
if dimension == nil || (dimension.Key != "resolution" && dimension.Key != "duration") {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if seen[dimension.Key] {
|
|
90
|
+
return nil, fmt.Errorf("模型配置包含重复 %s 维度,请 --refresh 重试", dimension.Key)
|
|
91
|
+
}
|
|
92
|
+
seen[dimension.Key] = true
|
|
93
|
+
out[dimension.Key] = describeDimension(dimension, warn)
|
|
94
|
+
}
|
|
95
|
+
// Keep unrelated parameter dimensions and combination constraints as supplied.
|
|
96
|
+
if parameterRaw, ok := fields["parameter_config"]; ok && string(parameterRaw) != "null" {
|
|
97
|
+
var parameter map[string]json.RawMessage
|
|
98
|
+
if err := json.Unmarshal(parameterRaw, ¶meter); err != nil {
|
|
99
|
+
return nil, err
|
|
100
|
+
}
|
|
101
|
+
var dimensions []json.RawMessage
|
|
102
|
+
if err := json.Unmarshal(parameter["dimensions"], &dimensions); len(parameter["dimensions"]) != 0 && err != nil {
|
|
103
|
+
return nil, err
|
|
104
|
+
}
|
|
105
|
+
remaining := []json.RawMessage{}
|
|
106
|
+
for _, dimension := range dimensions {
|
|
107
|
+
var key struct {
|
|
108
|
+
Key string `json:"key"`
|
|
109
|
+
}
|
|
110
|
+
if err := json.Unmarshal(dimension, &key); err != nil {
|
|
111
|
+
return nil, err
|
|
112
|
+
}
|
|
113
|
+
if key.Key != "resolution" && key.Key != "duration" {
|
|
114
|
+
remaining = append(remaining, dimension)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
delete(parameter, "dimensions")
|
|
118
|
+
if len(remaining) > 0 {
|
|
119
|
+
parameter["dimensions"], _ = json.Marshal(remaining)
|
|
120
|
+
}
|
|
121
|
+
delete(out, "parameter_config")
|
|
122
|
+
if len(parameter) > 0 {
|
|
123
|
+
out["parameter_config"] = parameter
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if seen["duration"] {
|
|
127
|
+
delete(out, "supported_duration_list")
|
|
128
|
+
delete(out, "default_duration_value")
|
|
129
|
+
} else if _, exists := fields["supported_duration_list"]; exists {
|
|
130
|
+
warn("服务端未提供 duration 参数维度;supported_duration_list 为旧时长枚举,不能直接当作 --duration 秒数")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
limits := map[string]json.RawMessage{}
|
|
134
|
+
for from, to := range map[string]string{
|
|
135
|
+
"total_limit": "total_limit", "image_total_limit": "image_total_limit",
|
|
136
|
+
"video_total_limit": "video_total_limit", "audio_total_limit": "audio_total_limit",
|
|
137
|
+
"max_image_size": "max_image_size_bytes", "min_video_duration": "min_video_duration_ms",
|
|
138
|
+
"max_video_duration": "max_video_duration_ms", "max_total_video_duration": "max_total_video_duration_ms",
|
|
139
|
+
} {
|
|
140
|
+
if value, exists := fields[from]; exists {
|
|
141
|
+
limits[to] = value
|
|
142
|
+
delete(out, from)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if len(limits) > 0 {
|
|
146
|
+
out["material_limits"] = limits
|
|
147
|
+
}
|
|
148
|
+
if source.Creation != nil {
|
|
149
|
+
modes := make([]map[string]any, 0, len(source.Creation.Modes))
|
|
150
|
+
for _, rawMode := range source.Creation.Modes {
|
|
151
|
+
if string(rawMode) == "null" {
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
var modeFields map[string]json.RawMessage
|
|
155
|
+
var mode struct {
|
|
156
|
+
Key string `json:"key"`
|
|
157
|
+
RatioPolicy struct {
|
|
158
|
+
ValueMode string `json:"value_mode"`
|
|
159
|
+
} `json:"ratio_policy"`
|
|
160
|
+
}
|
|
161
|
+
if err := json.Unmarshal(rawMode, &modeFields); err != nil {
|
|
162
|
+
return nil, err
|
|
163
|
+
}
|
|
164
|
+
if err := json.Unmarshal(rawMode, &mode); err != nil {
|
|
165
|
+
return nil, err
|
|
166
|
+
}
|
|
167
|
+
entry := make(map[string]any, len(modeFields)+2)
|
|
168
|
+
for key, value := range modeFields {
|
|
169
|
+
entry[key] = value
|
|
170
|
+
}
|
|
171
|
+
switch mode.Key {
|
|
172
|
+
case "text_to_video", "reference_generation":
|
|
173
|
+
entry["generate_type"] = 0
|
|
174
|
+
case "first_last_frame":
|
|
175
|
+
entry["generate_type"] = 1
|
|
176
|
+
}
|
|
177
|
+
switch mode.RatioPolicy.ValueMode {
|
|
178
|
+
case "smart":
|
|
179
|
+
entry["ratio"] = map[string]any{"options": []string{"adaptive"}, "default": "adaptive"}
|
|
180
|
+
case "", "inherit":
|
|
181
|
+
entry["ratio"] = ratio
|
|
182
|
+
if mode.Key == "text_to_video" && (source.Key == "MiniMax-H3" || source.Key == "MiniMax-H3-Max") {
|
|
183
|
+
fixed := make([]string, 0, len(ratios))
|
|
184
|
+
for _, value := range ratios {
|
|
185
|
+
if value != "adaptive" {
|
|
186
|
+
fixed = append(fixed, value)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
modeRatio := map[string]any{"options": fixed}
|
|
190
|
+
if value, ok := ratio["default"].(string); ok && value != "adaptive" {
|
|
191
|
+
modeRatio["default"] = value
|
|
192
|
+
}
|
|
193
|
+
entry["ratio"] = modeRatio
|
|
194
|
+
}
|
|
195
|
+
default:
|
|
196
|
+
warn("创作模式 " + mode.Key + " 的比例策略无法识别,请 --refresh 重试或升级 CLI")
|
|
197
|
+
}
|
|
198
|
+
modes = append(modes, entry)
|
|
199
|
+
}
|
|
200
|
+
// Retain schema/default-mode metadata, but expose interpreted modes separately.
|
|
201
|
+
var creation map[string]json.RawMessage
|
|
202
|
+
if err := json.Unmarshal(fields["creation_mode_config"], &creation); err != nil {
|
|
203
|
+
return nil, err
|
|
204
|
+
}
|
|
205
|
+
delete(creation, "modes")
|
|
206
|
+
delete(out, "creation_mode_config")
|
|
207
|
+
if len(creation) > 0 {
|
|
208
|
+
out["creation_mode_config"] = creation
|
|
209
|
+
}
|
|
210
|
+
out["creation_modes"] = modes
|
|
211
|
+
}
|
|
212
|
+
if (source.Key == "MiniMax-H3" || source.Key == "MiniMax-H3-Max") && slices.Contains(ratios, "adaptive") {
|
|
213
|
+
out["notes"] = []string{"MiniMax 使用 adaptive 需要参考图片或视频;纯文生视频请选择固定比例。创作模式的比例策略优先于模型级选项。"}
|
|
214
|
+
}
|
|
215
|
+
if len(warnings) > 0 {
|
|
216
|
+
out["warnings"] = warnings
|
|
217
|
+
}
|
|
218
|
+
return json.Marshal(out)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func describeDimension(d *dimensionConfig, warn func(string)) map[string]any {
|
|
222
|
+
out := map[string]any{}
|
|
223
|
+
if len(d.ActiveWhenAny) > 0 {
|
|
224
|
+
out["active_when_any"] = d.ActiveWhenAny
|
|
225
|
+
}
|
|
226
|
+
convert := func(value string) (any, bool) {
|
|
227
|
+
value = strings.TrimSpace(value)
|
|
228
|
+
if d.Key == "duration" {
|
|
229
|
+
number, err := strconv.ParseInt(value, 10, 32)
|
|
230
|
+
return number, err == nil && number > 0
|
|
231
|
+
}
|
|
232
|
+
return strings.ToLower(value), value != ""
|
|
233
|
+
}
|
|
234
|
+
if d.Key == "duration" {
|
|
235
|
+
out["unit"] = "seconds"
|
|
236
|
+
}
|
|
237
|
+
validDefault := func(any) bool { return false }
|
|
238
|
+
if bounds := d.RangeConfig; bounds != nil {
|
|
239
|
+
if d.Key != "duration" || len(d.OptionList) != 0 || bounds.Min == nil || bounds.Max == nil || *bounds.Min <= 0 || *bounds.Max < *bounds.Min {
|
|
240
|
+
warn(d.Key + " 范围配置不合法,请 --refresh 重试")
|
|
241
|
+
return out
|
|
242
|
+
}
|
|
243
|
+
step := bounds.Step
|
|
244
|
+
if step < 1 {
|
|
245
|
+
step = 1
|
|
246
|
+
}
|
|
247
|
+
out["min"], out["max"], out["step"] = *bounds.Min, *bounds.Max, step
|
|
248
|
+
validDefault = func(value any) bool {
|
|
249
|
+
n := value.(int64)
|
|
250
|
+
return n >= *bounds.Min && n <= *bounds.Max && (n-*bounds.Min)%step == 0
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
options := []any{}
|
|
254
|
+
for _, option := range d.OptionList {
|
|
255
|
+
if option.Disabled {
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
value, ok := convert(option.Value)
|
|
259
|
+
if !ok {
|
|
260
|
+
warn(d.Key + " 包含无法转换的选项,已排除;请 --refresh 重试")
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
if !slices.Contains(options, value) {
|
|
264
|
+
options = append(options, value)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
out["options"] = options
|
|
268
|
+
if len(options) == 0 {
|
|
269
|
+
warn(d.Key + " 没有可用选项,请 --refresh 重试")
|
|
270
|
+
}
|
|
271
|
+
validDefault = func(value any) bool { return slices.Contains(options, value) }
|
|
272
|
+
}
|
|
273
|
+
if d.DefaultValue != nil {
|
|
274
|
+
if value, ok := convert(*d.DefaultValue); ok && validDefault(value) {
|
|
275
|
+
out["default"] = value
|
|
276
|
+
} else {
|
|
277
|
+
warn(d.Key + " 默认值不在可用范围内,未输出默认值;请 --refresh 重试")
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return out
|
|
281
|
+
}
|