@pippit-dev/cli 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +33 -3
  2. package/checksums.txt +6 -6
  3. package/cmd/download_result.go +1 -1
  4. package/cmd/generate_video/generate_video.go +48 -0
  5. package/cmd/generate_video/query_result.go +42 -0
  6. package/cmd/generate_video_test.go +542 -0
  7. package/cmd/get_thread.go +1 -0
  8. package/cmd/list_thread_file.go +1 -1
  9. package/cmd/root.go +27 -2
  10. package/cmd/short_drama/short_drama.go +52 -9
  11. package/cmd/short_drama_test.go +19 -19
  12. package/cmd/update/update.go +8 -8
  13. package/internal/common/client.go +29 -21
  14. package/internal/common/download_results.go +20 -14
  15. package/internal/common/error_log.go +62 -1
  16. package/internal/common/error_log_test.go +30 -0
  17. package/internal/common/get_thread.go +28 -14
  18. package/internal/common/get_thread_test.go +74 -0
  19. package/{cmd → internal/common}/json.go +3 -2
  20. package/internal/common/list_thread_file.go +6 -6
  21. package/internal/common/submit_run.go +29 -0
  22. package/internal/common/upload_file.go +23 -19
  23. package/internal/config/config.go +1 -1
  24. package/internal/generate_video/generate_video.go +199 -0
  25. package/internal/generate_video/query_result.go +344 -0
  26. package/internal/generate_video/query_result_test.go +31 -0
  27. package/internal/short_drama/submit_run.go +8 -28
  28. package/package.json +1 -1
  29. package/skills/short-drama/SKILL.md +8 -8
  30. package/skills/xyq-nest-skill/scripts/download_results.py +3 -1
  31. package/skills/xyq-nest-skill/scripts/upload_file.py +2 -2
  32. package/skills/xyq-nest-skill/scripts/xyq_common.py +4 -3
  33. package/cmd/upload_file.go +0 -50
@@ -1,6 +1,8 @@
1
1
  package common
2
2
 
