@pippit-dev/cli 1.0.0 → 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 +37 -5
- package/checksums.txt +6 -6
- package/cmd/download_result.go +57 -0
- 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 +59 -0
- package/cmd/list_thread_file.go +51 -0
- package/cmd/root.go +30 -1
- package/cmd/short_drama/short_drama.go +12 -133
- package/cmd/short_drama_test.go +76 -49
- package/cmd/update/update.go +8 -8
- package/internal/common/client.go +29 -21
- package/internal/common/download_results.go +21 -15
- package/internal/common/error_log.go +62 -1
- package/internal/common/error_log_test.go +33 -3
- package/internal/common/get_thread.go +29 -15
- package/internal/common/get_thread_test.go +74 -0
- package/internal/common/json.go +18 -0
- package/internal/common/list_thread_file.go +7 -7
- package/internal/common/submit_run.go +29 -0
- package/internal/common/upload_file.go +22 -18
- 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 +34 -34
- 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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import "github.com/Pippit-dev/pippit-cli/internal/config"
|
|
4
|
+
|
|
5
|
+
// SubmitRunResponse is the shared response envelope returned by submit_run.
|
|
6
|
+
type SubmitRunResponse struct {
|
|
7
|
+
Ret string `json:"ret"`
|
|
8
|
+
Errmsg string `json:"errmsg"`
|
|
9
|
+
LogID string `json:"log_id"`
|
|
10
|
+
Data SubmitRunResponseData `json:"data"`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type SubmitRunResponseData struct {
|
|
14
|
+
WebThreadLink string `json:"web_thread_link"`
|
|
15
|
+
Run SubmitRunResponseRun `json:"run"`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type SubmitRunResponseRun struct {
|
|
19
|
+
ThreadID string `json:"thread_id"`
|
|
20
|
+
RunID string `json:"run_id"`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// SubmitRunPath returns the configured submit_run endpoint path.
|
|
24
|
+
func SubmitRunPath(runner *Runner) string {
|
|
25
|
+
if runner != nil && runner.Config != nil && runner.Config.Paths != nil && runner.Config.Paths.SubmitRun != "" {
|
|
26
|
+
return runner.Config.Paths.SubmitRun
|
|
27
|
+
}
|
|
28
|
+
return config.SubmitRunPath
|
|
29
|
+
}
|
|
@@ -2,6 +2,7 @@ package common
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"errors"
|
|
5
6
|
"fmt"
|
|
6
7
|
"mime"
|
|
7
8
|
"os"
|
|
@@ -35,36 +36,39 @@ type uploadFileResponse struct {
|
|
|
35
36
|
|
|
36
37
|
const uploadFileFieldName = "file"
|
|
37
38
|
|
|
38
|
-
var allowedUploadExtensions = map[string]bool{
|
|
39
|
-
".doc": true,
|
|
40
|
-
".docx": true,
|
|
41
|
-
".txt": true,
|
|
42
|
-
}
|
|
43
|
-
|
|
44
39
|
func UploadFile(ctx context.Context, opts UploadFileOptions, runner *Runner) (*UploadFileResult, error) {
|
|
45
40
|
if err := ctx.Err(); err != nil {
|
|
46
|
-
|
|
41
|
+
if errors.Is(err, context.Canceled) {
|
|
42
|
+
return nil, fmt.Errorf("上传文件已取消")
|
|
43
|
+
}
|
|
44
|
+
if errors.Is(err, context.DeadlineExceeded) {
|
|
45
|
+
return nil, fmt.Errorf("上传文件超时")
|
|
46
|
+
}
|
|
47
|
+
return nil, fmt.Errorf("上传文件上下文异常: %w", err)
|
|
47
48
|
}
|
|
48
49
|
if runner == nil || runner.Client == nil {
|
|
49
|
-
return nil, fmt.Errorf("
|
|
50
|
+
return nil, fmt.Errorf("上传文件运行器客户端缺失")
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
path := strings.TrimSpace(opts.Path)
|
|
53
54
|
if path == "" {
|
|
54
|
-
return nil, fmt.Errorf("
|
|
55
|
+
return nil, fmt.Errorf("上传文件路径不能为空")
|
|
55
56
|
}
|
|
56
57
|
info, err := os.Stat(path)
|
|
57
58
|
if err != nil {
|
|
58
|
-
|
|
59
|
+
if os.IsNotExist(err) {
|
|
60
|
+
return nil, fmt.Errorf("上传文件不存在: %s", path)
|
|
61
|
+
}
|
|
62
|
+
if os.IsPermission(err) {
|
|
63
|
+
return nil, fmt.Errorf("没有权限读取上传文件: %s", path)
|
|
64
|
+
}
|
|
65
|
+
return nil, fmt.Errorf("获取上传文件信息失败: %w", err)
|
|
59
66
|
}
|
|
60
67
|
if info.IsDir() {
|
|
61
|
-
return nil, fmt.Errorf("
|
|
68
|
+
return nil, fmt.Errorf("上传路径 %q 是目录,请指定文件", path)
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
ext := strings.ToLower(filepath.Ext(path))
|
|
65
|
-
if !allowedUploadExtensions[ext] {
|
|
66
|
-
return nil, fmt.Errorf("unsupported file extension %q; only .doc, .docx, and .txt uploads are supported", ext)
|
|
67
|
-
}
|
|
68
72
|
fileName := filepath.Base(path)
|
|
69
73
|
contentType := mime.TypeByExtension(ext)
|
|
70
74
|
|
|
@@ -75,13 +79,13 @@ func UploadFile(ctx context.Context, opts UploadFileOptions, runner *Runner) (*U
|
|
|
75
79
|
FileName: fileName,
|
|
76
80
|
ContentType: contentType,
|
|
77
81
|
}, &resp); err != nil {
|
|
78
|
-
return nil, fmt.Errorf("
|
|
82
|
+
return nil, fmt.Errorf("上传文件请求失败: %w", err)
|
|
79
83
|
}
|
|
80
84
|
if resp.Ret != "0" {
|
|
81
85
|
if resp.Errmsg == "" {
|
|
82
|
-
resp.Errmsg = "
|
|
86
|
+
resp.Errmsg = "未知错误"
|
|
83
87
|
}
|
|
84
|
-
return nil, fmt.Errorf("
|
|
88
|
+
return nil, fmt.Errorf("上传文件请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
assetID := strings.TrimSpace(resp.Data.PippitAssetID)
|
|
@@ -89,7 +93,7 @@ func UploadFile(ctx context.Context, opts UploadFileOptions, runner *Runner) (*U
|
|
|
89
93
|
assetID = strings.TrimSpace(resp.Data.AssetID)
|
|
90
94
|
}
|
|
91
95
|
if assetID == "" {
|
|
92
|
-
return nil, fmt.Errorf("
|
|
96
|
+
return nil, fmt.Errorf("上传文件响应缺少 pippit_asset_id")
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
return &UploadFileResult{
|
|
@@ -8,7 +8,7 @@ import (
|
|
|
8
8
|
|
|
9
9
|
const (
|
|
10
10
|
DefaultBaseURL = "https://xyq.jianying.com"
|
|
11
|
-
DefaultHTTPTimeout = 30 * time.
|
|
11
|
+
DefaultHTTPTimeout = 30 * time.Minute
|
|
12
12
|
DefaultAuthTTL = 30 * time.Second
|
|
13
13
|
DefaultOAuthClientKey = "mock-cli"
|
|
14
14
|
DefaultOAuthBaseURL = "https://passport.bytedance.com"
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
package generate_video
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const (
|
|
14
|
+
agentNameVideoPart = "pippit_video_part_agent"
|
|
15
|
+
maxReferenceImages = 9
|
|
16
|
+
maxReferenceVideos = 3
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
var (
|
|
20
|
+
allowedImageExtensionList = []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"}
|
|
21
|
+
allowedVideoExtensionList = []string{".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mkv", ".m4v"}
|
|
22
|
+
allowedImageExtensions = makeExtensionSet(allowedImageExtensionList)
|
|
23
|
+
allowedVideoExtensions = makeExtensionSet(allowedVideoExtensionList)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// Options is the stable command-facing request shape for generate-video.
|
|
27
|
+
type Options struct {
|
|
28
|
+
Prompt string
|
|
29
|
+
ImagePaths []string
|
|
30
|
+
VideoPaths []string
|
|
31
|
+
DurationSec *int
|
|
32
|
+
Ratio string
|
|
33
|
+
Model string
|
|
34
|
+
Resolution string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type mediaAsset struct {
|
|
38
|
+
PippitAssetID string `json:"pippit_asset_id"`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type videoPartToolParam struct {
|
|
42
|
+
Images []mediaAsset `json:"images,omitempty"`
|
|
43
|
+
Prompt string `json:"prompt"`
|
|
44
|
+
DurationSec *int `json:"duration_sec,omitempty"`
|
|
45
|
+
Ratio string `json:"ratio,omitempty"`
|
|
46
|
+
Videos []mediaAsset `json:"videos,omitempty"`
|
|
47
|
+
Model string `json:"model,omitempty"`
|
|
48
|
+
Resolution string `json:"resolution,omitempty"`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Result is the JSON envelope printed by `pippit-tool-cli generate-video`.
|
|
52
|
+
type Result struct {
|
|
53
|
+
ThreadID string `json:"thread_id"`
|
|
54
|
+
RunID string `json:"run_id"`
|
|
55
|
+
WebThreadLink string `json:"web_thread_link"`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, error) {
|
|
59
|
+
if runner == nil || runner.Client == nil {
|
|
60
|
+
return nil, fmt.Errorf("generate-video 运行器客户端缺失")
|
|
61
|
+
}
|
|
62
|
+
if err := ValidateOptions(opts); err != nil {
|
|
63
|
+
return nil, err
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
imageAssetIDs, err := uploadMediaList(ctx, opts.ImagePaths, runner)
|
|
67
|
+
if err != nil {
|
|
68
|
+
return nil, fmt.Errorf("上传图片失败: %w", err)
|
|
69
|
+
}
|
|
70
|
+
videoAssetIDs, err := uploadMediaList(ctx, opts.VideoPaths, runner)
|
|
71
|
+
if err != nil {
|
|
72
|
+
return nil, fmt.Errorf("上传视频失败: %w", err)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
body := buildSubmitRunBody(opts, imageAssetIDs, videoAssetIDs)
|
|
76
|
+
|
|
77
|
+
var resp common.SubmitRunResponse
|
|
78
|
+
if err := runner.Client.SendRequest(ctx, common.SubmitRunPath(runner), body, &resp); err != nil {
|
|
79
|
+
return nil, fmt.Errorf("提交 generate-video 请求失败: %w", err)
|
|
80
|
+
}
|
|
81
|
+
if resp.Ret != "0" {
|
|
82
|
+
if resp.Errmsg == "" {
|
|
83
|
+
resp.Errmsg = "未知错误"
|
|
84
|
+
}
|
|
85
|
+
return nil, common.NewLogIDError(fmt.Sprintf("generate-video 请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
|
|
86
|
+
}
|
|
87
|
+
if resp.Data.Run.ThreadID == "" {
|
|
88
|
+
return nil, fmt.Errorf("generate-video 响应缺少 data.run.thread_id")
|
|
89
|
+
}
|
|
90
|
+
if resp.Data.Run.RunID == "" {
|
|
91
|
+
return nil, fmt.Errorf("generate-video 响应缺少 data.run.run_id")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return &Result{
|
|
95
|
+
ThreadID: resp.Data.Run.ThreadID,
|
|
96
|
+
RunID: resp.Data.Run.RunID,
|
|
97
|
+
WebThreadLink: resp.Data.WebThreadLink,
|
|
98
|
+
}, nil
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func ValidateOptions(opts *Options) error {
|
|
102
|
+
if opts == nil {
|
|
103
|
+
return fmt.Errorf("缺少必填参数 --prompt")
|
|
104
|
+
}
|
|
105
|
+
if strings.TrimSpace(opts.Prompt) == "" {
|
|
106
|
+
return fmt.Errorf("缺少必填参数 --prompt")
|
|
107
|
+
}
|
|
108
|
+
if len(opts.ImagePaths) > maxReferenceImages {
|
|
109
|
+
return fmt.Errorf("参考图片最多支持 %d 个,当前传入 %d 个", maxReferenceImages, len(opts.ImagePaths))
|
|
110
|
+
}
|
|
111
|
+
if len(opts.VideoPaths) > maxReferenceVideos {
|
|
112
|
+
return fmt.Errorf("参考视频最多支持 %d 个,当前传入 %d 个", maxReferenceVideos, len(opts.VideoPaths))
|
|
113
|
+
}
|
|
114
|
+
if err := validateMediaExtensions("图片", opts.ImagePaths, allowedImageExtensions, allowedImageExtensionList); err != nil {
|
|
115
|
+
return err
|
|
116
|
+
}
|
|
117
|
+
if err := validateMediaExtensions("视频", opts.VideoPaths, allowedVideoExtensions, allowedVideoExtensionList); err != nil {
|
|
118
|
+
return err
|
|
119
|
+
}
|
|
120
|
+
return nil
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func validateMediaExtensions(kind string, paths []string, allowed map[string]struct{}, allowedList []string) error {
|
|
124
|
+
for _, path := range paths {
|
|
125
|
+
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(path)))
|
|
126
|
+
if _, ok := allowed[ext]; !ok {
|
|
127
|
+
return fmt.Errorf("不支持的%s文件后缀 %q,文件:%q;支持的后缀:%s", kind, ext, path, strings.Join(allowedList, ", "))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return nil
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func makeExtensionSet(list []string) map[string]struct{} {
|
|
134
|
+
set := make(map[string]struct{}, len(list))
|
|
135
|
+
for _, ext := range list {
|
|
136
|
+
set[ext] = struct{}{}
|
|
137
|
+
}
|
|
138
|
+
return set
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func uploadMediaList(ctx context.Context, paths []string, runner *common.Runner) ([]string, error) {
|
|
142
|
+
assetIDs := make([]string, 0, len(paths))
|
|
143
|
+
for _, path := range paths {
|
|
144
|
+
expanded, err := expandPath(path)
|
|
145
|
+
if err != nil {
|
|
146
|
+
return nil, err
|
|
147
|
+
}
|
|
148
|
+
result, err := common.UploadFile(ctx, common.UploadFileOptions{Path: expanded}, runner)
|
|
149
|
+
if err != nil {
|
|
150
|
+
return nil, err
|
|
151
|
+
}
|
|
152
|
+
assetIDs = append(assetIDs, result.AssetID)
|
|
153
|
+
}
|
|
154
|
+
return assetIDs, nil
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func buildSubmitRunBody(opts *Options, imageAssetIDs []string, videoAssetIDs []string) map[string]any {
|
|
158
|
+
param := videoPartToolParam{
|
|
159
|
+
Images: assetRefs(imageAssetIDs),
|
|
160
|
+
Prompt: strings.TrimSpace(opts.Prompt),
|
|
161
|
+
DurationSec: opts.DurationSec,
|
|
162
|
+
Ratio: strings.TrimSpace(opts.Ratio),
|
|
163
|
+
Videos: assetRefs(videoAssetIDs),
|
|
164
|
+
Model: strings.TrimSpace(opts.Model),
|
|
165
|
+
Resolution: strings.TrimSpace(opts.Resolution),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return map[string]any{
|
|
169
|
+
"agent_name": agentNameVideoPart,
|
|
170
|
+
"message": strings.TrimSpace(opts.Prompt),
|
|
171
|
+
"video_part_tool_param": param,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func assetRefs(assetIDs []string) []mediaAsset {
|
|
176
|
+
if len(assetIDs) == 0 {
|
|
177
|
+
return nil
|
|
178
|
+
}
|
|
179
|
+
refs := make([]mediaAsset, 0, len(assetIDs))
|
|
180
|
+
for _, assetID := range assetIDs {
|
|
181
|
+
refs = append(refs, mediaAsset{PippitAssetID: assetID})
|
|
182
|
+
}
|
|
183
|
+
return refs
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func expandPath(path string) (string, error) {
|
|
187
|
+
path = strings.TrimSpace(path)
|
|
188
|
+
if path == "~" {
|
|
189
|
+
return os.UserHomeDir()
|
|
190
|
+
}
|
|
191
|
+
if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
|
|
192
|
+
home, err := os.UserHomeDir()
|
|
193
|
+
if err != nil {
|
|
194
|
+
return "", fmt.Errorf("解析用户主目录失败: %w", err)
|
|
195
|
+
}
|
|
196
|
+
return filepath.Join(home, path[2:]), nil
|
|
197
|
+
}
|
|
198
|
+
return path, nil
|
|
199
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
package generate_video
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strconv"
|
|
9
|
+
"strings"
|
|
10
|
+
"unicode"
|
|
11
|
+
|
|
12
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const completedRunState = 3
|
|
16
|
+
|
|
17
|
+
// QueryResultOptions is the command-facing request shape for query-result.
|
|
18
|
+
type QueryResultOptions struct {
|
|
19
|
+
ThreadID string
|
|
20
|
+
RunID string
|
|
21
|
+
DownloadDir string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// QueryResultResult describes the user-facing query-result outcome.
|
|
25
|
+
type QueryResultResult struct {
|
|
26
|
+
Completed bool
|
|
27
|
+
State int
|
|
28
|
+
OutputPaths []string
|
|
29
|
+
DownloadURLs []string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type queryThread struct {
|
|
33
|
+
ThreadID string `json:"thread_id"`
|
|
34
|
+
RunList []queryRun `json:"run_list"`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type queryRun struct {
|
|
38
|
+
RunID string `json:"run_id"`
|
|
39
|
+
State int `json:"state"`
|
|
40
|
+
EntryList []queryEntry `json:"entry_list"`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type queryEntry struct {
|
|
44
|
+
Artifact queryArtifact `json:"artifact"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type queryArtifact struct {
|
|
48
|
+
Content []queryContent `json:"content"`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type queryContent struct {
|
|
52
|
+
SubType string `json:"sub_type"`
|
|
53
|
+
Data queryContentData `json:"data"`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type queryContentData struct {
|
|
57
|
+
Video *queryVideo `json:"video"`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type queryVideo struct {
|
|
61
|
+
DownloadURL string `json:"download_url"`
|
|
62
|
+
Title string `json:"title"`
|
|
63
|
+
VID string `json:"vid"`
|
|
64
|
+
AssetID string `json:"asset_id"`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.Runner) (*QueryResultResult, error) {
|
|
68
|
+
if err := validateQueryResultOptions(opts); err != nil {
|
|
69
|
+
return nil, err
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
threadResult, err := common.GetThread(ctx, &common.GetThreadOptions{
|
|
73
|
+
ThreadID: opts.ThreadID,
|
|
74
|
+
RunID: opts.RunID,
|
|
75
|
+
}, runner)
|
|
76
|
+
if err != nil {
|
|
77
|
+
return nil, fmt.Errorf("查询失败:%w", err)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
thread, err := parseQueryThread(threadResult)
|
|
81
|
+
if err != nil {
|
|
82
|
+
return nil, fmt.Errorf("查询失败:%w", err)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
run, ok := findQueryRun(thread, opts.RunID)
|
|
86
|
+
if !ok {
|
|
87
|
+
return nil, fmt.Errorf("查询失败:未找到 run_id=%s 对应的 Run", opts.RunID)
|
|
88
|
+
}
|
|
89
|
+
if run.State != completedRunState {
|
|
90
|
+
return &QueryResultResult{
|
|
91
|
+
Completed: false,
|
|
92
|
+
State: run.State,
|
|
93
|
+
}, nil
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
videos := extractQueryVideos(run)
|
|
97
|
+
if len(videos) == 0 {
|
|
98
|
+
return nil, fmt.Errorf("下载失败:未找到可下载的视频产物")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
downloadDir, err := expandPath(opts.DownloadDir)
|
|
102
|
+
if err != nil {
|
|
103
|
+
return nil, fmt.Errorf("下载失败:解析下载目录失败:%w", err)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
outputPaths := make([]string, 0, len(videos))
|
|
107
|
+
downloadURLs := make([]string, 0, len(videos))
|
|
108
|
+
usedNames := make(map[string]int, len(videos))
|
|
109
|
+
for i, video := range videos {
|
|
110
|
+
if strings.TrimSpace(video.DownloadURL) == "" {
|
|
111
|
+
return nil, fmt.Errorf("下载失败:第 %d 个视频产物 download_url 为空", i+1)
|
|
112
|
+
}
|
|
113
|
+
downloadURLs = append(downloadURLs, video.DownloadURL)
|
|
114
|
+
outputPath := filepath.Join(downloadDir, uniqueQueryResultFileName(videoFileName(video, i+1), usedNames))
|
|
115
|
+
download, err := common.DownloadResult(ctx, common.DownloadResultOptions{
|
|
116
|
+
URL: video.DownloadURL,
|
|
117
|
+
OutputPath: outputPath,
|
|
118
|
+
Workers: 5,
|
|
119
|
+
}, runner)
|
|
120
|
+
if err != nil {
|
|
121
|
+
return nil, fmt.Errorf("下载失败:%w", err)
|
|
122
|
+
}
|
|
123
|
+
if len(download.Downloaded) > 0 {
|
|
124
|
+
outputPaths = append(outputPaths, download.Downloaded...)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if len(download.AlreadyExist) > 0 {
|
|
128
|
+
outputPaths = append(outputPaths, download.AlreadyExist...)
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
outputPaths = append(outputPaths, outputPath)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return &QueryResultResult{
|
|
135
|
+
Completed: true,
|
|
136
|
+
State: run.State,
|
|
137
|
+
OutputPaths: outputPaths,
|
|
138
|
+
DownloadURLs: downloadURLs,
|
|
139
|
+
}, nil
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func validateQueryResultOptions(opts *QueryResultOptions) error {
|
|
143
|
+
if opts == nil {
|
|
144
|
+
return fmt.Errorf("查询失败:缺少必填参数 --thread-id")
|
|
145
|
+
}
|
|
146
|
+
opts.ThreadID = strings.TrimSpace(opts.ThreadID)
|
|
147
|
+
opts.RunID = strings.TrimSpace(opts.RunID)
|
|
148
|
+
opts.DownloadDir = strings.TrimSpace(opts.DownloadDir)
|
|
149
|
+
if opts.ThreadID == "" {
|
|
150
|
+
return fmt.Errorf("查询失败:缺少必填参数 --thread-id")
|
|
151
|
+
}
|
|
152
|
+
if opts.RunID == "" {
|
|
153
|
+
return fmt.Errorf("查询失败:缺少必填参数 --run-id")
|
|
154
|
+
}
|
|
155
|
+
if opts.DownloadDir == "" {
|
|
156
|
+
return fmt.Errorf("查询失败:缺少必填参数 --download-dir")
|
|
157
|
+
}
|
|
158
|
+
return nil
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func parseQueryThread(result *common.GetThreadResult) (*queryThread, error) {
|
|
162
|
+
if result == nil {
|
|
163
|
+
return nil, fmt.Errorf("get_thread 响应为空")
|
|
164
|
+
}
|
|
165
|
+
if len(result.RawData) > 0 {
|
|
166
|
+
var data map[string]json.RawMessage
|
|
167
|
+
if err := json.Unmarshal(result.RawData, &data); err == nil {
|
|
168
|
+
if raw := data["thread"]; len(raw) > 0 {
|
|
169
|
+
if thread, ok := decodeQueryThread(raw); ok {
|
|
170
|
+
return thread, nil
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return nil, fmt.Errorf("get_thread 响应中未找到 data.thread")
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func decodeQueryThread(raw []byte) (*queryThread, bool) {
|
|
179
|
+
var thread queryThread
|
|
180
|
+
if err := json.Unmarshal(raw, &thread); err != nil {
|
|
181
|
+
return nil, false
|
|
182
|
+
}
|
|
183
|
+
if thread.ThreadID == "" && len(thread.RunList) == 0 {
|
|
184
|
+
return nil, false
|
|
185
|
+
}
|
|
186
|
+
return &thread, true
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func findQueryRun(thread *queryThread, runID string) (queryRun, bool) {
|
|
190
|
+
for _, run := range thread.RunList {
|
|
191
|
+
if run.RunID == runID {
|
|
192
|
+
return run, true
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return queryRun{}, false
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func (data *queryContentData) UnmarshalJSON(raw []byte) error {
|
|
199
|
+
raw = []byte(strings.TrimSpace(string(raw)))
|
|
200
|
+
if len(raw) == 0 || string(raw) == "null" {
|
|
201
|
+
return nil
|
|
202
|
+
}
|
|
203
|
+
if raw[0] == '"' {
|
|
204
|
+
var encoded string
|
|
205
|
+
if err := json.Unmarshal(raw, &encoded); err != nil {
|
|
206
|
+
return err
|
|
207
|
+
}
|
|
208
|
+
encoded = strings.TrimSpace(encoded)
|
|
209
|
+
if encoded == "" {
|
|
210
|
+
return nil
|
|
211
|
+
}
|
|
212
|
+
raw = []byte(encoded)
|
|
213
|
+
}
|
|
214
|
+
if len(raw) == 0 || raw[0] != '{' {
|
|
215
|
+
return nil
|
|
216
|
+
}
|
|
217
|
+
type alias queryContentData
|
|
218
|
+
return json.Unmarshal(raw, (*alias)(data))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func extractQueryVideos(run queryRun) []queryVideo {
|
|
222
|
+
videos := make([]queryVideo, 0)
|
|
223
|
+
for _, entry := range run.EntryList {
|
|
224
|
+
artifact := entry.Artifact
|
|
225
|
+
for _, content := range artifact.Content {
|
|
226
|
+
if content.SubType != "biz/x_data_video" {
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
data := content.Data
|
|
230
|
+
if data.Video != nil {
|
|
231
|
+
videos = append(videos, *data.Video)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return videos
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func videoFileName(video queryVideo, index int) string {
|
|
239
|
+
name := firstNonEmpty(video.VID, video.Title, video.AssetID)
|
|
240
|
+
if name == "" {
|
|
241
|
+
name = "result_" + strconv.Itoa(index)
|
|
242
|
+
}
|
|
243
|
+
name = sanitizeFileName(name)
|
|
244
|
+
if !hasVideoExtension(name) {
|
|
245
|
+
name += ".mp4"
|
|
246
|
+
}
|
|
247
|
+
return name
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
func hasVideoExtension(name string) bool {
|
|
251
|
+
switch strings.ToLower(strings.TrimSpace(filepath.Ext(name))) {
|
|
252
|
+
case ".mp4", ".mov", ".m4v", ".webm":
|
|
253
|
+
return true
|
|
254
|
+
default:
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
func firstNonEmpty(values ...string) string {
|
|
260
|
+
for _, value := range values {
|
|
261
|
+
value = strings.TrimSpace(value)
|
|
262
|
+
if value != "" {
|
|
263
|
+
return value
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return ""
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
func sanitizeFileName(name string) string {
|
|
270
|
+
name = strings.TrimSpace(name)
|
|
271
|
+
if name == "" {
|
|
272
|
+
return "result.mp4"
|
|
273
|
+
}
|
|
274
|
+
return strings.Map(func(r rune) rune {
|
|
275
|
+
if unicode.IsControl(r) || r == '/' || r == '\\' || strings.ContainsRune(`<>:"|?*`, r) {
|
|
276
|
+
return '_'
|
|
277
|
+
}
|
|
278
|
+
return r
|
|
279
|
+
}, name)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
func uniqueQueryResultFileName(name string, used map[string]int) string {
|
|
283
|
+
count := used[name] + 1
|
|
284
|
+
used[name] = count
|
|
285
|
+
if count == 1 {
|
|
286
|
+
return name
|
|
287
|
+
}
|
|
288
|
+
ext := filepath.Ext(name)
|
|
289
|
+
base := strings.TrimSuffix(name, ext)
|
|
290
|
+
return fmt.Sprintf("%s-%d%s", base, count, ext)
|
|
291
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package generate_video
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestVideoFileNameUsesVIDBeforeTimestampTitle(t *testing.T) {
|
|
6
|
+
got := videoFileName(queryVideo{
|
|
7
|
+
Title: "v03c76g10004d8jp38iljhtepa11k25g_2026-06-09T120814.516",
|
|
8
|
+
VID: "v03c76g10004d8jp38iljhtepa11k25g",
|
|
9
|
+
}, 1)
|
|
10
|
+
want := "v03c76g10004d8jp38iljhtepa11k25g.mp4"
|
|
11
|
+
if got != want {
|
|
12
|
+
t.Fatalf("videoFileName() = %q, want %q", got, want)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func TestVideoFileNameAddsMP4ForTimestampTitleWithoutVID(t *testing.T) {
|
|
17
|
+
got := videoFileName(queryVideo{
|
|
18
|
+
Title: "v03c76g10004d8jp38iljhtepa11k25g_2026-06-09T120814.516",
|
|
19
|
+
}, 1)
|
|
20
|
+
want := "v03c76g10004d8jp38iljhtepa11k25g_2026-06-09T120814.516.mp4"
|
|
21
|
+
if got != want {
|
|
22
|
+
t.Fatalf("videoFileName() = %q, want %q", got, want)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestVideoFileNameKeepsVideoExtension(t *testing.T) {
|
|
27
|
+
got := videoFileName(queryVideo{Title: "cat_video.mp4"}, 1)
|
|
28
|
+
if got != "cat_video.mp4" {
|
|
29
|
+
t.Fatalf("videoFileName() = %q, want cat_video.mp4", got)
|
|
30
|
+
}
|
|
31
|
+
}
|