@pippit-dev/cli 1.0.1 → 1.0.2
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 -3
- package/checksums.txt +6 -6
- package/cmd/download_result.go +1 -1
- package/cmd/generate_video/generate_video.go +48 -0
- package/cmd/generate_video/query_result.go +58 -0
- package/cmd/generate_video_test.go +468 -0
- package/cmd/get_thread.go +1 -0
- package/cmd/list_thread_file.go +1 -1
- package/cmd/root.go +27 -2
- package/cmd/short_drama/short_drama.go +52 -9
- package/cmd/short_drama_test.go +19 -19
- package/cmd/update/update.go +8 -8
- package/internal/common/client.go +29 -21
- package/internal/common/download_results.go +20 -14
- package/internal/common/error_log.go +62 -1
- package/internal/common/error_log_test.go +30 -0
- package/internal/common/get_thread.go +28 -14
- package/internal/common/get_thread_test.go +74 -0
- package/{cmd → internal/common}/json.go +3 -2
- package/internal/common/list_thread_file.go +6 -6
- package/internal/common/submit_run.go +29 -0
- package/internal/common/upload_file.go +23 -19
- package/internal/config/config.go +1 -1
- package/internal/generate_video/generate_video.go +199 -0
- package/internal/generate_video/query_result.go +291 -0
- package/internal/generate_video/query_result_test.go +31 -0
- package/internal/short_drama/submit_run.go +8 -28
- package/package.json +1 -1
- package/skills/short-drama/SKILL.md +8 -8
- package/skills/xyq-nest-skill/scripts/download_results.py +3 -1
- package/skills/xyq-nest-skill/scripts/upload_file.py +2 -2
- package/skills/xyq-nest-skill/scripts/xyq_common.py +4 -3
- package/cmd/upload_file.go +0 -50
package/README.md
CHANGED
|
@@ -196,7 +196,7 @@ npx @pippit-dev/cli@latest install
|
|
|
196
196
|
export XYQ_ACCESS_KEY="<access-key>"
|
|
197
197
|
pippit-tool-cli --version
|
|
198
198
|
pippit-tool-cli short-drama +submit-run --message "写一个赛博朋克短剧开头"
|
|
199
|
-
pippit-tool-cli upload-file --path ./reference.doc
|
|
199
|
+
pippit-tool-cli short-drama +upload-file --path ./reference.doc
|
|
200
200
|
pippit-tool-cli get-thread --thread-id thread_123 --run-id run_456
|
|
201
201
|
pippit-tool-cli list-thread-file --thread-id thread_123 --page-num 1 --page-size 200
|
|
202
202
|
pippit-tool-cli download-result --output-path ./thread_123/results/result.mp4 --url URL --updated-at 1779716734
|
|
@@ -205,15 +205,45 @@ pippit-tool-cli download-result --output-path ./thread_123/results/result.mp4 --
|
|
|
205
205
|
`+submit-run`: 输出 `thread_id`、`run_id` 和 `web_thread_link`;其中 `--message` 为必填参数。
|
|
206
206
|
`get-thread`: 请求中带 `version=v2`,并输出 `readable_text`。
|
|
207
207
|
`list-thread-file`: 输出会话文件列表、分页提示和可直接传给下载命令的 `file_path`。
|
|
208
|
-
|
|
208
|
+
`+upload-file`: 输出返回的 `asset_id`。 当前仅支持 `.doc`、`.docx` 和 `.txt` 文件。
|
|
209
209
|
`download-result`: 会把结果 URL 下载到 `--output-path` 指定的文件路径;传入 `--updated-at` 后,如果本地文件早于该时间戳会覆盖更新,否则跳过。
|
|
210
210
|
|
|
211
211
|
短剧命令的错误日志会追加写入本地每日日志文件:`~/.pippit_tool_cli/logs/yyyy-mm-dd.log`。日志路径会基于当前用户主目录和系统路径分隔符生成,因此可在 macOS、Linux 和 Windows 上使用。
|
|
212
212
|
|
|
213
|
+
## 生视频 CLI
|
|
214
|
+
|
|
215
|
+
`generate-video` 会上传本地参考图片和视频,然后向视频片段 Agent 提交生视频请求:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
pippit-tool-cli generate-video \
|
|
219
|
+
--prompt "做个小猫视频" \
|
|
220
|
+
--image "~/images/cat1.jpg" \
|
|
221
|
+
--image "~/images/cat2.jpg" \
|
|
222
|
+
--video "~/images/video1.mp4" \
|
|
223
|
+
--video "~/images/video2.mp4" \
|
|
224
|
+
--duration 5 \
|
|
225
|
+
--ratio "9:16" \
|
|
226
|
+
--model "seedance2.0_direct" \
|
|
227
|
+
--resolution "720p"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交生视频 HTTP 请求时,参考图和参考视频会使用上传接口返回的 `pippit_asset_id`。图片最多 9 张,支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`;视频最多 3 个,支持 `.mp4`、`.avi`、`.mov`、`.wmv`、`.flv`、`.webm`、`.mkv`、`.m4v`。普通用户支持模型 `seedance2.0_direct` 和 `seedance2.0_fast_direct`;VIP 用户额外支持 `seedance2.0_vision` 和 `seedance2.0_fast_vision`。模型、比例、分辨率、素材数量等参数的语义校验当前预留在后续实现。
|
|
231
|
+
|
|
232
|
+
查询并下载生视频结果:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
pippit-tool-cli query-result \
|
|
236
|
+
--thread-id "skill_xxx" \
|
|
237
|
+
--run-id "skill_xxx" \
|
|
238
|
+
--download-dir "./output"
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`query-result` 会查询指定 Run;Run 完成后下载视频产物并输出本地结果路径,未完成时输出当前状态和重试提示。
|
|
242
|
+
|
|
213
243
|
## HTTP 客户端
|
|
214
244
|
|
|
215
245
|
命令模块通过 `common.Runner` 发起服务调用。运行时配置,例如基础地址、HTTP 超时时间和接口路径,由 `internal/config` 加载,并在运行器中与 `common.Client` 组合使用。
|
|
216
246
|
|
|
217
247
|
## 鉴权
|
|
218
248
|
|
|
219
|
-
`short-drama +submit-run`、`get-thread`、`list-thread-file`、`upload-file` 以及 `xyq-skill` Python 脚本都使用 `Authorization: Bearer <XYQ_ACCESS_KEY>` 鉴权。OAuth 命令代码仍保留在仓库中,但短剧运行时请求不使用 OAuth。
|
|
249
|
+
`short-drama +submit-run`、`get-thread`、`list-thread-file`、`short-drama +upload-file` 以及 `xyq-skill` Python 脚本都使用 `Authorization: Bearer <XYQ_ACCESS_KEY>` 鉴权。OAuth 命令代码仍保留在仓库中,但短剧运行时请求不使用 OAuth。
|
package/checksums.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
2674fbc4cf095e321dcba42a0e7ed44ecb50c14236a07c97dbc522227c8819e1 pippit-tool-cli-1.0.2-darwin-amd64.tar.gz
|
|
2
|
+
f58a8babc317bef904699938b69574bb26d28cf191fff5123be305be77e27c3b pippit-tool-cli-1.0.2-darwin-arm64.tar.gz
|
|
3
|
+
3b33fbf6bc66f77b54e1436c5e231f355fe10787d41fe1310b6575b36e7c7cee pippit-tool-cli-1.0.2-linux-amd64.tar.gz
|
|
4
|
+
f5e73a43c79d5d4a35872a05ad9d14c1260753042a004ef358c475e7e7c4e764 pippit-tool-cli-1.0.2-linux-arm64.tar.gz
|
|
5
|
+
36f48c0db0f6524d43fd49d2e2cea014166c9dad85834f1cd498c2e8ebfeafd4 pippit-tool-cli-1.0.2-windows-amd64.zip
|
|
6
|
+
b45999f5e9412a2d7728e538b9cedbb715f4ffc397c667e610bcb964311db8f0 pippit-tool-cli-1.0.2-windows-arm64.zip
|
package/cmd/download_result.go
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package generate_video
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"io"
|
|
5
|
+
"strconv"
|
|
6
|
+
|
|
7
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
8
|
+
internalgen "github.com/Pippit-dev/pippit-cli/internal/generate_video"
|
|
9
|
+
"github.com/spf13/cobra"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// NewCommand builds the generate-video command.
|
|
13
|
+
func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
14
|
+
opts := &internalgen.Options{}
|
|
15
|
+
var durationSec int
|
|
16
|
+
|
|
17
|
+
cmd := &cobra.Command{
|
|
18
|
+
Use: "generate-video",
|
|
19
|
+
Short: "Generate a video with the video part agent",
|
|
20
|
+
Args: cobra.NoArgs,
|
|
21
|
+
RunE: func(cmd *cobra.Command, args []string) error {
|
|
22
|
+
if cmd.Flags().Changed("duration") {
|
|
23
|
+
opts.DurationSec = &durationSec
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
result, err := internalgen.Run(cmd.Context(), opts, runner)
|
|
27
|
+
if err != nil {
|
|
28
|
+
_ = common.AppendDailyErrorLog("generate-video", err, map[string]string{
|
|
29
|
+
"image_count": strconv.Itoa(len(opts.ImagePaths)),
|
|
30
|
+
"video_count": strconv.Itoa(len(opts.VideoPaths)),
|
|
31
|
+
})
|
|
32
|
+
return err
|
|
33
|
+
}
|
|
34
|
+
return common.WriteJSON(stdout, result)
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
cmd.SetOut(stdout)
|
|
38
|
+
cmd.SetErr(stderr)
|
|
39
|
+
flags := cmd.Flags()
|
|
40
|
+
flags.StringVar(&opts.Prompt, "prompt", "", "video generation prompt")
|
|
41
|
+
flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; repeat for multiple images, up to 9")
|
|
42
|
+
flags.StringArrayVar(&opts.VideoPaths, "video", nil, "local reference video path; repeat for multiple videos, up to 3")
|
|
43
|
+
flags.IntVar(&durationSec, "duration", 0, "video duration in seconds")
|
|
44
|
+
flags.StringVar(&opts.Ratio, "ratio", "", "video ratio, such as 9:16, 16:9, 3:4, 4:3")
|
|
45
|
+
flags.StringVar(&opts.Model, "model", "", "video model; normal users: seedance2.0_direct, seedance2.0_fast_direct; VIP users: seedance2.0_direct, seedance2.0_fast_direct, seedance2.0_vision, seedance2.0_fast_vision")
|
|
46
|
+
flags.StringVar(&opts.Resolution, "resolution", "", "video resolution, such as 720p, 1080p")
|
|
47
|
+
return cmd
|
|
48
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package generate_video
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
9
|
+
internalgen "github.com/Pippit-dev/pippit-cli/internal/generate_video"
|
|
10
|
+
"github.com/spf13/cobra"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// NewQueryResultCommand builds the query-result command.
|
|
14
|
+
func NewQueryResultCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
15
|
+
opts := &internalgen.QueryResultOptions{}
|
|
16
|
+
|
|
17
|
+
cmd := &cobra.Command{
|
|
18
|
+
Use: "query-result",
|
|
19
|
+
Short: "Query a generate-video run result and download completed videos",
|
|
20
|
+
Args: cobra.NoArgs,
|
|
21
|
+
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
22
|
+
result, err := internalgen.QueryResult(cmd.Context(), opts, runner)
|
|
23
|
+
if err != nil {
|
|
24
|
+
_ = common.AppendDailyErrorLog("query-result", err, map[string]string{
|
|
25
|
+
"thread_id": strings.TrimSpace(opts.ThreadID),
|
|
26
|
+
"run_id": strings.TrimSpace(opts.RunID),
|
|
27
|
+
"download_dir": strings.TrimSpace(opts.DownloadDir),
|
|
28
|
+
})
|
|
29
|
+
return err
|
|
30
|
+
}
|
|
31
|
+
if !result.Completed {
|
|
32
|
+
_, err = fmt.Fprintf(stdout, "Run 尚未完成,当前状态:%d\n请稍后重试 query-result。\n", result.State)
|
|
33
|
+
return err
|
|
34
|
+
}
|
|
35
|
+
_, err = fmt.Fprintln(stdout, "Run 已完成,产物已下载:")
|
|
36
|
+
if err != nil {
|
|
37
|
+
return err
|
|
38
|
+
}
|
|
39
|
+
for i, path := range result.OutputPaths {
|
|
40
|
+
if _, err := fmt.Fprintln(stdout, path); err != nil {
|
|
41
|
+
return err
|
|
42
|
+
}
|
|
43
|
+
if i < len(result.DownloadURLs) && strings.TrimSpace(result.DownloadURLs[i]) != "" {
|
|
44
|
+
if _, err := fmt.Fprintf(stdout, "download_url: %s\n", result.DownloadURLs[i]); err != nil {
|
|
45
|
+
return err
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return nil
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
cmd.SetOut(stdout)
|
|
53
|
+
cmd.SetErr(stderr)
|
|
54
|
+
cmd.Flags().StringVar(&opts.ThreadID, "thread-id", "", "thread_id from generate-video output")
|
|
55
|
+
cmd.Flags().StringVar(&opts.RunID, "run-id", "", "run_id from generate-video output")
|
|
56
|
+
cmd.Flags().StringVar(&opts.DownloadDir, "download-dir", "", "directory to download completed videos into")
|
|
57
|
+
return cmd
|
|
58
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
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 TestGenerateVideo(t *testing.T) {
|
|
17
|
+
assetIDs := []string{"image_asset_1", "image_asset_2", "video_asset_1", "video_asset_2"}
|
|
18
|
+
uploadIndex := 0
|
|
19
|
+
|
|
20
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
21
|
+
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
22
|
+
t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
|
|
23
|
+
}
|
|
24
|
+
switch r.URL.Path {
|
|
25
|
+
case "/api/biz/v1/skill/upload_file":
|
|
26
|
+
if r.Method != http.MethodPost {
|
|
27
|
+
t.Fatalf("upload method = %s, want POST", r.Method)
|
|
28
|
+
}
|
|
29
|
+
if uploadIndex >= len(assetIDs) {
|
|
30
|
+
t.Fatalf("unexpected upload %d", uploadIndex)
|
|
31
|
+
}
|
|
32
|
+
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
33
|
+
t.Fatalf("ParseMultipartForm(): %v", err)
|
|
34
|
+
}
|
|
35
|
+
files := r.MultipartForm.File["file"]
|
|
36
|
+
if len(files) != 1 {
|
|
37
|
+
t.Fatalf("file parts = %d, want 1", len(files))
|
|
38
|
+
}
|
|
39
|
+
uploadIndex++
|
|
40
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"pippit_asset_id":"` + assetIDs[uploadIndex-1] + `"}}`))
|
|
41
|
+
case "/api/biz/v1/skill/submit_run":
|
|
42
|
+
if uploadIndex != len(assetIDs) {
|
|
43
|
+
t.Fatalf("submit called after %d uploads, want %d", uploadIndex, len(assetIDs))
|
|
44
|
+
}
|
|
45
|
+
data, err := io.ReadAll(r.Body)
|
|
46
|
+
if err != nil {
|
|
47
|
+
t.Fatalf("read body: %v", err)
|
|
48
|
+
}
|
|
49
|
+
var body map[string]any
|
|
50
|
+
if err := sonic.Unmarshal(data, &body); err != nil {
|
|
51
|
+
t.Fatalf("decode body: %v", err)
|
|
52
|
+
}
|
|
53
|
+
if body["agent_name"] != "pippit_video_part_agent" {
|
|
54
|
+
t.Fatalf("agent_name = %v, want video part agent", body["agent_name"])
|
|
55
|
+
}
|
|
56
|
+
if body["message"] != "做个小猫视频" {
|
|
57
|
+
t.Fatalf("message = %v, want submitted prompt", body["message"])
|
|
58
|
+
}
|
|
59
|
+
param, ok := body["video_part_tool_param"].(map[string]any)
|
|
60
|
+
if !ok {
|
|
61
|
+
t.Fatalf("video_part_tool_param = %#v, want object", body["video_part_tool_param"])
|
|
62
|
+
}
|
|
63
|
+
if param["prompt"] != "做个小猫视频" {
|
|
64
|
+
t.Fatalf("prompt = %v, want submitted prompt", param["prompt"])
|
|
65
|
+
}
|
|
66
|
+
if param["duration_sec"] != float64(5) {
|
|
67
|
+
t.Fatalf("duration_sec = %v, want 5", param["duration_sec"])
|
|
68
|
+
}
|
|
69
|
+
if param["ratio"] != "9:16" || param["model"] != "seedance2.0_vision" || param["resolution"] != "720p" {
|
|
70
|
+
t.Fatalf("param = %#v, want ratio/model/resolution", param)
|
|
71
|
+
}
|
|
72
|
+
assertAssetRefs(t, param["images"], []string{"image_asset_1", "image_asset_2"})
|
|
73
|
+
assertAssetRefs(t, param["videos"], []string{"video_asset_1", "video_asset_2"})
|
|
74
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"run":{"thread_id":"thread_123","run_id":"run_456"},"web_thread_link":"https://xyq.example/thread_123"}}`))
|
|
75
|
+
default:
|
|
76
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
77
|
+
}
|
|
78
|
+
}))
|
|
79
|
+
defer server.Close()
|
|
80
|
+
|
|
81
|
+
cwd := chdirTemp(t)
|
|
82
|
+
image1 := filepath.Join(cwd, "cat1.jpg")
|
|
83
|
+
image2 := filepath.Join(cwd, "cat2.jpg")
|
|
84
|
+
video1 := filepath.Join(cwd, "video1.mp4")
|
|
85
|
+
video2 := filepath.Join(cwd, "video2.mp4")
|
|
86
|
+
for _, path := range []string{image1, image2, video1, video2} {
|
|
87
|
+
if err := os.WriteFile(path, []byte("media-data"), 0o644); err != nil {
|
|
88
|
+
t.Fatalf("WriteFile(%s): %v", path, err)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
var stdout, stderr bytes.Buffer
|
|
93
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
94
|
+
root.SetArgs([]string{
|
|
95
|
+
"generate-video",
|
|
96
|
+
"--prompt", "做个小猫视频",
|
|
97
|
+
"--image", image1,
|
|
98
|
+
"--image", image2,
|
|
99
|
+
"--video", video1,
|
|
100
|
+
"--video", video2,
|
|
101
|
+
"--duration", "5",
|
|
102
|
+
"--ratio", "9:16",
|
|
103
|
+
"--model", "seedance2.0_vision",
|
|
104
|
+
"--resolution", "720p",
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if err := root.Execute(); err != nil {
|
|
108
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
112
|
+
if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
|
|
113
|
+
t.Fatalf("output = %#v, want thread and run IDs", got)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func TestGenerateVideoSkipsSemanticValidation(t *testing.T) {
|
|
118
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
119
|
+
switch r.URL.Path {
|
|
120
|
+
case "/api/biz/v1/skill/submit_run":
|
|
121
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"run":{"thread_id":"thread_123","run_id":"run_456"}}}`))
|
|
122
|
+
default:
|
|
123
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
124
|
+
}
|
|
125
|
+
}))
|
|
126
|
+
defer server.Close()
|
|
127
|
+
|
|
128
|
+
var stdout, stderr bytes.Buffer
|
|
129
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
130
|
+
root.SetArgs([]string{
|
|
131
|
+
"generate-video",
|
|
132
|
+
"--prompt", "x",
|
|
133
|
+
"--duration", "1",
|
|
134
|
+
"--ratio", "1:1",
|
|
135
|
+
"--model", "bad_model",
|
|
136
|
+
"--resolution", "bad_resolution",
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
if err := root.Execute(); err != nil {
|
|
140
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func TestGenerateVideoRequiresPrompt(t *testing.T) {
|
|
145
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
146
|
+
t.Fatal("server should not receive request without prompt")
|
|
147
|
+
}))
|
|
148
|
+
defer server.Close()
|
|
149
|
+
|
|
150
|
+
var stdout, stderr bytes.Buffer
|
|
151
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
152
|
+
root.SetArgs([]string{
|
|
153
|
+
"generate-video",
|
|
154
|
+
"--duration", "1",
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
err := root.Execute()
|
|
158
|
+
if err == nil {
|
|
159
|
+
t.Fatal("Execute() error = nil, want prompt validation")
|
|
160
|
+
}
|
|
161
|
+
if !strings.Contains(err.Error(), "缺少必填参数 --prompt") {
|
|
162
|
+
t.Fatalf("error = %q, want prompt validation", err)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func TestGenerateVideoRejectsTooManyImages(t *testing.T) {
|
|
167
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
168
|
+
t.Fatal("server should not receive request when image count is invalid")
|
|
169
|
+
}))
|
|
170
|
+
defer server.Close()
|
|
171
|
+
|
|
172
|
+
var stdout, stderr bytes.Buffer
|
|
173
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
174
|
+
args := []string{"generate-video", "--prompt", "x"}
|
|
175
|
+
for _, path := range mediaPaths("image", ".jpg", 10) {
|
|
176
|
+
args = append(args, "--image", path)
|
|
177
|
+
}
|
|
178
|
+
root.SetArgs(args)
|
|
179
|
+
|
|
180
|
+
err := root.Execute()
|
|
181
|
+
if err == nil {
|
|
182
|
+
t.Fatal("Execute() error = nil, want image count validation")
|
|
183
|
+
}
|
|
184
|
+
if !strings.Contains(err.Error(), "参考图片最多支持 9 个,当前传入 10 个") {
|
|
185
|
+
t.Fatalf("error = %q, want image count validation", err)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func TestGenerateVideoRejectsTooManyVideos(t *testing.T) {
|
|
190
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
191
|
+
t.Fatal("server should not receive request when video count is invalid")
|
|
192
|
+
}))
|
|
193
|
+
defer server.Close()
|
|
194
|
+
|
|
195
|
+
var stdout, stderr bytes.Buffer
|
|
196
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
197
|
+
args := []string{"generate-video", "--prompt", "x"}
|
|
198
|
+
for _, path := range mediaPaths("video", ".mp4", 4) {
|
|
199
|
+
args = append(args, "--video", path)
|
|
200
|
+
}
|
|
201
|
+
root.SetArgs(args)
|
|
202
|
+
|
|
203
|
+
err := root.Execute()
|
|
204
|
+
if err == nil {
|
|
205
|
+
t.Fatal("Execute() error = nil, want video count validation")
|
|
206
|
+
}
|
|
207
|
+
if !strings.Contains(err.Error(), "参考视频最多支持 3 个,当前传入 4 个") {
|
|
208
|
+
t.Fatalf("error = %q, want video count validation", err)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func TestGenerateVideoSubmitRunErrorIncludesLogID(t *testing.T) {
|
|
213
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
214
|
+
switch r.URL.Path {
|
|
215
|
+
case "/api/biz/v1/skill/submit_run":
|
|
216
|
+
_, _ = w.Write([]byte(`{"ret":"16008","errmsg":"提交Run任务失败","log_id":"log_123"}`))
|
|
217
|
+
default:
|
|
218
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
219
|
+
}
|
|
220
|
+
}))
|
|
221
|
+
defer server.Close()
|
|
222
|
+
|
|
223
|
+
var stdout, stderr bytes.Buffer
|
|
224
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
225
|
+
root.SetArgs([]string{
|
|
226
|
+
"generate-video",
|
|
227
|
+
"--prompt", "x",
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
err := root.Execute()
|
|
231
|
+
if err == nil {
|
|
232
|
+
t.Fatal("Execute() error = nil, want submit_run error")
|
|
233
|
+
}
|
|
234
|
+
if !strings.Contains(err.Error(), "log_id=log_123") {
|
|
235
|
+
t.Fatalf("error = %q, want log_id", err)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
func TestGenerateVideoErrorLogIncludesLogID(t *testing.T) {
|
|
240
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
241
|
+
switch r.URL.Path {
|
|
242
|
+
case "/api/biz/v1/skill/submit_run":
|
|
243
|
+
_, _ = w.Write([]byte(`{"ret":"16008","errmsg":"提交Run任务失败","log_id":"log_123"}`))
|
|
244
|
+
default:
|
|
245
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
246
|
+
}
|
|
247
|
+
}))
|
|
248
|
+
defer server.Close()
|
|
249
|
+
|
|
250
|
+
clearDailyErrorLog(t)
|
|
251
|
+
var stdout, stderr bytes.Buffer
|
|
252
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
253
|
+
root.SetArgs([]string{
|
|
254
|
+
"generate-video",
|
|
255
|
+
"--prompt", "x",
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
err := root.Execute()
|
|
259
|
+
if err == nil {
|
|
260
|
+
t.Fatal("Execute() error = nil, want submit_run error")
|
|
261
|
+
}
|
|
262
|
+
entries := readDailyErrorLog(t)
|
|
263
|
+
if len(entries) != 1 {
|
|
264
|
+
t.Fatalf("log entries = %d, want 1: %#v", len(entries), entries)
|
|
265
|
+
}
|
|
266
|
+
fields, ok := entries[0]["fields"].(map[string]any)
|
|
267
|
+
if !ok {
|
|
268
|
+
t.Fatalf("fields = %#v, want object", entries[0]["fields"])
|
|
269
|
+
}
|
|
270
|
+
if fields["log_id"] != "log_123" {
|
|
271
|
+
t.Fatalf("log_id = %v, want log_123", fields["log_id"])
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
func TestQueryResultDownloadsCompletedVideo(t *testing.T) {
|
|
276
|
+
var requestedDownload bool
|
|
277
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
278
|
+
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
279
|
+
t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
|
|
280
|
+
}
|
|
281
|
+
switch r.URL.Path {
|
|
282
|
+
case "/api/biz/v1/skill/get_thread":
|
|
283
|
+
if r.Method != http.MethodPost {
|
|
284
|
+
t.Fatalf("get_thread method = %s, want POST", r.Method)
|
|
285
|
+
}
|
|
286
|
+
data, err := io.ReadAll(r.Body)
|
|
287
|
+
if err != nil {
|
|
288
|
+
t.Fatalf("read body: %v", err)
|
|
289
|
+
}
|
|
290
|
+
var body map[string]any
|
|
291
|
+
if err := sonic.Unmarshal(data, &body); err != nil {
|
|
292
|
+
t.Fatalf("decode body: %v", err)
|
|
293
|
+
}
|
|
294
|
+
if body["thread_id"] != "thread_123" {
|
|
295
|
+
t.Fatalf("thread_id = %v, want thread_123", body["thread_id"])
|
|
296
|
+
}
|
|
297
|
+
if body["run_id"] != "run_456" {
|
|
298
|
+
t.Fatalf("run_id = %v, want run_456", body["run_id"])
|
|
299
|
+
}
|
|
300
|
+
if _, ok := body["version"]; ok {
|
|
301
|
+
t.Fatalf("version = %v, want omitted", body["version"])
|
|
302
|
+
}
|
|
303
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":3,"entry_list":[{"artifact":{"content":[{"sub_type":"biz/x_data_prompt_text","data":"做个小猫视频"},{"sub_type":"biz/x_data_video","data":"{\"video\":{\"download_url\":\"` + serverURL(r) + `/video.mp4\",\"title\":\"cat_video\",\"vid\":\"cat_vid\"}}"}]}}]}]}}}`))
|
|
304
|
+
case "/video.mp4":
|
|
305
|
+
requestedDownload = true
|
|
306
|
+
if r.Method != http.MethodGet {
|
|
307
|
+
t.Fatalf("download method = %s, want GET", r.Method)
|
|
308
|
+
}
|
|
309
|
+
_, _ = w.Write([]byte("video-data"))
|
|
310
|
+
default:
|
|
311
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
312
|
+
}
|
|
313
|
+
}))
|
|
314
|
+
defer server.Close()
|
|
315
|
+
|
|
316
|
+
downloadDir := filepath.Join(t.TempDir(), "downloads")
|
|
317
|
+
var stdout, stderr bytes.Buffer
|
|
318
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
319
|
+
root.SetArgs([]string{
|
|
320
|
+
"query-result",
|
|
321
|
+
"--thread-id", "thread_123",
|
|
322
|
+
"--run-id", "run_456",
|
|
323
|
+
"--download-dir", downloadDir,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
if err := root.Execute(); err != nil {
|
|
327
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
outputPath := filepath.Join(downloadDir, "cat_vid.mp4")
|
|
331
|
+
downloadURL := server.URL + "/video.mp4"
|
|
332
|
+
if !requestedDownload {
|
|
333
|
+
t.Fatal("download endpoint was not requested")
|
|
334
|
+
}
|
|
335
|
+
if got := stdout.String(); !strings.Contains(got, "Run 已完成,产物已下载:") ||
|
|
336
|
+
!strings.Contains(got, outputPath) ||
|
|
337
|
+
!strings.Contains(got, "download_url: "+downloadURL) {
|
|
338
|
+
t.Fatalf("stdout = %q, want download path and download url", got)
|
|
339
|
+
}
|
|
340
|
+
assertFileContent(t, outputPath, "video-data")
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
func TestQueryResultIgnoresVideoDataWithoutVideoSubType(t *testing.T) {
|
|
344
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
345
|
+
switch r.URL.Path {
|
|
346
|
+
case "/api/biz/v1/skill/get_thread":
|
|
347
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":3,"entry_list":[{"artifact":{"content":[{"data":"{\"video\":{\"download_url\":\"` + serverURL(r) + `/video.mp4\",\"title\":\"cat_video\"}}"}]}}]}]}}}`))
|
|
348
|
+
default:
|
|
349
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
350
|
+
}
|
|
351
|
+
}))
|
|
352
|
+
defer server.Close()
|
|
353
|
+
|
|
354
|
+
var stdout, stderr bytes.Buffer
|
|
355
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
356
|
+
root.SetArgs([]string{
|
|
357
|
+
"query-result",
|
|
358
|
+
"--thread-id", "thread_123",
|
|
359
|
+
"--run-id", "run_456",
|
|
360
|
+
"--download-dir", t.TempDir(),
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
err := root.Execute()
|
|
364
|
+
if err == nil {
|
|
365
|
+
t.Fatal("Execute() error = nil, want no downloadable video error")
|
|
366
|
+
}
|
|
367
|
+
if !strings.Contains(err.Error(), "下载失败:未找到可下载的视频产物") {
|
|
368
|
+
t.Fatalf("error = %q, want no downloadable video error", err)
|
|
369
|
+
}
|
|
370
|
+
if stdout.Len() != 0 {
|
|
371
|
+
t.Fatalf("stdout = %q, want empty", stdout.String())
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
func TestQueryResultErrorLogIncludesLogID(t *testing.T) {
|
|
376
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
377
|
+
switch r.URL.Path {
|
|
378
|
+
case "/api/biz/v1/skill/get_thread":
|
|
379
|
+
_, _ = w.Write([]byte(`{"ret":"5","errmsg":"创作失败","log_id":"log_456"}`))
|
|
380
|
+
default:
|
|
381
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
382
|
+
}
|
|
383
|
+
}))
|
|
384
|
+
defer server.Close()
|
|
385
|
+
|
|
386
|
+
clearDailyErrorLog(t)
|
|
387
|
+
var stdout, stderr bytes.Buffer
|
|
388
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
389
|
+
root.SetArgs([]string{
|
|
390
|
+
"query-result",
|
|
391
|
+
"--thread-id", "thread_123",
|
|
392
|
+
"--run-id", "run_456",
|
|
393
|
+
"--download-dir", t.TempDir(),
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
err := root.Execute()
|
|
397
|
+
if err == nil {
|
|
398
|
+
t.Fatal("Execute() error = nil, want get_thread error")
|
|
399
|
+
}
|
|
400
|
+
entries := readDailyErrorLog(t)
|
|
401
|
+
if len(entries) != 1 {
|
|
402
|
+
t.Fatalf("log entries = %d, want 1: %#v", len(entries), entries)
|
|
403
|
+
}
|
|
404
|
+
fields, ok := entries[0]["fields"].(map[string]any)
|
|
405
|
+
if !ok {
|
|
406
|
+
t.Fatalf("fields = %#v, want object", entries[0]["fields"])
|
|
407
|
+
}
|
|
408
|
+
if fields["log_id"] != "log_456" {
|
|
409
|
+
t.Fatalf("log_id = %v, want log_456", fields["log_id"])
|
|
410
|
+
}
|
|
411
|
+
if fields["thread_id"] != "thread_123" || fields["run_id"] != "run_456" {
|
|
412
|
+
t.Fatalf("fields = %#v, want thread/run ids", fields)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
func TestQueryResultPendingDoesNotDownload(t *testing.T) {
|
|
417
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
418
|
+
switch r.URL.Path {
|
|
419
|
+
case "/api/biz/v1/skill/get_thread":
|
|
420
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":1}]}}}`))
|
|
421
|
+
default:
|
|
422
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
423
|
+
}
|
|
424
|
+
}))
|
|
425
|
+
defer server.Close()
|
|
426
|
+
|
|
427
|
+
var stdout, stderr bytes.Buffer
|
|
428
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
429
|
+
root.SetArgs([]string{
|
|
430
|
+
"query-result",
|
|
431
|
+
"--thread-id", "thread_123",
|
|
432
|
+
"--run-id", "run_456",
|
|
433
|
+
"--download-dir", t.TempDir(),
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
if err := root.Execute(); err != nil {
|
|
437
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
438
|
+
}
|
|
439
|
+
if got := stdout.String(); !strings.Contains(got, "Run 尚未完成,当前状态:1") {
|
|
440
|
+
t.Fatalf("stdout = %q, want pending state", got)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
func serverURL(r *http.Request) string {
|
|
445
|
+
return "http://" + r.Host
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
func assertAssetRefs(t *testing.T, got any, want []string) {
|
|
449
|
+
t.Helper()
|
|
450
|
+
items, ok := got.([]any)
|
|
451
|
+
if !ok || len(items) != len(want) {
|
|
452
|
+
t.Fatalf("asset refs = %#v, want %v", got, want)
|
|
453
|
+
}
|
|
454
|
+
for i, item := range items {
|
|
455
|
+
ref, ok := item.(map[string]any)
|
|
456
|
+
if !ok || ref["pippit_asset_id"] != want[i] {
|
|
457
|
+
t.Fatalf("asset refs[%d] = %#v, want %s", i, item, want[i])
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
func mediaPaths(prefix string, ext string, count int) []string {
|
|
463
|
+
paths := make([]string, 0, count)
|
|
464
|
+
for i := 0; i < count; i++ {
|
|
465
|
+
paths = append(paths, prefix+string(rune('a'+i))+ext)
|
|
466
|
+
}
|
|
467
|
+
return paths
|
|
468
|
+
}
|
package/cmd/get_thread.go
CHANGED
|
@@ -27,6 +27,7 @@ func newGetThreadCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra
|
|
|
27
27
|
return fmt.Errorf("--thread-id is required")
|
|
28
28
|
}
|
|
29
29
|
opts.RunID = strings.TrimSpace(opts.RunID)
|
|
30
|
+
opts.Version = common.GetThreadVersionV2
|
|
30
31
|
|
|
31
32
|
result, err := common.GetThread(cmd.Context(), &opts, runner)
|
|
32
33
|
if err != nil {
|
package/cmd/list_thread_file.go
CHANGED