@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
|
@@ -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
|
|
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
|
|
25
|
-
Errmsg string
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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("
|
|
50
|
+
return nil, fmt.Errorf("获取线程请求失败: %w", err)
|
|
47
51
|
}
|
|
48
52
|
if resp.Ret != "0" {
|
|
49
53
|
if resp.Errmsg == "" {
|
|
50
|
-
resp.Errmsg = "
|
|
54
|
+
resp.Errmsg = "未知错误"
|
|
51
55
|
}
|
|
52
|
-
return nil, fmt.
|
|
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
|
-
|
|
55
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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("
|
|
69
|
+
return nil, fmt.Errorf("获取线程文件列表请求失败: %w", err)
|
|
70
70
|
}
|
|
71
71
|
if resp.Ret != "0" {
|
|
72
72
|
if resp.Errmsg == "" {
|
|
73
|
-
resp.Errmsg = "
|
|
73
|
+
resp.Errmsg = "未知错误"
|
|
74
74
|
}
|
|
75
|
-
return nil, fmt.Errorf("
|
|
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("
|
|
104
|
+
message = fmt.Sprintf("文件总数已达到 %d;请使用 --page-num %d 查询下一页", MaxListThreadFilePageSize, pageNum+1)
|
|
105
105
|
} else {
|
|
106
|
-
message = fmt.Sprintf("
|
|
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
|
-
|
|
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
|
+
}
|