@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.
@@ -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
- return nil, err
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("upload_file runner client is required")
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("upload file path is required")
55
+ return nil, fmt.Errorf("上传文件路径不能为空")
55
56
  }
56
57
  info, err := os.Stat(path)
57
58
  if err != nil {
58
- return nil, fmt.Errorf("stat upload file: %w", err)
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("upload path %q is a directory", path)
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("upload_file request failed: %w", err)
82
+ return nil, fmt.Errorf("上传文件请求失败: %w", err)
79
83
  }
80
84
  if resp.Ret != "0" {
81
85
  if resp.Errmsg == "" {
82
- resp.Errmsg = "unknown error"
86
+ resp.Errmsg = "未知错误"
83
87
  }
84
- return nil, fmt.Errorf("upload_file failed: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
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("upload_file response missing pippit_asset_id")
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.Second
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
+ }