@pippit-dev/cli 1.0.11 → 1.0.12
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 +33 -2
- package/checksums.txt +6 -6
- package/cmd/generate_image/generate_image.go +59 -0
- package/cmd/generate_image_test.go +135 -0
- package/cmd/generate_video/query_result.go +3 -2
- package/cmd/root.go +3 -1
- package/internal/common/path.go +24 -0
- package/internal/common/set.go +10 -0
- package/internal/generate_image/generate_image.go +164 -0
- package/internal/generate_image/generate_image_test.go +148 -0
- package/internal/generate_video/query_result.go +107 -5
- package/internal/generate_video/query_result_test.go +95 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -213,6 +213,37 @@ pippit-tool-cli download-result --output-path ./thread_123/results/result.mp4 --
|
|
|
213
213
|
|
|
214
214
|
短剧命令的错误日志会追加写入本地每日日志文件:`~/.pippit_tool_cli/logs/yyyy-mm-dd.log`。日志路径会基于当前用户主目录和系统路径分隔符生成,因此可在 macOS、Linux 和 Windows 上使用。
|
|
215
215
|
|
|
216
|
+
## 生图 CLI
|
|
217
|
+
|
|
218
|
+
`generate-image` 会上传本地参考图片,然后向综合 Nest Agent 提交生图请求:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
pippit-tool-cli generate-image \
|
|
222
|
+
--prompt "生成一张小猫海报" \
|
|
223
|
+
--image "~/images/cat.png" \
|
|
224
|
+
--model "seedream_4.5" \
|
|
225
|
+
--ratio 6 \
|
|
226
|
+
--generate-image-count 2
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交 HTTP 请求时,`agent_name` 固定为 `pippit_nest_agent`,参考图会使用上传接口返回的 `pippit_asset_id` 写入顶层 `asset_ids`,生图模型写入 `general_agent_settings.image_model`,比例写入 `general_agent_settings.ratio`,生图数量写入 `general_agent_settings.generate_image_count`。`--model` 为必填参数,CLI 只做非空校验,具体模型值是否可用由服务端决定。
|
|
230
|
+
|
|
231
|
+
`--ratio` 可选,填写服务端 `Ratio` 枚举值。CLI 只做整数格式解析,不检查枚举值是否在下表范围内;具体值是否可用由服务端决定。常用枚举值含义如下:
|
|
232
|
+
|
|
233
|
+
| ratio 参数 | IDL 枚举 | 含义 |
|
|
234
|
+
| ---: | --- | --- |
|
|
235
|
+
| `0` | `CanvasRatioOriginal` | 原始比例(自动) |
|
|
236
|
+
| `2` | `CanvasRatio16To9` | 16:9(横屏) |
|
|
237
|
+
| `13` | `CanvasRatio21To9` | 21:9(电影) |
|
|
238
|
+
| `3` | `CanvasRatio9To16` | 9:16(竖屏) |
|
|
239
|
+
| `4` | `CanvasRatio4To3` | 4:3 |
|
|
240
|
+
| `5` | `CanvasRatio3To4` | 3:4 |
|
|
241
|
+
| `6` | `CanvasRatio1To1` | 1:1 |
|
|
242
|
+
|
|
243
|
+
`--generate-image-count` 可选,填写生图数量,对应 IDL 字段 `GeneralSettingsPart.GenerateImageCount` / JSON 字段 `generate_image_count`。CLI 只校验不能为负数;具体数量范围由服务端决定。
|
|
244
|
+
|
|
245
|
+
图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`。CLI 会在提交前校验 prompt、model 必填、ratio 整数格式、generate-image-count 非负和文件后缀。
|
|
246
|
+
|
|
216
247
|
## 生视频 CLI
|
|
217
248
|
|
|
218
249
|
`generate-video` 会上传本地参考图片、视频和音频,然后向视频片段 Agent 提交生视频请求:
|
|
@@ -233,7 +264,7 @@ pippit-tool-cli generate-video \
|
|
|
233
264
|
|
|
234
265
|
命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交生视频 HTTP 请求时,参考图、参考视频和参考音频会使用上传接口返回的 `pippit_asset_id`,并分别写入 `video_part_tool_param.images`、`video_part_tool_param.videos` 和 `video_part_tool_param.audios`。图片最多 9 张,支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`;视频最多 3 个,支持 `.mp4`、`.avi`、`.mov`、`.wmv`、`.flv`、`.webm`、`.mkv`、`.m4v`;音频最多 3 个,仅支持 `.mp3`、`.wav`。普通用户支持模型 `Seedance_2.0_mini_lite`;`seedance2.0_vision`、`seedance2.0_fast_vision` 和 `Seedance_2.0_mini` 为 VIP 专属模型。CLI 会在提交前校验 prompt、素材数量和文件后缀;模型、比例、分辨率等语义校验由服务端处理。
|
|
235
266
|
|
|
236
|
-
|
|
267
|
+
查询并下载生图/生视频结果:
|
|
237
268
|
|
|
238
269
|
```bash
|
|
239
270
|
pippit-tool-cli query-result \
|
|
@@ -242,7 +273,7 @@ pippit-tool-cli query-result \
|
|
|
242
273
|
--download-dir "./output"
|
|
243
274
|
```
|
|
244
275
|
|
|
245
|
-
`query-result` 会查询指定 Run 并输出 JSON。Run
|
|
276
|
+
`query-result` 会查询指定 Run 并输出 JSON。Run 成功完成后下载视频和图片产物,`completed=true`,`videos` 和 `images` 中各包含 `download_url` 和 `output_path`;图片扩展名取自产物 `metadata.format`,缺省时兜底 `.png`。Run 失败也视为终态,`completed=true` 且填充 `error_message`;Run 未到终态时 `completed=false`。
|
|
246
277
|
|
|
247
278
|
## HTTP 客户端
|
|
248
279
|
|
package/checksums.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
127a8f5d5f5c655e671fcd9dee1ce5007ce85f5cd3fbb8bd96310bbfb9c36233 pippit-tool-cli-1.0.12-darwin-amd64.tar.gz
|
|
2
|
+
2f51e8957dae53550f07b63aedb38aafa39c42b07a5d7bf2bc9591cceea933e4 pippit-tool-cli-1.0.12-darwin-arm64.tar.gz
|
|
3
|
+
d25be6ff1afc4ca2f16028993f7340d210bd20e10a98e81dcd29122f156934ba pippit-tool-cli-1.0.12-linux-amd64.tar.gz
|
|
4
|
+
85f5edd2da2a143f7b41145a3e090d84dce2aaf877189c95b04769bde081bda2 pippit-tool-cli-1.0.12-linux-arm64.tar.gz
|
|
5
|
+
205e82c8982ce275a87884b056304e595815dafc62c9be186299b5b684066aba pippit-tool-cli-1.0.12-windows-amd64.zip
|
|
6
|
+
4264f068bf7bbdc6bada501eec7867d83b2c494543a53c0b82cdecf55205188f pippit-tool-cli-1.0.12-windows-arm64.zip
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
package generate_image
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"io"
|
|
5
|
+
"strconv"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
9
|
+
internalgen "github.com/Pippit-dev/pippit-cli/internal/generate_image"
|
|
10
|
+
"github.com/spf13/cobra"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// NewCommand builds the generate-image command.
|
|
14
|
+
func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
15
|
+
opts := &internalgen.Options{}
|
|
16
|
+
var generateImageCount int
|
|
17
|
+
|
|
18
|
+
cmd := &cobra.Command{
|
|
19
|
+
Use: "generate-image",
|
|
20
|
+
Short: "Generate an image with the nest agent",
|
|
21
|
+
Args: cobra.NoArgs,
|
|
22
|
+
RunE: func(cmd *cobra.Command, args []string) error {
|
|
23
|
+
if cmd.Flags().Changed("generate-image-count") {
|
|
24
|
+
opts.GenerateImageCount = &generateImageCount
|
|
25
|
+
} else {
|
|
26
|
+
opts.GenerateImageCount = nil
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
result, err := internalgen.Run(cmd.Context(), opts, runner)
|
|
30
|
+
if err != nil {
|
|
31
|
+
_ = common.AppendDailyErrorLog("generate-image", err, map[string]string{
|
|
32
|
+
"prompt": strings.TrimSpace(opts.Prompt),
|
|
33
|
+
"image": strings.Join(opts.ImagePaths, ","),
|
|
34
|
+
"model": strings.TrimSpace(opts.Model),
|
|
35
|
+
"ratio": strings.TrimSpace(opts.Ratio),
|
|
36
|
+
"generate_image_count": optionalIntString(opts.GenerateImageCount),
|
|
37
|
+
})
|
|
38
|
+
return err
|
|
39
|
+
}
|
|
40
|
+
return common.WriteJSON(stdout, result)
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
cmd.SetOut(stdout)
|
|
44
|
+
cmd.SetErr(stderr)
|
|
45
|
+
flags := cmd.Flags()
|
|
46
|
+
flags.StringVar(&opts.Prompt, "prompt", "", "image generation prompt")
|
|
47
|
+
flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; repeat for multiple images")
|
|
48
|
+
flags.StringVar(&opts.Model, "model", "", "image model; supported: seedream_5.0_pro, seedream_5.0, seedream_4.3, nova2, seedream_4.5, seedream_4.1, seedream_4")
|
|
49
|
+
flags.StringVar(&opts.Ratio, "ratio", "", "image ratio; "+internalgen.SupportedRatioUsage())
|
|
50
|
+
flags.IntVar(&generateImageCount, "generate-image-count", 0, "generated image count")
|
|
51
|
+
return cmd
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func optionalIntString(value *int) string {
|
|
55
|
+
if value == nil {
|
|
56
|
+
return ""
|
|
57
|
+
}
|
|
58
|
+
return strconv.Itoa(*value)
|
|
59
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"io"
|
|
6
|
+
"net/http"
|
|
7
|
+
"net/http/httptest"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
|
|
13
|
+
"github.com/bytedance/sonic"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
func TestGenerateImage(t *testing.T) {
|
|
17
|
+
var uploaded bool
|
|
18
|
+
|
|
19
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
20
|
+
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
21
|
+
t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
|
|
22
|
+
}
|
|
23
|
+
switch r.URL.Path {
|
|
24
|
+
case "/api/biz/v1/skill/upload_file":
|
|
25
|
+
if r.Method != http.MethodPost {
|
|
26
|
+
t.Fatalf("upload method = %s, want POST", r.Method)
|
|
27
|
+
}
|
|
28
|
+
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
29
|
+
t.Fatalf("ParseMultipartForm(): %v", err)
|
|
30
|
+
}
|
|
31
|
+
files := r.MultipartForm.File["file"]
|
|
32
|
+
if len(files) != 1 {
|
|
33
|
+
t.Fatalf("file parts = %d, want 1", len(files))
|
|
34
|
+
}
|
|
35
|
+
if files[0].Filename != "cat.png" {
|
|
36
|
+
t.Fatalf("filename = %q, want cat.png", files[0].Filename)
|
|
37
|
+
}
|
|
38
|
+
uploaded = true
|
|
39
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"pippit_asset_id":"image_asset_1"}}`))
|
|
40
|
+
case "/api/biz/v1/skill/submit_run":
|
|
41
|
+
if !uploaded {
|
|
42
|
+
t.Fatal("submit called before upload")
|
|
43
|
+
}
|
|
44
|
+
data, err := io.ReadAll(r.Body)
|
|
45
|
+
if err != nil {
|
|
46
|
+
t.Fatalf("read body: %v", err)
|
|
47
|
+
}
|
|
48
|
+
var body map[string]any
|
|
49
|
+
if err := sonic.Unmarshal(data, &body); err != nil {
|
|
50
|
+
t.Fatalf("decode body: %v", err)
|
|
51
|
+
}
|
|
52
|
+
if body["agent_name"] != "pippit_nest_agent" {
|
|
53
|
+
t.Fatalf("agent_name = %v, want nest agent", body["agent_name"])
|
|
54
|
+
}
|
|
55
|
+
if body["message"] != "生成小猫海报" {
|
|
56
|
+
t.Fatalf("message = %v, want prompt", body["message"])
|
|
57
|
+
}
|
|
58
|
+
if _, ok := body["video_part_tool_param"]; ok {
|
|
59
|
+
t.Fatalf("video_part_tool_param should be omitted: %#v", body)
|
|
60
|
+
}
|
|
61
|
+
assetIDs, ok := body["asset_ids"].([]any)
|
|
62
|
+
if !ok || len(assetIDs) != 1 || assetIDs[0] != "image_asset_1" {
|
|
63
|
+
t.Fatalf("asset_ids = %#v, want uploaded asset", body["asset_ids"])
|
|
64
|
+
}
|
|
65
|
+
settings, ok := body["general_agent_settings"].(map[string]any)
|
|
66
|
+
if !ok {
|
|
67
|
+
t.Fatalf("general_agent_settings = %#v, want object", body["general_agent_settings"])
|
|
68
|
+
}
|
|
69
|
+
if settings["image_model"] != "seedream_4.5" {
|
|
70
|
+
t.Fatalf("image_model = %v, want seedream_4.5", settings["image_model"])
|
|
71
|
+
}
|
|
72
|
+
if settings["ratio"] != float64(6) {
|
|
73
|
+
t.Fatalf("ratio = %v, want 6", settings["ratio"])
|
|
74
|
+
}
|
|
75
|
+
if settings["generate_image_count"] != float64(2) {
|
|
76
|
+
t.Fatalf("generate_image_count = %v, want 2", settings["generate_image_count"])
|
|
77
|
+
}
|
|
78
|
+
if _, ok := settings["video_model"]; ok {
|
|
79
|
+
t.Fatalf("video_model should be omitted: %#v", settings)
|
|
80
|
+
}
|
|
81
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"run":{"thread_id":"thread_123","run_id":"run_456"},"web_thread_link":"https://xyq.example/thread_123"}}`))
|
|
82
|
+
default:
|
|
83
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
84
|
+
}
|
|
85
|
+
}))
|
|
86
|
+
defer server.Close()
|
|
87
|
+
|
|
88
|
+
cwd := chdirTemp(t)
|
|
89
|
+
image := filepath.Join(cwd, "cat.png")
|
|
90
|
+
if err := os.WriteFile(image, []byte("image-data"), 0o644); err != nil {
|
|
91
|
+
t.Fatalf("WriteFile(%s): %v", image, err)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
var stdout, stderr bytes.Buffer
|
|
95
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
96
|
+
root.SetArgs([]string{
|
|
97
|
+
"generate-image",
|
|
98
|
+
"--prompt", "生成小猫海报",
|
|
99
|
+
"--image", image,
|
|
100
|
+
"--model", "seedream_4.5",
|
|
101
|
+
"--ratio", "6",
|
|
102
|
+
"--generate-image-count", "2",
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
if err := root.Execute(); err != nil {
|
|
106
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
110
|
+
if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
|
|
111
|
+
t.Fatalf("output = %#v, want thread and run IDs", got)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func TestGenerateImageRequiresModel(t *testing.T) {
|
|
116
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
117
|
+
t.Fatal("server should not receive request without model")
|
|
118
|
+
}))
|
|
119
|
+
defer server.Close()
|
|
120
|
+
|
|
121
|
+
var stdout, stderr bytes.Buffer
|
|
122
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
123
|
+
root.SetArgs([]string{
|
|
124
|
+
"generate-image",
|
|
125
|
+
"--prompt", "x",
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
err := root.Execute()
|
|
129
|
+
if err == nil {
|
|
130
|
+
t.Fatal("Execute() error = nil, want model validation")
|
|
131
|
+
}
|
|
132
|
+
if !strings.Contains(err.Error(), "缺少必填参数 --model") {
|
|
133
|
+
t.Fatalf("error = %q, want model validation", err)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -16,7 +16,7 @@ func NewQueryResultCommand(stdout, stderr io.Writer, runner *common.Runner) *cob
|
|
|
16
16
|
|
|
17
17
|
cmd := &cobra.Command{
|
|
18
18
|
Use: "query-result",
|
|
19
|
-
Short: "Query a
|
|
19
|
+
Short: "Query a run result and download completed videos or images",
|
|
20
20
|
Args: cobra.NoArgs,
|
|
21
21
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
22
22
|
result, err := internalgen.QueryResult(cmd.Context(), opts, runner)
|
|
@@ -37,7 +37,7 @@ func NewQueryResultCommand(stdout, stderr io.Writer, runner *common.Runner) *cob
|
|
|
37
37
|
cmd.SetErr(stderr)
|
|
38
38
|
cmd.Flags().StringVar(&opts.ThreadID, "thread-id", "", "thread_id from generate-video output")
|
|
39
39
|
cmd.Flags().StringVar(&opts.RunID, "run-id", "", "run_id from generate-video output")
|
|
40
|
-
cmd.Flags().StringVar(&opts.DownloadDir, "download-dir", "", "directory to download completed videos into")
|
|
40
|
+
cmd.Flags().StringVar(&opts.DownloadDir, "download-dir", "", "directory to download completed videos or images into")
|
|
41
41
|
return cmd
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -45,6 +45,7 @@ func queryResultFromError(err error, opts *internalgen.QueryResultOptions) *inte
|
|
|
45
45
|
result := &internalgen.QueryResultResult{
|
|
46
46
|
ErrorMessage: err.Error(),
|
|
47
47
|
Videos: []internalgen.QueryResultVideo{},
|
|
48
|
+
Images: []internalgen.QueryResultImage{},
|
|
48
49
|
}
|
|
49
50
|
if opts != nil {
|
|
50
51
|
result.ThreadID = strings.TrimSpace(opts.ThreadID)
|
package/cmd/root.go
CHANGED
|
@@ -7,6 +7,7 @@ import (
|
|
|
7
7
|
"strings"
|
|
8
8
|
|
|
9
9
|
// authcmd "github.com/Pippit-dev/pippit-cli/cmd/auth"
|
|
10
|
+
"github.com/Pippit-dev/pippit-cli/cmd/generate_image"
|
|
10
11
|
"github.com/Pippit-dev/pippit-cli/cmd/generate_video"
|
|
11
12
|
"github.com/Pippit-dev/pippit-cli/cmd/short_drama"
|
|
12
13
|
updatecmd "github.com/Pippit-dev/pippit-cli/cmd/update"
|
|
@@ -32,7 +33,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
|
|
|
32
33
|
root := &cobra.Command{
|
|
33
34
|
Use: "pippit-tool-cli",
|
|
34
35
|
Short: "Pippit CLI",
|
|
35
|
-
Long: "Pippit CLI generates videos, submits short-drama workflows, downloads generated assets, and updates the installed CLI package.",
|
|
36
|
+
Long: "Pippit CLI generates videos and images, submits short-drama workflows, downloads generated assets, and updates the installed CLI package.",
|
|
36
37
|
Version: version.Current(),
|
|
37
38
|
SilenceUsage: true,
|
|
38
39
|
SilenceErrors: true,
|
|
@@ -45,6 +46,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
|
|
|
45
46
|
root.AddCommand(newDownloadResultCommand(stdout, stderr, runner))
|
|
46
47
|
root.AddCommand(newGetThreadCommand(stdout, stderr, runner))
|
|
47
48
|
root.AddCommand(newListThreadFileCommand(stdout, stderr, runner))
|
|
49
|
+
root.AddCommand(generate_image.NewCommand(stdout, stderr, runner))
|
|
48
50
|
root.AddCommand(generate_video.NewCommand(stdout, stderr, runner))
|
|
49
51
|
root.AddCommand(generate_video.NewQueryResultCommand(stdout, stderr, runner))
|
|
50
52
|
root.AddCommand(short_drama.NewCommand(stdout, stderr, runner))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strings"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// ExpandPath expands a leading "~" to the current user's home directory.
|
|
11
|
+
func ExpandPath(path string) (string, error) {
|
|
12
|
+
path = strings.TrimSpace(path)
|
|
13
|
+
if path == "~" {
|
|
14
|
+
return os.UserHomeDir()
|
|
15
|
+
}
|
|
16
|
+
if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
|
|
17
|
+
home, err := os.UserHomeDir()
|
|
18
|
+
if err != nil {
|
|
19
|
+
return "", fmt.Errorf("解析用户主目录失败: %w", err)
|
|
20
|
+
}
|
|
21
|
+
return filepath.Join(home, path[2:]), nil
|
|
22
|
+
}
|
|
23
|
+
return path, nil
|
|
24
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
package generate_image
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strconv"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const (
|
|
14
|
+
agentNameNest = "pippit_nest_agent"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
var (
|
|
18
|
+
allowedImageExtensionList = []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"}
|
|
19
|
+
allowedImageExtensions = common.StringSet(allowedImageExtensionList)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const ratioUsage = "enum values: 0=原始比例/自动, 2=16:9(横屏), 13=21:9(电影), 3=9:16(竖屏), 4=4:3, 5=3:4, 6=1:1"
|
|
23
|
+
|
|
24
|
+
// Options is the stable command-facing request shape for generate-image.
|
|
25
|
+
type Options struct {
|
|
26
|
+
Prompt string
|
|
27
|
+
ImagePaths []string
|
|
28
|
+
Model string
|
|
29
|
+
Ratio string
|
|
30
|
+
GenerateImageCount *int
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type generalAgentSettings struct {
|
|
34
|
+
ImageModel string `json:"image_model"`
|
|
35
|
+
Ratio *int `json:"ratio,omitempty"`
|
|
36
|
+
GenerateImageCount *int `json:"generate_image_count,omitempty"`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Result is the JSON envelope printed by `pippit-tool-cli generate-image`.
|
|
40
|
+
type Result struct {
|
|
41
|
+
ThreadID string `json:"thread_id"`
|
|
42
|
+
RunID string `json:"run_id"`
|
|
43
|
+
WebThreadLink string `json:"web_thread_link"`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, error) {
|
|
47
|
+
if runner == nil || runner.Client == nil {
|
|
48
|
+
return nil, fmt.Errorf("generate-image 运行器客户端缺失")
|
|
49
|
+
}
|
|
50
|
+
if err := ValidateOptions(opts); err != nil {
|
|
51
|
+
return nil, err
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
imageAssetIDs, err := uploadImageList(ctx, opts.ImagePaths, runner)
|
|
55
|
+
if err != nil {
|
|
56
|
+
return nil, fmt.Errorf("上传图片失败: %w", err)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
body := buildSubmitRunBody(opts, imageAssetIDs)
|
|
60
|
+
|
|
61
|
+
var resp common.SubmitRunResponse
|
|
62
|
+
if err := runner.Client.SendRequest(ctx, common.SubmitRunPath(runner), body, &resp); err != nil {
|
|
63
|
+
return nil, fmt.Errorf("提交 generate-image 请求失败: %w", err)
|
|
64
|
+
}
|
|
65
|
+
if resp.Ret != "0" {
|
|
66
|
+
if resp.Errmsg == "" {
|
|
67
|
+
resp.Errmsg = "未知错误"
|
|
68
|
+
}
|
|
69
|
+
return nil, common.NewLogIDError(fmt.Sprintf("generate-image 请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
|
|
70
|
+
}
|
|
71
|
+
if resp.Data.Run.ThreadID == "" {
|
|
72
|
+
return nil, fmt.Errorf("generate-image 响应缺少 data.run.thread_id")
|
|
73
|
+
}
|
|
74
|
+
if resp.Data.Run.RunID == "" {
|
|
75
|
+
return nil, fmt.Errorf("generate-image 响应缺少 data.run.run_id")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return &Result{
|
|
79
|
+
ThreadID: resp.Data.Run.ThreadID,
|
|
80
|
+
RunID: resp.Data.Run.RunID,
|
|
81
|
+
WebThreadLink: resp.Data.WebThreadLink,
|
|
82
|
+
}, nil
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func ValidateOptions(opts *Options) error {
|
|
86
|
+
if opts == nil {
|
|
87
|
+
return fmt.Errorf("缺少必填参数 --prompt")
|
|
88
|
+
}
|
|
89
|
+
if strings.TrimSpace(opts.Prompt) == "" {
|
|
90
|
+
return fmt.Errorf("缺少必填参数 --prompt")
|
|
91
|
+
}
|
|
92
|
+
if strings.TrimSpace(opts.Model) == "" {
|
|
93
|
+
return fmt.Errorf("缺少必填参数 --model")
|
|
94
|
+
}
|
|
95
|
+
if _, err := parseRatio(opts.Ratio); err != nil {
|
|
96
|
+
return err
|
|
97
|
+
}
|
|
98
|
+
if opts.GenerateImageCount != nil && *opts.GenerateImageCount < 0 {
|
|
99
|
+
return fmt.Errorf("--generate-image-count 不能为负数")
|
|
100
|
+
}
|
|
101
|
+
if err := validateImageExtensions(opts.ImagePaths); err != nil {
|
|
102
|
+
return err
|
|
103
|
+
}
|
|
104
|
+
return nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func validateImageExtensions(paths []string) error {
|
|
108
|
+
for _, path := range paths {
|
|
109
|
+
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(path)))
|
|
110
|
+
if _, ok := allowedImageExtensions[ext]; !ok {
|
|
111
|
+
return fmt.Errorf("不支持的图片文件后缀 %q,文件:%q;支持的后缀:%s", ext, path, strings.Join(allowedImageExtensionList, ", "))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func SupportedRatioUsage() string {
|
|
118
|
+
return ratioUsage
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func uploadImageList(ctx context.Context, paths []string, runner *common.Runner) ([]string, error) {
|
|
122
|
+
assetIDs := make([]string, 0, len(paths))
|
|
123
|
+
for _, path := range paths {
|
|
124
|
+
expanded, err := common.ExpandPath(path)
|
|
125
|
+
if err != nil {
|
|
126
|
+
return nil, err
|
|
127
|
+
}
|
|
128
|
+
result, err := common.UploadFile(ctx, common.UploadFileOptions{Path: expanded}, runner)
|
|
129
|
+
if err != nil {
|
|
130
|
+
return nil, err
|
|
131
|
+
}
|
|
132
|
+
assetIDs = append(assetIDs, result.AssetID)
|
|
133
|
+
}
|
|
134
|
+
return assetIDs, nil
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func buildSubmitRunBody(opts *Options, imageAssetIDs []string) map[string]any {
|
|
138
|
+
ratio, _ := parseRatio(opts.Ratio)
|
|
139
|
+
body := map[string]any{
|
|
140
|
+
"agent_name": agentNameNest,
|
|
141
|
+
"message": strings.TrimSpace(opts.Prompt),
|
|
142
|
+
"general_agent_settings": generalAgentSettings{
|
|
143
|
+
ImageModel: strings.TrimSpace(opts.Model),
|
|
144
|
+
Ratio: ratio,
|
|
145
|
+
GenerateImageCount: opts.GenerateImageCount,
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
if len(imageAssetIDs) > 0 {
|
|
149
|
+
body["asset_ids"] = imageAssetIDs
|
|
150
|
+
}
|
|
151
|
+
return body
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func parseRatio(raw string) (*int, error) {
|
|
155
|
+
ratio := strings.TrimSpace(raw)
|
|
156
|
+
if ratio == "" {
|
|
157
|
+
return nil, nil
|
|
158
|
+
}
|
|
159
|
+
value, err := strconv.Atoi(ratio)
|
|
160
|
+
if err != nil {
|
|
161
|
+
return nil, fmt.Errorf("ratio %q 必须是整数枚举值;可参考:%s", ratio, ratioUsage)
|
|
162
|
+
}
|
|
163
|
+
return &value, nil
|
|
164
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
package generate_image
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestValidateOptionsRequiresModel(t *testing.T) {
|
|
9
|
+
opts := &Options{
|
|
10
|
+
Prompt: "x",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
err := ValidateOptions(opts)
|
|
14
|
+
if err == nil {
|
|
15
|
+
t.Fatal("ValidateOptions() error = nil, want model validation")
|
|
16
|
+
}
|
|
17
|
+
if !strings.Contains(err.Error(), "缺少必填参数 --model") {
|
|
18
|
+
t.Fatalf("error = %q, want model validation", err)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func TestValidateOptionsAllowsServerDecidedModel(t *testing.T) {
|
|
23
|
+
opts := &Options{
|
|
24
|
+
Prompt: "x",
|
|
25
|
+
Model: "seedream_3.0",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if err := ValidateOptions(opts); err != nil {
|
|
29
|
+
t.Fatalf("ValidateOptions() error = %v, want nil", err)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestValidateOptionsAllowsServerDecidedRatio(t *testing.T) {
|
|
34
|
+
opts := &Options{
|
|
35
|
+
Prompt: "x",
|
|
36
|
+
Model: "seedream_4.5",
|
|
37
|
+
Ratio: "99",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if err := ValidateOptions(opts); err != nil {
|
|
41
|
+
t.Fatalf("ValidateOptions() error = %v, want nil", err)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func TestValidateOptionsRejectsNegativeGenerateImageCount(t *testing.T) {
|
|
46
|
+
count := -1
|
|
47
|
+
opts := &Options{
|
|
48
|
+
Prompt: "x",
|
|
49
|
+
Model: "seedream_4.5",
|
|
50
|
+
GenerateImageCount: &count,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
err := ValidateOptions(opts)
|
|
54
|
+
if err == nil {
|
|
55
|
+
t.Fatal("ValidateOptions() error = nil, want generate-image-count validation")
|
|
56
|
+
}
|
|
57
|
+
if !strings.Contains(err.Error(), "--generate-image-count 不能为负数") {
|
|
58
|
+
t.Fatalf("error = %q, want generate-image-count validation", err)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestParseRatioSupportsVisibleEnumValues(t *testing.T) {
|
|
63
|
+
cases := []struct {
|
|
64
|
+
ratio string
|
|
65
|
+
want int
|
|
66
|
+
}{
|
|
67
|
+
{ratio: "0", want: 0},
|
|
68
|
+
{ratio: "2", want: 2},
|
|
69
|
+
{ratio: "13", want: 13},
|
|
70
|
+
{ratio: "3", want: 3},
|
|
71
|
+
{ratio: "4", want: 4},
|
|
72
|
+
{ratio: "5", want: 5},
|
|
73
|
+
{ratio: "6", want: 6},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for _, tt := range cases {
|
|
77
|
+
t.Run(tt.ratio, func(t *testing.T) {
|
|
78
|
+
got, err := parseRatio(tt.ratio)
|
|
79
|
+
if err != nil {
|
|
80
|
+
t.Fatalf("parseRatio(%q) error = %v", tt.ratio, err)
|
|
81
|
+
}
|
|
82
|
+
if got == nil || *got != tt.want {
|
|
83
|
+
t.Fatalf("parseRatio(%q) = %#v, want %d", tt.ratio, got, tt.want)
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func TestParseRatioRejectsNonInteger(t *testing.T) {
|
|
90
|
+
_, err := parseRatio("1:1")
|
|
91
|
+
if err == nil {
|
|
92
|
+
t.Fatal("parseRatio() error = nil, want integer validation")
|
|
93
|
+
}
|
|
94
|
+
if !strings.Contains(err.Error(), `ratio "1:1" 必须是整数枚举值`) {
|
|
95
|
+
t.Fatalf("error = %q, want integer validation", err)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func TestValidateOptionsRejectsUnsupportedImageExtension(t *testing.T) {
|
|
100
|
+
opts := &Options{
|
|
101
|
+
Prompt: "x",
|
|
102
|
+
Model: "seedream_4.5",
|
|
103
|
+
ImagePaths: []string{"ref.tiff"},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
err := ValidateOptions(opts)
|
|
107
|
+
if err == nil {
|
|
108
|
+
t.Fatal("ValidateOptions() error = nil, want image extension validation")
|
|
109
|
+
}
|
|
110
|
+
if !strings.Contains(err.Error(), `不支持的图片文件后缀 ".tiff"`) {
|
|
111
|
+
t.Fatalf("error = %q, want image extension validation", err)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func TestBuildSubmitRunBodyWithGeneralAgentSettings(t *testing.T) {
|
|
116
|
+
count := 2
|
|
117
|
+
opts := &Options{
|
|
118
|
+
Prompt: " 生成小猫海报 ",
|
|
119
|
+
Model: " seedream_4.5 ",
|
|
120
|
+
Ratio: "6",
|
|
121
|
+
GenerateImageCount: &count,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
body := buildSubmitRunBody(opts, []string{"asset_1"})
|
|
125
|
+
if body["agent_name"] != agentNameNest {
|
|
126
|
+
t.Fatalf("agent_name = %v, want nest agent", body["agent_name"])
|
|
127
|
+
}
|
|
128
|
+
if body["message"] != "生成小猫海报" {
|
|
129
|
+
t.Fatalf("message = %v, want trimmed prompt", body["message"])
|
|
130
|
+
}
|
|
131
|
+
settings, ok := body["general_agent_settings"].(generalAgentSettings)
|
|
132
|
+
if !ok {
|
|
133
|
+
t.Fatalf("general_agent_settings = %#v, want object", body["general_agent_settings"])
|
|
134
|
+
}
|
|
135
|
+
if settings.ImageModel != "seedream_4.5" {
|
|
136
|
+
t.Fatalf("image_model = %q, want seedream_4.5", settings.ImageModel)
|
|
137
|
+
}
|
|
138
|
+
if settings.Ratio == nil || *settings.Ratio != 6 {
|
|
139
|
+
t.Fatalf("ratio = %#v, want 6", settings.Ratio)
|
|
140
|
+
}
|
|
141
|
+
if settings.GenerateImageCount == nil || *settings.GenerateImageCount != 2 {
|
|
142
|
+
t.Fatalf("generate_image_count = %#v, want 2", settings.GenerateImageCount)
|
|
143
|
+
}
|
|
144
|
+
assetIDs, ok := body["asset_ids"].([]string)
|
|
145
|
+
if !ok || len(assetIDs) != 1 || assetIDs[0] != "asset_1" {
|
|
146
|
+
t.Fatalf("asset_ids = %#v, want asset_1", body["asset_ids"])
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -32,6 +32,7 @@ type QueryResultResult struct {
|
|
|
32
32
|
RunID string `json:"run_id"`
|
|
33
33
|
ErrorMessage string `json:"error_message"`
|
|
34
34
|
Videos []QueryResultVideo `json:"videos"`
|
|
35
|
+
Images []QueryResultImage `json:"images"`
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
// QueryResultVideo describes a downloaded video from query-result.
|
|
@@ -40,6 +41,12 @@ type QueryResultVideo struct {
|
|
|
40
41
|
OutputPath string `json:"output_path"`
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// QueryResultImage describes a downloaded image from query-result.
|
|
45
|
+
type QueryResultImage struct {
|
|
46
|
+
DownloadURL string `json:"download_url"`
|
|
47
|
+
OutputPath string `json:"output_path"`
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
type queryThread struct {
|
|
44
51
|
ThreadID string `json:"thread_id"`
|
|
45
52
|
RunList []queryRun `json:"run_list"`
|
|
@@ -69,6 +76,7 @@ type queryContent struct {
|
|
|
69
76
|
|
|
70
77
|
type queryContentData struct {
|
|
71
78
|
Video *queryVideo `json:"video"`
|
|
79
|
+
Image *queryImage `json:"image"`
|
|
72
80
|
ErrorMessage string `json:"error_message"`
|
|
73
81
|
ErrorCode json.RawMessage `json:"error_code"`
|
|
74
82
|
}
|
|
@@ -80,6 +88,16 @@ type queryVideo struct {
|
|
|
80
88
|
AssetID string `json:"asset_id"`
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
type queryImage struct {
|
|
92
|
+
DownloadURL string `json:"url"`
|
|
93
|
+
AssetID string `json:"asset_id"`
|
|
94
|
+
Metadata queryImageMeta `json:"metadata"`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type queryImageMeta struct {
|
|
98
|
+
Format string `json:"format"`
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.Runner) (*QueryResultResult, error) {
|
|
84
102
|
if err := validateQueryResultOptions(opts); err != nil {
|
|
85
103
|
return nil, err
|
|
@@ -111,6 +129,7 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
|
|
|
111
129
|
ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
|
|
112
130
|
RunID: opts.RunID,
|
|
113
131
|
Videos: []QueryResultVideo{},
|
|
132
|
+
Images: []QueryResultImage{},
|
|
114
133
|
}
|
|
115
134
|
if run.State == failedRunState {
|
|
116
135
|
result.ErrorMessage = firstNonEmpty(extractQueryErrorMessage(run), "Run 失败")
|
|
@@ -119,17 +138,19 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
|
|
|
119
138
|
}
|
|
120
139
|
|
|
121
140
|
videos := extractQueryVideos(run)
|
|
122
|
-
|
|
123
|
-
|
|
141
|
+
images := extractQueryImages(run)
|
|
142
|
+
if len(videos) == 0 && len(images) == 0 {
|
|
143
|
+
return nil, fmt.Errorf("下载失败:未找到可下载的产物")
|
|
124
144
|
}
|
|
125
145
|
|
|
126
|
-
downloadDir, err :=
|
|
146
|
+
downloadDir, err := common.ExpandPath(opts.DownloadDir)
|
|
127
147
|
if err != nil {
|
|
128
148
|
return nil, fmt.Errorf("下载失败:解析下载目录失败:%w", err)
|
|
129
149
|
}
|
|
130
150
|
|
|
151
|
+
usedNames := make(map[string]int, len(videos)+len(images))
|
|
152
|
+
|
|
131
153
|
resultVideos := make([]QueryResultVideo, 0, len(videos))
|
|
132
|
-
usedNames := make(map[string]int, len(videos))
|
|
133
154
|
for i, video := range videos {
|
|
134
155
|
if strings.TrimSpace(video.DownloadURL) == "" {
|
|
135
156
|
return nil, fmt.Errorf("下载失败:第 %d 个视频产物 download_url 为空", i+1)
|
|
@@ -155,11 +176,38 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
|
|
|
155
176
|
})
|
|
156
177
|
}
|
|
157
178
|
|
|
179
|
+
resultImages := make([]QueryResultImage, 0, len(images))
|
|
180
|
+
for i, image := range images {
|
|
181
|
+
if strings.TrimSpace(image.DownloadURL) == "" {
|
|
182
|
+
return nil, fmt.Errorf("下载失败:第 %d 个图片产物 download_url 为空", i+1)
|
|
183
|
+
}
|
|
184
|
+
outputPath := filepath.Join(downloadDir, uniqueQueryResultFileName(imageFileName(image, i+1), usedNames))
|
|
185
|
+
download, err := common.DownloadResult(ctx, common.DownloadResultOptions{
|
|
186
|
+
URL: image.DownloadURL,
|
|
187
|
+
OutputPath: outputPath,
|
|
188
|
+
Workers: 5,
|
|
189
|
+
}, runner)
|
|
190
|
+
if err != nil {
|
|
191
|
+
return nil, fmt.Errorf("下载失败:%w", err)
|
|
192
|
+
}
|
|
193
|
+
actualOutputPath := outputPath
|
|
194
|
+
if len(download.Downloaded) > 0 {
|
|
195
|
+
actualOutputPath = download.Downloaded[0]
|
|
196
|
+
} else if len(download.AlreadyExist) > 0 {
|
|
197
|
+
actualOutputPath = download.AlreadyExist[0]
|
|
198
|
+
}
|
|
199
|
+
resultImages = append(resultImages, QueryResultImage{
|
|
200
|
+
DownloadURL: image.DownloadURL,
|
|
201
|
+
OutputPath: actualOutputPath,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
158
205
|
return &QueryResultResult{
|
|
159
206
|
Completed: true,
|
|
160
207
|
ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
|
|
161
208
|
RunID: opts.RunID,
|
|
162
209
|
Videos: resultVideos,
|
|
210
|
+
Images: resultImages,
|
|
163
211
|
}, nil
|
|
164
212
|
}
|
|
165
213
|
|
|
@@ -181,6 +229,7 @@ func queryResultFromGetThreadBusinessError(err error, opts *QueryResultOptions)
|
|
|
181
229
|
RunID: opts.RunID,
|
|
182
230
|
ErrorMessage: message,
|
|
183
231
|
Videos: []QueryResultVideo{},
|
|
232
|
+
Images: []QueryResultImage{},
|
|
184
233
|
}, true
|
|
185
234
|
}
|
|
186
235
|
|
|
@@ -288,6 +337,23 @@ func extractQueryVideos(run queryRun) []queryVideo {
|
|
|
288
337
|
return videos
|
|
289
338
|
}
|
|
290
339
|
|
|
340
|
+
func extractQueryImages(run queryRun) []queryImage {
|
|
341
|
+
images := make([]queryImage, 0)
|
|
342
|
+
for _, entry := range run.EntryList {
|
|
343
|
+
artifact := entry.Artifact
|
|
344
|
+
for _, content := range artifact.Content {
|
|
345
|
+
if content.SubType != "biz/x_data_image" {
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
data := content.Data
|
|
349
|
+
if data.Image != nil {
|
|
350
|
+
images = append(images, *data.Image)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return images
|
|
355
|
+
}
|
|
356
|
+
|
|
291
357
|
func extractQueryErrorMessage(run queryRun) string {
|
|
292
358
|
if message := firstNonEmpty(run.ErrorMessage, run.ErrorMsg, run.Errmsg); message != "" {
|
|
293
359
|
return message
|
|
@@ -342,6 +408,42 @@ func hasVideoExtension(name string) bool {
|
|
|
342
408
|
}
|
|
343
409
|
}
|
|
344
410
|
|
|
411
|
+
func imageFileName(image queryImage, index int) string {
|
|
412
|
+
name := image.AssetID
|
|
413
|
+
if name == "" {
|
|
414
|
+
name = "result_" + strconv.Itoa(index)
|
|
415
|
+
}
|
|
416
|
+
name = sanitizeFileName(name)
|
|
417
|
+
if !hasImageExtension(name) {
|
|
418
|
+
name += "." + normalizeImageFormatExt(image.Metadata.Format)
|
|
419
|
+
}
|
|
420
|
+
return name
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// normalizeImageFormatExt maps the server-provided metadata.format to a safe
|
|
424
|
+
// file extension. Only a known allowlist is accepted; anything else (including
|
|
425
|
+
// "image/jpeg", ".jpeg", or empty values) falls back to "png".
|
|
426
|
+
func normalizeImageFormatExt(format string) string {
|
|
427
|
+
format = strings.ToLower(strings.TrimSpace(format))
|
|
428
|
+
format = strings.TrimPrefix(format, "image/")
|
|
429
|
+
format = strings.TrimPrefix(format, ".")
|
|
430
|
+
switch format {
|
|
431
|
+
case "jpg", "jpeg", "png", "gif", "bmp", "webp", "svg":
|
|
432
|
+
return format
|
|
433
|
+
default:
|
|
434
|
+
return "png"
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
func hasImageExtension(name string) bool {
|
|
439
|
+
switch strings.ToLower(strings.TrimSpace(filepath.Ext(name))) {
|
|
440
|
+
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg":
|
|
441
|
+
return true
|
|
442
|
+
default:
|
|
443
|
+
return false
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
345
447
|
func firstNonEmpty(values ...string) string {
|
|
346
448
|
for _, value := range values {
|
|
347
449
|
value = strings.TrimSpace(value)
|
|
@@ -355,7 +457,7 @@ func firstNonEmpty(values ...string) string {
|
|
|
355
457
|
func sanitizeFileName(name string) string {
|
|
356
458
|
name = strings.TrimSpace(name)
|
|
357
459
|
if name == "" {
|
|
358
|
-
return "result
|
|
460
|
+
return "result"
|
|
359
461
|
}
|
|
360
462
|
return strings.Map(func(r rune) rune {
|
|
361
463
|
if unicode.IsControl(r) || r == '/' || r == '\\' || strings.ContainsRune(`<>:"|?*`, r) {
|
|
@@ -29,3 +29,98 @@ func TestVideoFileNameKeepsVideoExtension(t *testing.T) {
|
|
|
29
29
|
t.Fatalf("videoFileName() = %q, want cat_video.mp4", got)
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
func TestImageFileNameUsesAssetID(t *testing.T) {
|
|
34
|
+
got := imageFileName(queryImage{
|
|
35
|
+
AssetID: "7659311708893512254",
|
|
36
|
+
Metadata: queryImageMeta{Format: "jpeg"},
|
|
37
|
+
}, 1)
|
|
38
|
+
want := "7659311708893512254.jpeg"
|
|
39
|
+
if got != want {
|
|
40
|
+
t.Fatalf("imageFileName() = %q, want %q", got, want)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestImageFileNameAddsExtensionFromFormat(t *testing.T) {
|
|
45
|
+
got := imageFileName(queryImage{
|
|
46
|
+
AssetID: "pic1",
|
|
47
|
+
Metadata: queryImageMeta{Format: "png"},
|
|
48
|
+
}, 1)
|
|
49
|
+
if got != "pic1.png" {
|
|
50
|
+
t.Fatalf("imageFileName() = %q, want pic1.png", got)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func TestImageFileNameFallsBackToPngWhenFormatEmpty(t *testing.T) {
|
|
55
|
+
got := imageFileName(queryImage{
|
|
56
|
+
AssetID: "pic1",
|
|
57
|
+
Metadata: queryImageMeta{Format: ""},
|
|
58
|
+
}, 1)
|
|
59
|
+
if got != "pic1.png" {
|
|
60
|
+
t.Fatalf("imageFileName() = %q, want pic1.png", got)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func TestImageFileNameNormalizesMimeTypeFormat(t *testing.T) {
|
|
65
|
+
cases := []struct {
|
|
66
|
+
format string
|
|
67
|
+
want string
|
|
68
|
+
}{
|
|
69
|
+
{format: "image/jpeg", want: "pic1.jpeg"},
|
|
70
|
+
{format: ".jpeg", want: "pic1.jpeg"},
|
|
71
|
+
{format: "JPEG", want: "pic1.jpeg"},
|
|
72
|
+
{format: "image/png", want: "pic1.png"},
|
|
73
|
+
{format: "image/webp", want: "pic1.webp"},
|
|
74
|
+
{format: "image/svg+xml", want: "pic1.png"}, // unsupported, falls back
|
|
75
|
+
{format: "application/octet-stream", want: "pic1.png"},
|
|
76
|
+
}
|
|
77
|
+
for _, tt := range cases {
|
|
78
|
+
t.Run(tt.format, func(t *testing.T) {
|
|
79
|
+
got := imageFileName(queryImage{AssetID: "pic1", Metadata: queryImageMeta{Format: tt.format}}, 1)
|
|
80
|
+
if got != tt.want {
|
|
81
|
+
t.Fatalf("imageFileName(format=%q) = %q, want %q", tt.format, got, tt.want)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func TestImageFileNameUsesResultIndexWhenNoID(t *testing.T) {
|
|
88
|
+
got := imageFileName(queryImage{
|
|
89
|
+
Metadata: queryImageMeta{Format: "jpeg"},
|
|
90
|
+
}, 2)
|
|
91
|
+
if got != "result_2.jpeg" {
|
|
92
|
+
t.Fatalf("imageFileName() = %q, want result_2.jpeg", got)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func TestImageFileNameKeepsExistingImageExtension(t *testing.T) {
|
|
97
|
+
got := imageFileName(queryImage{
|
|
98
|
+
AssetID: "cat_poster.png",
|
|
99
|
+
Metadata: queryImageMeta{Format: "jpeg"},
|
|
100
|
+
}, 1)
|
|
101
|
+
if got != "cat_poster.png" {
|
|
102
|
+
t.Fatalf("imageFileName() = %q, want cat_poster.png", got)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func TestExtractQueryImagesFiltersBySubType(t *testing.T) {
|
|
107
|
+
run := queryRun{
|
|
108
|
+
EntryList: []queryEntry{
|
|
109
|
+
{Artifact: queryArtifact{Content: []queryContent{
|
|
110
|
+
{SubType: "biz/x_data_image", Data: queryContentData{Image: &queryImage{DownloadURL: "https://x/a.jpeg", AssetID: "p1"}}},
|
|
111
|
+
{SubType: "biz/x_data_video", Data: queryContentData{Video: &queryVideo{DownloadURL: "https://x/v.mp4", VID: "v1"}}},
|
|
112
|
+
}}},
|
|
113
|
+
{Artifact: queryArtifact{Content: []queryContent{
|
|
114
|
+
{SubType: "biz/x_data_image", Data: queryContentData{Image: &queryImage{DownloadURL: "https://x/b.png", AssetID: "p2"}}},
|
|
115
|
+
{SubType: "text/plain", Data: queryContentData{}},
|
|
116
|
+
}}},
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
got := extractQueryImages(run)
|
|
120
|
+
if len(got) != 2 {
|
|
121
|
+
t.Fatalf("extractQueryImages() = %d images, want 2", len(got))
|
|
122
|
+
}
|
|
123
|
+
if got[0].AssetID != "p1" || got[1].AssetID != "p2" {
|
|
124
|
+
t.Fatalf("extractQueryImages() = %q,%q, want p1,p2", got[0].AssetID, got[1].AssetID)
|
|
125
|
+
}
|
|
126
|
+
}
|