@pippit-dev/cli 1.0.26 → 1.0.28

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.
Files changed (36) hide show
  1. package/README.md +23 -1
  2. package/checksums.txt +6 -6
  3. package/cmd/generate_image/generate_image.go +1 -0
  4. package/cmd/generate_video/generate_video.go +4 -3
  5. package/cmd/model.go +74 -0
  6. package/cmd/model_test.go +81 -0
  7. package/cmd/root.go +1 -0
  8. package/cmd/short_drama/short_drama.go +1 -0
  9. package/cmd/source_test.go +179 -0
  10. package/cmd/submit_run.go +3 -0
  11. package/cmd/update/update.go +1 -1
  12. package/cmd/video_tool/video_tool.go +2 -0
  13. package/dist/checksums.txt +6 -6
  14. package/internal/common/submit_run.go +38 -1
  15. package/internal/config/config.go +13 -10
  16. package/internal/config/config_test.go +3 -0
  17. package/internal/generate_image/generate_image.go +2 -1
  18. package/internal/generate_video/generate_video.go +12 -3
  19. package/internal/generate_video/generate_video_test.go +126 -0
  20. package/internal/models/describe.go +281 -0
  21. package/internal/models/describe_test.go +199 -0
  22. package/internal/models/models.go +220 -0
  23. package/internal/models/models_test.go +200 -0
  24. package/internal/short_drama/submit_run.go +2 -0
  25. package/internal/video_tool/video_tool.go +4 -2
  26. package/package.json +1 -1
  27. package/scripts/install.js +3 -2
  28. package/skills/short-drama/SKILL.md +4 -0
  29. package/skills/xyq-nest-skill/SKILL.md +10 -0
  30. package/skills/xyq-nest-skill/commands/erase-video-subtitle.md +2 -0
  31. package/skills/xyq-nest-skill/commands/generate-image.md +2 -0
  32. package/skills/xyq-nest-skill/commands/generate-video.md +31 -7
  33. package/skills/xyq-nest-skill/commands/model.md +59 -0
  34. package/skills/xyq-nest-skill/commands/video-super-resolution.md +2 -0
  35. package/skills/xyq-nest-skill/scripts/ensure-cli.js +3 -3
  36. package/skills/xyq-nest-skill/scripts/install.md +1 -1
@@ -18,6 +18,7 @@ var (
18
18
 
19
19
  // Options is the stable command-facing request shape for generate-video.
20
20
  type Options struct {
21
+ Source string
21
22
  Prompt string
22
23
  ImagePaths []string
23
24
  VideoPaths []string
@@ -54,7 +55,15 @@ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, er
54
55
  }
55
56
 
56
57
  body := buildSubmitRunBody(opts, imageAssetIDs, videoAssetIDs, audioAssetIDs)
57
- return common.SubmitRun(ctx, "generate-video", body, runner)
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
+ })
58
67
  }
59
68
 
60
69
  func ValidateOptions(opts *Options) error {
@@ -116,11 +125,11 @@ func buildSubmitRunBody(opts *Options, imageAssetIDs []string, videoAssetIDs []s
116
125
  GenerateType: opts.GenerateType,
117
126
  }
118
127
 
119
- return map[string]any{
128
+ return common.WithSubmitRunSource(map[string]any{
120
129
  "agent_name": common.AgentNameVideoPart,
121
130
  "message": prompt,
122
131
  "video_part_tool_param": param,
123
- }
132
+ }, opts.Source)
124
133
  }
125
134
 