3
3
  import (
4
+ "errors"
5
+ "fmt"
4
6
  "os"
5
7
  "path/filepath"
6
8
  "strings"
@@ -21,6 +23,42 @@ type errorLogEntry struct {
21
23
  Error string `json:"error"`
22
24
  }
23
25
 
26
+ type logIDCarrier interface {
27
+ LogID() string
28
+ }
29
+
30
+ type LogIDError struct {
31
+ Message string
32
+ ID string
33
+ }
34
+
35
+ func NewLogIDError(message string, logID string) error {
36
+ return &LogIDError{
37
+ Message: strings.TrimSpace(message),
38
+ ID: strings.TrimSpace(logID),
39
+ }
40
+ }
41
+
42
+ func (e *LogIDError) Error() string {
43
+ if e == nil {
44
+ return ""
45
+ }
46
+ if e.ID == "" {
47
+ return e.Message
48
+ }
49
+ if e.Message == "" {
50
+ return fmt.Sprintf("log_id=%s", e.ID)
51
+ }
52
+ return fmt.Sprintf("%s log_id=%s", e.Message, e.ID)
53
+ }
54
+
55
+ func (e *LogIDError) LogID() string {
56
+ if e == nil {
57
+ return ""
58
+ }
59
+ return strings.TrimSpace(e.ID)
60
+ }
61
+
24
62
  // AppendDailyErrorLog appends one CLI error record to ~/.pippit_tool_cli/logs/yyyy-mm-dd.log.
25
63
  func AppendDailyErrorLog(command string, err error, fields map[string]string) error {
26
64
  if err == nil {
@@ -38,7 +76,7 @@ func AppendDailyErrorLog(command string, err error, fields map[string]string) er
38
76
  entry := errorLogEntry{
39
77
  Time: now.Format(time.RFC3339),
40
78
  Command: strings.TrimSpace(command),
41
- Fields: cleanErrorLogFields(fields),
79
+ Fields: cleanErrorLogFields(addLogIDField(fields, err)),
42
80
  Error: err.Error(),
43
81
  }
44
82
  data, marshalErr := sonic.Marshal(entry)
@@ -55,6 +93,29 @@ func AppendDailyErrorLog(command string, err error, fields map[string]string) er
55
93
  return writeErr
56
94
  }
57
95
 
96
+ func addLogIDField(fields map[string]string, err error) map[string]string {
97
+ logID := logIDFromError(err)
98
+ if logID == "" {
99
+ return fields
100
+ }
101
+ next := make(map[string]string, len(fields)+1)
102
+ for key, value := range fields {
103
+ next[key] = value
104
+ }
105
+ if strings.TrimSpace(next["log_id"]) == "" {
106
+ next["log_id"] = logID
107
+ }
108
+ return next
109
+ }
110
+
111
+ func logIDFromError(err error) string {
112
+ var carrier logIDCarrier
113
+ if errors.As(err, &carrier) {
114
+ return strings.TrimSpace(carrier.LogID())
115
+ }
116
+ return ""
117
+ }
118
+
58
119
  func dailyErrorLogPath(now time.Time) (string, error) {
59
120
  home, err := os.UserHomeDir()
60
121
  if err != nil {
@@ -59,3 +59,33 @@ func TestAppendDailyErrorLog(t *testing.T) {
59
59
  t.Fatalf("access_key should be omitted from log fields: %#v", fields)
60
60
  }
61
61
  }
62
+
63
+ func TestAppendDailyErrorLogAddsLogID(t *testing.T) {
64
+ home := t.TempDir()
65
+ t.Setenv("HOME", home)
66
+ t.Setenv("USERPROFILE", home)
67
+
68
+ err := AppendDailyErrorLog("query-result", NewLogIDError("request failed", "log_123"), map[string]string{
69
+ "thread_id": "thread_123",
70
+ })
71
+ if err != nil {
72
+ t.Fatalf("AppendDailyErrorLog(): %v", err)
73
+ }
74
+
75
+ path := filepath.Join(home, ".pippit_tool_cli", "logs", time.Now().Format("2006-01-02")+".log")
76
+ data, err := os.ReadFile(path)
77
+ if err != nil {
78
+ t.Fatalf("ReadFile(%s): %v", path, err)
79
+ }
80
+ var entry map[string]any
81
+ if err := sonic.Unmarshal([]byte(strings.TrimSpace(string(data))), &entry); err != nil {
82
+ t.Fatalf("decode log line: %v\n%s", err, string(data))
83
+ }
84
+ fields, ok := entry["fields"].(map[string]any)
85
+ if !ok {
86
+ t.Fatalf("fields = %#v, want object", entry["fields"])
87
+ }
88
+ if fields["log_id"] != "log_123" {
89
+ t.Fatalf("log_id = %v, want log_123", fields["log_id"])
90
+ }
91
+ }
@@ -2,61 +2,75 @@ package common
2
2
 
3
3
  import (
4
4
  "context"
5
+ "encoding/json"
5
6
  "fmt"
6
7
 
7
8
  "github.com/Pippit-dev/pippit-cli/internal/config"
8
9
  )
9
10
 
10
- const getThreadVersionV2 = "v2"
11
+ const GetThreadVersionV2 = "v2"
11
12
 
12
13
  // GetThreadOptions is the stable command-facing request shape for thread lookup.
13
14
  type GetThreadOptions struct {
14
15
  ThreadID string `json:"thread_id"`
15
16
  RunID string `json:"run_id,omitempty"`
17
+ Version string `json:"version,omitempty"`
16
18
  }
17
19
 
18
20
  // GetThreadResult is the parsed get_thread response used by `pippit-tool-cli get-thread`.
19
21
  type GetThreadResult struct {
20
22
  ReadableText string `json:"readable_text"`
23
+ RawData []byte `json:"-"`
21
24
  }
22
25
 
23
26
  type getThreadResponse struct {
24
- Ret string `json:"ret"`
25
- Errmsg string `json:"errmsg"`
26
- Data struct {
27
- ReadableText string `json:"readable_text"`
28
- } `json:"data"`
27
+ Ret string `json:"ret"`
28
+ Errmsg string `json:"errmsg"`
29
+ LogID string `json:"log_id"`
30
+ Data json.RawMessage `json:"data"`
29
31
  }
30
32
 
31
33
  func GetThread(ctx context.Context, opts *GetThreadOptions, runner *Runner) (*GetThreadResult, error) {
32
34
  if runner == nil || runner.Client == nil {
33
- return nil, fmt.Errorf("get_thread runner client is required")
35
+ return nil, fmt.Errorf("get_thread 运行器客户端缺失")
34
36
  }
35
37
 
36
38
  body := map[string]any{
37
39
  "thread_id": opts.ThreadID,
38
- "version": getThreadVersionV2,
39
40
  }
40
41
  if opts.RunID != "" {
41
42
  body["run_id"] = opts.RunID
42
43
  }
44
+ if opts.Version != "" {
45
+ body["version"] = opts.Version
46
+ }
43
47
 
44
48
  var resp getThreadResponse
45
49
  if err := runner.Client.SendRequest(ctx, getThreadPath(runner), body, &resp); err != nil {
46
- return nil, fmt.Errorf("get_thread request failed: %w", err)
50
+ return nil, fmt.Errorf("获取线程请求失败: %w", err)
47
51
  }
48
52
  if resp.Ret != "0" {
49
53
  if resp.Errmsg == "" {
50
- resp.Errmsg = "unknown error"
54
+ resp.Errmsg = "未知错误"
51
55
  }
52
- return nil, fmt.Errorf("get_thread failed: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
56
+ return nil, NewLogIDError(fmt.Sprintf("获取线程请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
57
+ }
58
+ if len(resp.Data) == 0 {
59
+ return nil, fmt.Errorf("get_thread 响应缺少 data")
53
60
  }
54
- if resp.Data.ReadableText == "" {
55
- return nil, fmt.Errorf("get_thread response missing data.readable_text")
61
+ readable := struct {
62
+ ReadableText string `json:"readable_text"`
63
+ }{}
64
+ if opts.Version == GetThreadVersionV2 {
65
+ _ = json.Unmarshal(resp.Data, &readable)
66
+ if readable.ReadableText == "" {
67
+ return nil, fmt.Errorf("get_thread v2 响应缺少 data.readable_text")
68
+ }
56
69
  }
57
70
 
58
71
  return &GetThreadResult{
59
- ReadableText: resp.Data.ReadableText,
72
+ ReadableText: readable.ReadableText,
73
+ RawData: resp.Data,
60
74
  }, nil
61
75
  }
62
76
 
@@ -0,0 +1,74 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/bytedance/sonic"
9
+ )
10
+
11
+ type getThreadFakeClient struct {
12
+ response string
13
+ }
14
+
15
+ func (c getThreadFakeClient) SendRequest(_ context.Context, _ string, _ any, out any) error {
16
+ return sonic.Unmarshal([]byte(c.response), out)
17
+ }
18
+
19
+ func (c getThreadFakeClient) SendRequestWithHeaders(ctx context.Context, path string, body any, _ map[string]string, out any) error {
20
+ return c.SendRequest(ctx, path, body, out)
21
+ }
22
+
23
+ func (c getThreadFakeClient) SendMultipartRequest(context.Context, string, map[string]string, MultipartFile, any) error {
24
+ return nil
25
+ }
26
+
27
+ func TestGetThreadAllowsStructuredDataWithoutVersion(t *testing.T) {
28
+ result, err := GetThread(context.Background(), &GetThreadOptions{
29
+ ThreadID: "thread_123",
30
+ RunID: "run_456",
31
+ }, &Runner{
32
+ Client: getThreadFakeClient{response: `{"ret":"0","errmsg":"","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":3}]}}}`},
33
+ })
34
+ if err != nil {
35
+ t.Fatalf("GetThread() error = %v", err)
36
+ }
37
+ if result.ReadableText != "" {
38
+ t.Fatalf("ReadableText = %q, want empty for non-v2 response", result.ReadableText)
39
+ }
40
+ if !strings.Contains(string(result.RawData), `"thread"`) {
41
+ t.Fatalf("RawData = %s, want structured thread data", string(result.RawData))
42
+ }
43
+ }
44
+
45
+ func TestGetThreadRequiresRetZero(t *testing.T) {
46
+ _, err := GetThread(context.Background(), &GetThreadOptions{
47
+ ThreadID: "thread_123",
48
+ RunID: "run_456",
49
+ }, &Runner{
50
+ Client: getThreadFakeClient{response: `{"errmsg":"","data":{"thread":{"thread_id":"thread_123"}}}`},
51
+ })
52
+ if err == nil {
53
+ t.Fatal("GetThread() error = nil, want missing ret validation")
54
+ }
55
+ if !strings.Contains(err.Error(), "获取线程请求返回失败: ret=") {
56
+ t.Fatalf("error = %q, want missing ret validation", err)
57
+ }
58
+ }
59
+
60
+ func TestGetThreadV2RequiresReadableText(t *testing.T) {
61
+ _, err := GetThread(context.Background(), &GetThreadOptions{
62
+ ThreadID: "thread_123",
63
+ RunID: "run_456",
64
+ Version: GetThreadVersionV2,
65
+ }, &Runner{
66
+ Client: getThreadFakeClient{response: `{"ret":"0","errmsg":"","data":{"thread":{"thread_id":"thread_123"}}}`},
67
+ })
68
+ if err == nil {
69
+ t.Fatal("GetThread() error = nil, want v2 readable_text validation")
70
+ }
71
+ if !strings.Contains(err.Error(), "get_thread v2 响应缺少 data.readable_text") {
72
+ t.Fatalf("error = %q, want readable_text validation", err)
73
+ }
74
+ }
@@ -1,4 +1,4 @@
1
- package cmd
1
+ package common
2
2
 
3
3
  import (
4
4
  "fmt"
@@ -7,7 +7,8 @@ import (
7
7
  "github.com/bytedance/sonic"
8
8
  )
9
9
 
10
- func writeJSON(w io.Writer, v any) error {
10
+ // WriteJSON writes v as one JSON line.
11
+ func WriteJSON(w io.Writer, v any) error {
11
12
  data, err := sonic.Marshal(v)
12
13
  if err != nil {
13
14
  return err
@@ -51,7 +51,7 @@ type threadFileResponse struct {
51
51
 
52
52
  func ListThreadFile(ctx context.Context, opts *ListThreadFileOptions, runner *Runner) (*ListThreadFileResult, error) {
53
53
  if runner == nil || runner.Client == nil {
54
- return nil, fmt.Errorf("list_thread_file runner client is required")
54
+ return nil, fmt.Errorf("list_thread_file 运行器客户端缺失")
55
55
  }
56
56
 
57
57
  body := map[string]any{
@@ -66,13 +66,13 @@ func ListThreadFile(ctx context.Context, opts *ListThreadFileOptions, runner *Ru
66
66
 
67
67
  var resp listThreadFileResponse
68
68
  if err := runner.Client.SendRequest(ctx, listThreadFilePath(runner), body, &resp); err != nil {
69
- return nil, fmt.Errorf("list_thread_file request failed: %w", err)
69
+ return nil, fmt.Errorf("获取线程文件列表请求失败: %w", err)
70
70
  }
71
71
  if resp.Ret != "0" {
72
72
  if resp.Errmsg == "" {
73
- resp.Errmsg = "unknown error"
73
+ resp.Errmsg = "未知错误"
74
74
  }
75
- return nil, fmt.Errorf("list_thread_file failed: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
75
+ return nil, fmt.Errorf("获取线程文件列表请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
76
76
  }
77
77
 
78
78
  files := make([]*ThreadFile, 0, len(resp.Data.Files))
@@ -101,9 +101,9 @@ func listThreadFileMessage(total int64, pageNum int) string {
101
101
  }
102
102
  var message string
103
103
  if total >= MaxListThreadFilePageSize {
104
- message = fmt.Sprintf("total reached %d; query the next page with --page-num %d", MaxListThreadFilePageSize, pageNum+1)
104
+ message = fmt.Sprintf("文件总数已达到 %d;请使用 --page-num %d 查询下一页", MaxListThreadFilePageSize, pageNum+1)
105
105
  } else {
106
- message = fmt.Sprintf("total is below %d; continue querying with current --page-num %d", MaxListThreadFilePageSize, pageNum)
106
+ message = fmt.Sprintf("文件总数小于 %d;继续使用当前 --page-num %d 查询", MaxListThreadFilePageSize, pageNum)
107
107
  }
108
108
  return fmt.Sprintf("%s\n- %s\n%s", start, message, end)
109
109
  }
@@ -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"
@@ -17,7 +18,7 @@ type UploadFileOptions struct {
17
18
  FileName string `json:"file_name"`
18
19
  }
19
20
 
20
- // UploadFileResult is the JSON envelope printed by `pippit-tool-cli upload-file`.
21
+ // UploadFileResult is the JSON envelope printed by `pippit-tool-cli short-drama +upload-file`.
21
22
  type UploadFileResult struct {
22
23
  AssetID string `json:"asset_id"`
23
24
  }
@@ -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
+ }