@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.
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 +7 -6
  5. package/cmd/generate_video_test.go +57 -55
  6. package/cmd/model.go +74 -0
  7. package/cmd/model_test.go +81 -0
  8. package/cmd/root.go +1 -0
  9. package/cmd/short_drama/short_drama.go +1 -0
  10. package/cmd/source_test.go +179 -0
  11. package/cmd/submit_run.go +3 -0
  12. package/cmd/update/update.go +1 -1
  13. package/cmd/video_tool/video_tool.go +2 -0
  14. package/dist/checksums.txt +6 -6
  15. package/internal/common/submit_run.go +38 -1
  16. package/internal/config/config.go +13 -10
  17. package/internal/config/config_test.go +3 -0
  18. package/internal/generate_image/generate_image.go +2 -1
  19. package/internal/generate_video/generate_video.go +12 -18
  20. package/internal/generate_video/generate_video_test.go +126 -0
  21. package/internal/models/describe.go +281 -0
  22. package/internal/models/describe_test.go +199 -0
  23. package/internal/models/models.go +220 -0
  24. package/internal/models/models_test.go +200 -0
  25. package/internal/short_drama/submit_run.go +2 -0
  26. package/internal/video_tool/video_tool.go +4 -2
  27. package/package.json +1 -1
  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 +34 -10
  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 +2 -2
  36. 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, SubmitRunPath(runner), body, &resp); err != nil {
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 string
34
- GetCreditBalance string
35
- GetThread string
36
- UploadFile string
37
- ListThreadFile string
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: SubmitRunPath,
49
- GetCreditBalance: GetCreditBalancePath,
50
- GetThread: GetThreadPath,
51
- UploadFile: UploadFilePath,
52
- ListThreadFile: ListThreadFilePath,
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
- 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
+ })
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, &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
+ }