126
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, &parameter); 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
+ }
@@ -0,0 +1,199 @@
1
+ package models
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "reflect"
7
+ "testing"
8
+ )
9
+
10
+ func description(t *testing.T, raw string) map[string]json.RawMessage {
11
+ t.Helper()
12
+ out, err := describeModel(json.RawMessage(raw))
13
+ if err != nil {
14
+ t.Fatal(err)
15
+ }
16
+ var fields map[string]json.RawMessage
17
+ if err := json.Unmarshal(out, &fields); err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ return fields
21
+ }
22
+
23
+ func TestDescriptionCLIParametersAndRawPreservation(t *testing.T) {
24
+ raw := `{"key":"MiniMax-H3","supported_ratio_list":[0,2,13,3,4,5,6],"default_ratio":3,"config_key":"internal",
25
+ "future_field":9007199254740993,"audio_total_limit":0,"max_image_size":31457280,"min_video_duration":2000,
26
+ "supported_duration_list":[{"value":999}],"default_duration_value":999,
27
+ "parameter_config":{"dimensions":[
28
+ {"key":"duration","default_value":"10","range_config":{"min_value":4,"max_value":15,"step":1}},
29
+ {"key":"resolution","default_value":" 768P ","option_list":[{"value":"768p"},{"value":"2k"},{"value":"4k","disabled":true}]},
30
+ {"key":"seed","default_value":"random"}],"need_available_combinations":true}}
31
+ `
32
+ out := description(t, raw)
33
+ for _, key := range []string{"config_key", "supported_ratio_list", "default_ratio", "supported_duration_list", "default_duration_value", "audio_total_limit", "max_image_size"} {
34
+ if _, ok := out[key]; ok {
35
+ t.Fatalf("unconverted field %s", key)
36
+ }
37
+ }
38
+ var ratio struct {
39
+ Options []string
40
+ Default string
41
+ }
42
+ if err := json.Unmarshal(out["ratio"], &ratio); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if !reflect.DeepEqual(ratio.Options, []string{"adaptive", "16:9", "21:9", "9:16", "4:3", "3:4", "1:1"}) || ratio.Default != "9:16" {
46
+ t.Fatalf("ratio=%+v", ratio)
47
+ }
48
+ var resolution struct {
49
+ Options []string
50
+ Default string
51
+ }
52
+ if err := json.Unmarshal(out["resolution"], &resolution); err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ if !reflect.DeepEqual(resolution.Options, []string{"768p", "2k"}) || resolution.Default != "768p" {
56
+ t.Fatalf("resolution=%+v", resolution)
57
+ }
58
+ var duration struct {
59
+ Min, Max, Step, Default int
60
+ Unit string
61
+ }
62
+ if err := json.Unmarshal(out["duration"], &duration); err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ if duration.Min != 4 || duration.Max != 15 || duration.Step != 1 || duration.Default != 10 || duration.Unit != "seconds" {
66
+ t.Fatalf("duration=%+v", duration)
67
+ }
68
+ if string(out["future_field"]) != "9007199254740993" {
69
+ t.Fatal("integer precision lost")
70
+ }
71
+ var limits map[string]json.RawMessage
72
+ if err := json.Unmarshal(out["material_limits"], &limits); err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ if string(limits["audio_total_limit"]) != "0" || string(limits["max_image_size_bytes"]) != "31457280" || string(limits["min_video_duration_ms"]) != "2000" {
76
+ t.Fatalf("limits=%s", out["material_limits"])
77
+ }
78
+ if _, ok := limits["video_total_limit"]; ok {
79
+ t.Fatal("absent limit must stay absent")
80
+ }
81
+ if !bytes.Contains(out["parameter_config"], []byte("seed")) || !bytes.Contains(out["parameter_config"], []byte("need_available_combinations")) {
82
+ t.Fatal("unrelated configuration lost")
83
+ }
84
+ if _, ok := out["warnings"]; ok {
85
+ t.Fatalf("valid dimensions must override legacy durations: %s", out["warnings"])
86
+ }
87
+ if !bytes.Contains(out["notes"], []byte("纯文生视频")) {
88
+ t.Fatal("missing MiniMax adaptive restriction")
89
+ }
90
+ }
91
+
92
+ func TestDescriptionUnknownRatioAndDefault(t *testing.T) {
93
+ for _, value := range []int{1, 999} {
94
+ raw, _ := json.Marshal(map[string]any{"supported_ratio_list": []int{value, 2, 2}, "default_ratio": value})
95
+ out := description(t, string(raw))
96
+ var ratio struct {
97
+ Options []string
98
+ Default *string
99
+ }
100
+ if err := json.Unmarshal(out["ratio"], &ratio); err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ if !reflect.DeepEqual(ratio.Options, []string{"16:9"}) || ratio.Default != nil {
104
+ t.Fatalf("invented usable value: %+v", ratio)
105
+ }
106
+ if _, exists := out["warnings"]; exists {
107
+ t.Fatal("unknown and custom enums should be silently skipped")
108
+ }
109
+ }
110
+ out := description(t, `{"supported_ratio_list":[0,7,8,9,10,11,12],"default_ratio":0}`)
111
+ var ratio struct {
112
+ Options []string
113
+ Default string
114
+ }
115
+ if err := json.Unmarshal(out["ratio"], &ratio); err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ if ratio.Default != "adaptive" || !reflect.DeepEqual(ratio.Options, []string{"adaptive", "2:1", "2.35:1", "1.85:1", "1.125:2.436", "3:2", "2:3"}) {
119
+ t.Fatalf("ratio=%+v", ratio)
120
+ }
121
+ }
122
+
123
+ func TestDescriptionDimensionFailuresAndOptions(t *testing.T) {
124
+ for _, dimension := range []string{
125
+ `{"key":"resolution","default_value":"2k","option_list":[{"value":"768p"},{"value":"2k","disabled":true}]}`,
126
+ `{"key":"resolution","default_value":"","option_list":[]}`,
127
+ `{"key":"duration","default_value":"7","range_config":{"min_value":4,"max_value":10,"step":2}}`,
128
+ `{"key":"duration","range_config":{"min_value":10,"max_value":4}}`,
129
+ `{"key":"duration","option_list":[{"value":"auto"}]}`,
130
+ } {
131
+ out := description(t, `{"parameter_config":{"dimensions":[`+dimension+`]}}`)
132
+ if _, ok := out["warnings"]; !ok {
133
+ t.Fatalf("missing warning: %s", dimension)
134
+ }
135
+ for _, key := range []string{"resolution", "duration"} {
136
+ var value map[string]json.RawMessage
137
+ if len(out[key]) == 0 {
138
+ continue
139
+ }
140
+ if err := json.Unmarshal(out[key], &value); err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ if _, ok := value["default"]; ok {
144
+ t.Fatalf("invalid default exposed: %s", out[key])
145
+ }
146
+ }
147
+ }
148
+ out := description(t, `{"parameter_config":{"dimensions":[{"key":"duration","default_value":"10","option_list":[{"value":"5"},{"value":"10"},{"value":"15","disabled":true}]}]}}`)
149
+ var duration struct {
150
+ Options []int
151
+ Default int
152
+ Min *int
153
+ }
154
+ if err := json.Unmarshal(out["duration"], &duration); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ if !reflect.DeepEqual(duration.Options, []int{5, 10}) || duration.Default != 10 || duration.Min != nil {
158
+ t.Fatalf("duration=%+v", duration)
159
+ }
160
+ }
161
+
162
+ func TestDescriptionCreationModesAndCacheNotMutated(t *testing.T) {
163
+ raw := json.RawMessage(`{"key":"MiniMax-H3-Max","kind":"video","supported_ratio_list":[0,2],"default_ratio":2,"creation_mode_config":{"schema_version":1,"default_mode":"text_to_video","modes":[{"key":"text_to_video","enabled":true,"ratio_policy":{"value_mode":"inherit"}},{"key":"first_last_frame","enabled":true,"ratio_policy":{"value_mode":"smart"},"validation_policy":{"required_video_count":0,"forbidden_input_types":["video","audio"]}}]}}`)
164
+ before := append([]byte(nil), raw...)
165
+ var catalog Catalog
166
+ if err := json.Unmarshal([]byte(`{"config":{"models":[]}}`), &catalog); err != nil {
167
+ t.Fatal(err)
168
+ }
169
+ catalog.Config.Models = append(catalog.Config.Models, raw)
170
+ result, err := catalog.Describe("MiniMax-H3-Max")
171
+ if err != nil {
172
+ t.Fatal(err)
173
+ }
174
+ var out struct {
175
+ Modes []struct {
176
+ GenerateType int `json:"generate_type"`
177
+ Ratio struct {
178
+ Options []string
179
+ Default string
180
+ }
181
+ Validation struct {
182
+ Required *int `json:"required_video_count"`
183
+ Forbidden []string `json:"forbidden_input_types"`
184
+ } `json:"validation_policy"`
185
+ } `json:"creation_modes"`
186
+ }
187
+ if err := json.Unmarshal(result, &out); err != nil {
188
+ t.Fatal(err)
189
+ }
190
+ if len(out.Modes) != 2 || out.Modes[0].Ratio.Default != "16:9" || !reflect.DeepEqual(out.Modes[0].Ratio.Options, []string{"16:9"}) || out.Modes[1].GenerateType != 1 || out.Modes[1].Ratio.Default != "adaptive" || !reflect.DeepEqual(out.Modes[1].Ratio.Options, []string{"adaptive"}) {
191
+ t.Fatalf("modes=%+v", out.Modes)
192
+ }
193
+ if out.Modes[1].Validation.Required == nil || *out.Modes[1].Validation.Required != 0 || !reflect.DeepEqual(out.Modes[1].Validation.Forbidden, []string{"video", "audio"}) {
194
+ t.Fatal("mode requirements lost")
195
+ }
196
+ if !bytes.Equal(before, catalog.Config.Models[0]) {
197
+ t.Fatal("description mutated cached raw config")
198
+ }
199
+ }