@pippit-dev/cli 1.0.44 → 1.0.46
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/checksums.txt +6 -6
- package/cmd/generate_video_test.go +27 -17
- package/cmd/media_upload_test.go +132 -0
- package/internal/common/upload_file.go +2 -2
- package/internal/common/upload_media.go +69 -0
- package/internal/common/upload_media_test.go +192 -0
- package/internal/generate_image/generate_image.go +5 -19
- package/internal/generate_video/generate_video.go +7 -27
- package/internal/space/session.go +27 -5
- package/package.json +1 -1
- package/skills/xyq-nest-skill/SKILL.md +2 -0
- package/dist/checksums.txt +0 -6
package/checksums.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
a22b8aaebe337a2617f238dfcdd425a4133a9c63bebf812f15b834955881233b pippit-tool-cli-1.0.46-darwin-amd64.tar.gz
|
|
2
|
+
a28e74b130adb63620bd3bb5d382d621e7dfc8a826c7e4663b1fba7f4a623449 pippit-tool-cli-1.0.46-darwin-arm64.tar.gz
|
|
3
|
+
6c7e1a7998e48de298bb1c0263ff26e06a9acd55c8469edc6a7731914109aa3d pippit-tool-cli-1.0.46-linux-amd64.tar.gz
|
|
4
|
+
1641f4cc8161e4b1f012211c690db07a3fd100ff18845130f77f603e06298cd4 pippit-tool-cli-1.0.46-linux-arm64.tar.gz
|
|
5
|
+
938743186947edac0e27627891066c946d99d8facc80f27b6395eaab88fda0f9 pippit-tool-cli-1.0.46-windows-amd64.zip
|
|
6
|
+
1f8903e4c94214e21c5e86556243a8e95175e076f41261da6210fb51fdca72c2 pippit-tool-cli-1.0.46-windows-arm64.zip
|
|
@@ -8,14 +8,18 @@ import (
|
|
|
8
8
|
"os"
|
|
9
9
|
"path/filepath"
|
|
10
10
|
"strings"
|
|
11
|
+
"sync/atomic"
|
|
11
12
|
"testing"
|
|
12
13
|
|
|
13
14
|
"github.com/bytedance/sonic"
|
|
14
15
|
)
|
|
15
16
|
|
|
16
17
|
func TestGenerateVideo(t *testing.T) {
|
|
17
|
-
assetIDs := []string{
|
|
18
|
-
|
|
18
|
+
assetIDs := map[string]string{
|
|
19
|
+
"cat1.jpg": "image_asset_1", "cat2.jpg": "image_asset_2",
|
|
20
|
+
"video1.mp4": "video_asset_1", "video2.mp4": "video_asset_2", "bgm.wav": "audio_asset_1",
|
|
21
|
+
}
|
|
22
|
+
var uploadCount atomic.Int32
|
|
19
23
|
|
|
20
24
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
21
25
|
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
@@ -26,9 +30,6 @@ func TestGenerateVideo(t *testing.T) {
|
|
|
26
30
|
if r.Method != http.MethodPost {
|
|
27
31
|
t.Fatalf("upload method = %s, want POST", r.Method)
|
|
28
32
|
}
|
|
29
|
-
if uploadIndex >= len(assetIDs) {
|
|
30
|
-
t.Fatalf("unexpected upload %d", uploadIndex)
|
|
31
|
-
}
|
|
32
33
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
33
34
|
t.Fatalf("ParseMultipartForm(): %v", err)
|
|
34
35
|
}
|
|
@@ -39,11 +40,15 @@ func TestGenerateVideo(t *testing.T) {
|
|
|
39
40
|
if files[0].Filename == "bgm.wav" && !strings.HasPrefix(files[0].Header.Get("Content-Type"), "audio/") {
|
|
40
41
|
t.Fatalf("audio content type = %q, want audio content type", files[0].Header.Get("Content-Type"))
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
assetID, ok := assetIDs[files[0].Filename]
|
|
44
|
+
if !ok {
|
|
45
|
+
t.Errorf("unexpected upload %q", files[0].Filename)
|
|
46
|
+
}
|
|
47
|
+
uploadCount.Add(1)
|
|
48
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"pippit_asset_id":"` + assetID + `"}}`))
|
|
44
49
|
case "/api/biz/v1/skill/submit_run":
|
|
45
|
-
if
|
|
46
|
-
t.Fatalf("submit called after %d uploads, want %d",
|
|
50
|
+
if got := uploadCount.Load(); int(got) != len(assetIDs) {
|
|
51
|
+
t.Fatalf("submit called after %d uploads, want %d", got, len(assetIDs))
|
|
47
52
|
}
|
|
48
53
|
data, err := io.ReadAll(r.Body)
|
|
49
54
|
if err != nil {
|
|
@@ -288,19 +293,24 @@ func TestGenerateVideoAcceptsReferencesBeyondFormerLimits(t *testing.T) {
|
|
|
288
293
|
}
|
|
289
294
|
}
|
|
290
295
|
|
|
291
|
-
|
|
296
|
+
var uploadCount atomic.Int32
|
|
292
297
|
submitted := false
|
|
293
298
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
294
299
|
switch r.URL.Path {
|
|
295
300
|
case "/api/biz/v1/skill/upload_file":
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
+
file, header, err := r.FormFile("file")
|
|
302
|
+
if err != nil {
|
|
303
|
+
t.Errorf("FormFile(): %v", err)
|
|
304
|
+
http.Error(w, "invalid upload", http.StatusBadRequest)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
file.Close()
|
|
308
|
+
defer r.MultipartForm.RemoveAll()
|
|
309
|
+
uploadCount.Add(1)
|
|
310
|
+
_, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"` + header.Filename + `"}}`))
|
|
301
311
|
case "/api/biz/v1/skill/submit_run":
|
|
302
|
-
if
|
|
303
|
-
t.Fatalf("uploaded %d references, want %d",
|
|
312
|
+
if got := uploadCount.Load(); int(got) != len(assetIDs) {
|
|
313
|
+
t.Fatalf("uploaded %d references, want %d", got, len(assetIDs))
|
|
304
314
|
}
|
|
305
315
|
data, err := io.ReadAll(r.Body)
|
|
306
316
|
if err != nil {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
"sync"
|
|
13
|
+
"sync/atomic"
|
|
14
|
+
"testing"
|
|
15
|
+
|
|
16
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
17
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
func TestGenerationCollectsUploadFailuresBeforeReturning(t *testing.T) {
|
|
21
|
+
for _, command := range []string{"generate-image", "generate-video"} {
|
|
22
|
+
t.Run(command, func(t *testing.T) {
|
|
23
|
+
t.Setenv("HOME", t.TempDir())
|
|
24
|
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
25
|
+
t.Setenv("LocalAppData", t.TempDir())
|
|
26
|
+
type material struct {
|
|
27
|
+
flag, name, failure string
|
|
28
|
+
missing bool
|
|
29
|
+
}
|
|
30
|
+
materials := []material{
|
|
31
|
+
{flag: "image", name: "rejected-a.jpg", failure: `第1张参考图片 "rejected-a.jpg" 上传失败: 上传文件请求返回失败: ret=1 errmsg=图片不符合社区规范 log_id=log-rejected-a.jpg`},
|
|
32
|
+
{flag: "image", name: "accepted.jpg"},
|
|
33
|
+
{flag: "image", name: "rejected-b.jpg", failure: `第3张参考图片 "rejected-b.jpg" 上传失败: 上传文件请求返回失败: ret=1 errmsg=图片不符合社区规范 log_id=log-rejected-b.jpg`},
|
|
34
|
+
}
|
|
35
|
+
if command == "generate-video" {
|
|
36
|
+
materials = append(materials,
|
|
37
|
+
material{flag: "video", name: "rejected.mp4", failure: `第1个参考视频 "rejected.mp4" 上传失败: 上传文件请求返回失败: ret=1 errmsg=视频不符合社区规范 log_id=log-rejected.mp4`},
|
|
38
|
+
material{flag: "video", name: "accepted.mp4"},
|
|
39
|
+
material{flag: "audio", name: "missing.wav", missing: true, failure: `第1段参考音频 "missing.wav" 上传失败: 上传文件不存在:`},
|
|
40
|
+
material{flag: "audio", name: "unavailable.wav", failure: `第2段参考音频 "unavailable.wav" 上传失败: 上传文件请求返回失败: ret=1 errmsg=服务暂不可用 log_id=log-unavailable.wav`},
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
args := []string{command, "--prompt", "test"}
|
|
44
|
+
if command == "generate-image" {
|
|
45
|
+
args = append(args, "--model", "Seedream 5.0 Pro")
|
|
46
|
+
}
|
|
47
|
+
dir := t.TempDir()
|
|
48
|
+
for _, item := range materials {
|
|
49
|
+
path := filepath.Join(dir, item.name)
|
|
50
|
+
if !item.missing {
|
|
51
|
+
if err := os.WriteFile(path, []byte("media"), 0600); err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
args = append(args, "--"+item.flag, path)
|
|
56
|
+
}
|
|
57
|
+
var mu sync.Mutex
|
|
58
|
+
seen := make(map[string]int)
|
|
59
|
+
var submits atomic.Int32
|
|
60
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
61
|
+
switch r.URL.Path {
|
|
62
|
+
case config.GetAvailableModelListPath:
|
|
63
|
+
_, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":[{"key":"seedream_5.0_pro","name":"Seedream 5.0 Pro","kind":"image"}]}}}`))
|
|
64
|
+
case config.UploadFilePath:
|
|
65
|
+
file, header, err := r.FormFile("file")
|
|
66
|
+
if err != nil {
|
|
67
|
+
t.Errorf("FormFile(): %v", err)
|
|
68
|
+
http.Error(w, "invalid upload", http.StatusBadRequest)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
file.Close()
|
|
72
|
+
defer r.MultipartForm.RemoveAll()
|
|
73
|
+
mu.Lock()
|
|
74
|
+
seen[header.Filename]++
|
|
75
|
+
mu.Unlock()
|
|
76
|
+
switch {
|
|
77
|
+
case strings.HasPrefix(header.Filename, "accepted"):
|
|
78
|
+
fmt.Fprintf(w, `{"ret":"0","data":{"pippit_asset_id":%q}}`, header.Filename)
|
|
79
|
+
case header.Filename == "unavailable.wav":
|
|
80
|
+
fmt.Fprintf(w, `{"ret":"1","errmsg":"服务暂不可用","log_id":%q}`, "log-"+header.Filename)
|
|
81
|
+
default:
|
|
82
|
+
reason := "图片不符合社区规范"
|
|
83
|
+
if strings.HasSuffix(header.Filename, ".mp4") {
|
|
84
|
+
reason = "视频不符合社区规范"
|
|
85
|
+
}
|
|
86
|
+
fmt.Fprintf(w, `{"ret":"1","errmsg":%q,"log_id":%q}`, reason, "log-"+header.Filename)
|
|
87
|
+
}
|
|
88
|
+
case config.SubmitRunPath:
|
|
89
|
+
submits.Add(1)
|
|
90
|
+
http.Error(w, "must not submit", http.StatusInternalServerError)
|
|
91
|
+
default:
|
|
92
|
+
t.Errorf("unexpected request %s", r.URL.Path)
|
|
93
|
+
http.NotFound(w, r)
|
|
94
|
+
}
|
|
95
|
+
}))
|
|
96
|
+
defer server.Close()
|
|
97
|
+
var stdout, stderr bytes.Buffer
|
|
98
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
99
|
+
root.SetArgs(args)
|
|
100
|
+
err := root.Execute()
|
|
101
|
+
if err == nil || !strings.Contains(err.Error(), "未提交生成") {
|
|
102
|
+
t.Fatalf("error = %v, want upload failure summary", err)
|
|
103
|
+
}
|
|
104
|
+
var logErr *common.LogIDError
|
|
105
|
+
if !errors.As(err, &logErr) || logErr.LogID() != "log-rejected-a.jpg" {
|
|
106
|
+
t.Errorf("aggregated error lost the original LogID error chain: %v", err)
|
|
107
|
+
}
|
|
108
|
+
lastPosition := -1
|
|
109
|
+
mu.Lock()
|
|
110
|
+
defer mu.Unlock()
|
|
111
|
+
for _, item := range materials {
|
|
112
|
+
wantRequests := 1
|
|
113
|
+
if item.missing {
|
|
114
|
+
wantRequests = 0
|
|
115
|
+
}
|
|
116
|
+
if seen[item.name] != wantRequests {
|
|
117
|
+
t.Errorf("%s uploaded %d times, want %d", item.name, seen[item.name], wantRequests)
|
|
118
|
+
}
|
|
119
|
+
if item.failure != "" {
|
|
120
|
+
position := strings.Index(err.Error(), item.failure)
|
|
121
|
+
if position <= lastPosition {
|
|
122
|
+
t.Errorf("failure missing or out of order: %s; error: %v", item.failure, err)
|
|
123
|
+
}
|
|
124
|
+
lastPosition = position
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if submits.Load() != 0 || stdout.Len() != 0 {
|
|
128
|
+
t.Fatalf("failed uploads created a task: submits=%d stdout=%s", submits.Load(), stdout.String())
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -94,12 +94,12 @@ func UploadFile(ctx context.Context, opts UploadFileOptions, runner *Runner) (*U
|
|
|
94
94
|
if resp.Errmsg == "" {
|
|
95
95
|
resp.Errmsg = "未知错误"
|
|
96
96
|
}
|
|
97
|
-
return nil, fmt.
|
|
97
|
+
return nil, NewLogIDError(fmt.Sprintf("上传文件请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
assetID := strings.TrimSpace(resp.Data.PippitAssetID)
|
|
101
101
|
if assetID == "" {
|
|
102
|
-
return nil,
|
|
102
|
+
return nil, NewLogIDError("上传文件响应缺少 pippit_asset_id", resp.LogID)
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
return &UploadFileResult{
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"sync"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const mediaUploadWorkers = 10
|
|
12
|
+
|
|
13
|
+
// UploadMediaGroup keeps the original order and type-local numbering of references.
|
|
14
|
+
type UploadMediaGroup struct {
|
|
15
|
+
Label string
|
|
16
|
+
Paths []string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// UploadMediaGroups waits for every upload, preserving group and path order even
|
|
20
|
+
// when requests finish out of order. Any failure prevents generation callers
|
|
21
|
+
// from receiving a partial set of asset IDs.
|
|
22
|
+
func UploadMediaGroups(ctx context.Context, runner *Runner, groups ...UploadMediaGroup) ([][]string, error) {
|
|
23
|
+
type task struct {
|
|
24
|
+
group int
|
|
25
|
+
index int
|
|
26
|
+
slot int
|
|
27
|
+
}
|
|
28
|
+
assetIDs := make([][]string, len(groups))
|
|
29
|
+
var tasks []task
|
|
30
|
+
for g, group := range groups {
|
|
31
|
+
assetIDs[g] = make([]string, len(group.Paths))
|
|
32
|
+
for i := range group.Paths {
|
|
33
|
+
tasks = append(tasks, task{group: g, index: i, slot: len(tasks)})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
errs := make([]error, len(tasks))
|
|
37
|
+
taskCh := make(chan task)
|
|
38
|
+
var wg sync.WaitGroup
|
|
39
|
+
for worker := 0; worker < min(mediaUploadWorkers, len(tasks)); worker++ {
|
|
40
|
+
wg.Add(1)
|
|
41
|
+
go func() {
|
|
42
|
+
defer wg.Done()
|
|
43
|
+
for task := range taskCh {
|
|
44
|
+
group := groups[task.group]
|
|
45
|
+
path := group.Paths[task.index]
|
|
46
|
+
expanded, err := ExpandPath(path)
|
|
47
|
+
if err != nil {
|
|
48
|
+
errs[task.slot] = fmt.Errorf("第%d%s %q 路径解析失败: %w", task.index+1, group.Label, filepath.Base(path), err)
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
result, err := UploadFile(ctx, UploadFileOptions{Path: expanded}, runner)
|
|
52
|
+
if err != nil {
|
|
53
|
+
errs[task.slot] = fmt.Errorf("第%d%s %q 上传失败: %w", task.index+1, group.Label, filepath.Base(path), err)
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
assetIDs[task.group][task.index] = result.AssetID
|
|
57
|
+
}
|
|
58
|
+
}()
|
|
59
|
+
}
|
|
60
|
+
for _, task := range tasks {
|
|
61
|
+
taskCh <- task
|
|
62
|
+
}
|
|
63
|
+
close(taskCh)
|
|
64
|
+
wg.Wait()
|
|
65
|
+
if err := errors.Join(errs...); err != nil {
|
|
66
|
+
return nil, fmt.Errorf("参考素材上传失败,未提交生成:\n%w", err)
|
|
67
|
+
}
|
|
68
|
+
return assetIDs, nil
|
|
69
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"reflect"
|
|
12
|
+
"strings"
|
|
13
|
+
"sync/atomic"
|
|
14
|
+
"testing"
|
|
15
|
+
"time"
|
|
16
|
+
|
|
17
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
18
|
+
"github.com/Pippit-dev/pippit-cli/internal/space"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
type mediaUploadClient struct {
|
|
22
|
+
Client
|
|
23
|
+
upload func(context.Context, MultipartFile, any) error
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func (c mediaUploadClient) SendMultipartRequest(ctx context.Context, _ string, _ map[string]string, file MultipartFile, out any) error {
|
|
27
|
+
return c.upload(ctx, file, out)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func TestUploadMediaGroupsConcurrentAndOrdered(t *testing.T) {
|
|
31
|
+
const wantConcurrency = 10
|
|
32
|
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
33
|
+
defer cancel()
|
|
34
|
+
groups := []UploadMediaGroup{{Label: "张参考图片"}, {Label: "个参考视频"}, {Label: "段参考音频"}}
|
|
35
|
+
want := [][]string{
|
|
36
|
+
{"a.jpg", "b.jpg", "c.jpg", "d.jpg"},
|
|
37
|
+
{"a.mp4", "b.mp4", "c.mp4", "d.mp4"},
|
|
38
|
+
{"a.wav", "b.wav", "c.wav", "d.wav"},
|
|
39
|
+
}
|
|
40
|
+
release := make(map[string]chan struct{})
|
|
41
|
+
dir := t.TempDir()
|
|
42
|
+
for g, names := range want {
|
|
43
|
+
for _, name := range names {
|
|
44
|
+
path := filepath.Join(dir, name)
|
|
45
|
+
if err := os.WriteFile(path, []byte("media"), 0600); err != nil {
|
|
46
|
+
t.Fatal(err)
|
|
47
|
+
}
|
|
48
|
+
groups[g].Paths = append(groups[g].Paths, path)
|
|
49
|
+
release[name] = make(chan struct{})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
started := make(chan string, len(release))
|
|
53
|
+
var active, peak atomic.Int32
|
|
54
|
+
client := mediaUploadClient{upload: func(ctx context.Context, file MultipartFile, out any) error {
|
|
55
|
+
n := active.Add(1)
|
|
56
|
+
defer active.Add(-1)
|
|
57
|
+
for old := peak.Load(); n > old; old = peak.Load() {
|
|
58
|
+
if peak.CompareAndSwap(old, n) {
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
started <- file.FileName
|
|
63
|
+
select {
|
|
64
|
+
case <-release[file.FileName]:
|
|
65
|
+
case <-ctx.Done():
|
|
66
|
+
return ctx.Err()
|
|
67
|
+
}
|
|
68
|
+
response := out.(*uploadFileResponse)
|
|
69
|
+
response.Ret = "0"
|
|
70
|
+
response.Data.PippitAssetID = file.FileName
|
|
71
|
+
return nil
|
|
72
|
+
}}
|
|
73
|
+
type outcome struct {
|
|
74
|
+
ids [][]string
|
|
75
|
+
err error
|
|
76
|
+
}
|
|
77
|
+
done := make(chan outcome, 1)
|
|
78
|
+
go func() {
|
|
79
|
+
ids, err := UploadMediaGroups(ctx, &Runner{Client: client}, groups...)
|
|
80
|
+
done <- outcome{ids, err}
|
|
81
|
+
}()
|
|
82
|
+
var first []string
|
|
83
|
+
for i := 0; i < wantConcurrency; i++ {
|
|
84
|
+
select {
|
|
85
|
+
case name := <-started:
|
|
86
|
+
first = append(first, name)
|
|
87
|
+
case <-ctx.Done():
|
|
88
|
+
t.Fatal("uploads did not start concurrently")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Release the first requests in reverse order; IDs must still match inputs.
|
|
92
|
+
for i := len(first) - 1; i >= 0; i-- {
|
|
93
|
+
close(release[first[i]])
|
|
94
|
+
}
|
|
95
|
+
for i := len(first); i < len(release); i++ {
|
|
96
|
+
select {
|
|
97
|
+
case name := <-started:
|
|
98
|
+
close(release[name])
|
|
99
|
+
case <-ctx.Done():
|
|
100
|
+
t.Fatal("remaining uploads did not start")
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
select {
|
|
104
|
+
case got := <-done:
|
|
105
|
+
if got.err != nil || !reflect.DeepEqual(got.ids, want) {
|
|
106
|
+
t.Fatalf("UploadMediaGroups() = %v, %v; want %v", got.ids, got.err, want)
|
|
107
|
+
}
|
|
108
|
+
case <-ctx.Done():
|
|
109
|
+
t.Fatal("uploads did not finish")
|
|
110
|
+
}
|
|
111
|
+
if got := peak.Load(); got != wantConcurrency {
|
|
112
|
+
t.Fatalf("peak uploads = %d, want %d", got, wantConcurrency)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func TestUploadMediaGroupsPreservesSpace(t *testing.T) {
|
|
117
|
+
for _, teamID := range []string{"0", "12"} {
|
|
118
|
+
t.Run(teamID, func(t *testing.T) {
|
|
119
|
+
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
120
|
+
wantHeader := teamID
|
|
121
|
+
if teamID == "0" {
|
|
122
|
+
wantHeader = ""
|
|
123
|
+
}
|
|
124
|
+
if r.Header.Get("X-Team-Id") != wantHeader || r.Header.Get("Authorization") != "Bearer test-key" {
|
|
125
|
+
t.Error("upload lost selected space or authentication")
|
|
126
|
+
}
|
|
127
|
+
if r.URL.Path != config.UploadFilePath {
|
|
128
|
+
t.Errorf("unexpected path %s", r.URL.Path)
|
|
129
|
+
}
|
|
130
|
+
file, header, err := r.FormFile("file")
|
|
131
|
+
if err != nil {
|
|
132
|
+
t.Errorf("FormFile(): %v", err)
|
|
133
|
+
http.Error(w, "invalid upload", http.StatusBadRequest)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
file.Close()
|
|
137
|
+
defer r.MultipartForm.RemoveAll()
|
|
138
|
+
fmt.Fprintf(w, `{"ret":"0","data":{"pippit_asset_id":%q}}`, header.Filename)
|
|
139
|
+
}))
|
|
140
|
+
defer api.Close()
|
|
141
|
+
store := &space.Store{Dir: t.TempDir(), Account: "account", Origin: api.URL}
|
|
142
|
+
var stderr bytes.Buffer
|
|
143
|
+
session := &space.Session{Store: store, Explicit: &teamID, Stderr: &stderr}
|
|
144
|
+
ctx := WithSpace(context.Background(), session)
|
|
145
|
+
var paths []string
|
|
146
|
+
dir := t.TempDir()
|
|
147
|
+
for i := 0; i < 7; i++ {
|
|
148
|
+
path := filepath.Join(dir, fmt.Sprintf("image-%d.jpg", i))
|
|
149
|
+
if err := os.WriteFile(path, []byte("image"), 0600); err != nil {
|
|
150
|
+
t.Fatal(err)
|
|
151
|
+
}
|
|
152
|
+
paths = append(paths, path)
|
|
153
|
+
}
|
|
154
|
+
runner := &Runner{Client: NewHTTPClient(api.URL, 5*time.Second, NewAccessKeyAuthorizer("test-key"))}
|
|
155
|
+
ids, err := UploadMediaGroups(ctx, runner, UploadMediaGroup{Label: "张参考图片", Paths: paths})
|
|
156
|
+
if err != nil {
|
|
157
|
+
t.Fatal(err)
|
|
158
|
+
}
|
|
159
|
+
if strings.Count(stderr.String(), "本次空间:") != 1 || session.TeamID() != teamID {
|
|
160
|
+
t.Fatalf("space selected more than once or changed: %s", stderr.String())
|
|
161
|
+
}
|
|
162
|
+
binding, err := space.DecodeBinding(session.Token())
|
|
163
|
+
if err != nil || binding.TeamID != teamID {
|
|
164
|
+
t.Fatalf("space token changed: %+v, %v", binding, err)
|
|
165
|
+
}
|
|
166
|
+
for _, id := range ids[0] {
|
|
167
|
+
ref := space.References{AssetIDs: []string{id}}
|
|
168
|
+
selected, err := (&space.Session{Store: store, Explicit: &teamID}).Resolve(ref)
|
|
169
|
+
if err != nil || selected.TeamID != teamID {
|
|
170
|
+
t.Fatalf("asset %s lost its space: %+v, %v", id, selected, err)
|
|
171
|
+
}
|
|
172
|
+
other := "34"
|
|
173
|
+
if _, err := (&space.Session{Store: store, Explicit: &other}).Resolve(ref); err == nil {
|
|
174
|
+
t.Fatalf("asset %s accepted in another team", id)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func TestUploadMediaGroupsCanceledBeforeUpload(t *testing.T) {
|
|
182
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
183
|
+
cancel()
|
|
184
|
+
client := mediaUploadClient{upload: func(context.Context, MultipartFile, any) error {
|
|
185
|
+
t.Error("canceled batch sent an upload request")
|
|
186
|
+
return nil
|
|
187
|
+
}}
|
|
188
|
+
ids, err := UploadMediaGroups(ctx, &Runner{Client: client}, UploadMediaGroup{Label: "张参考图片", Paths: []string{"a.jpg", "b.jpg"}})
|
|
189
|
+
if ids != nil || err == nil || strings.Count(err.Error(), "上传文件已取消") != 2 {
|
|
190
|
+
t.Fatalf("canceled batch = %v, %v", ids, err)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -58,12 +58,14 @@ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*common.Sub
|
|
|
58
58
|
return nil, err
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
assetIDs, err := common.UploadMediaGroups(ctx, runner,
|
|
62
|
+
common.UploadMediaGroup{Label: "张参考图片", Paths: opts.ImagePaths},
|
|
63
|
+
)
|
|
62
64
|
if err != nil {
|
|
63
|
-
return nil,
|
|
65
|
+
return nil, err
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
body := buildSubmitRunBody(opts, modelKey,
|
|
68
|
+
body := buildSubmitRunBody(opts, modelKey, assetIDs[0])
|
|
67
69
|
|
|
68
70
|
var resp common.SubmitRunResponse
|
|
69
71
|
if err := runner.Client.SendRequest(ctx, common.SubmitRunPath(runner), body, &resp); err != nil {
|
|
@@ -126,22 +128,6 @@ func SupportedRatioUsage() string {
|
|
|
126
128
|
return ratioUsage
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
func uploadImageList(ctx context.Context, paths []string, runner *common.Runner) ([]string, error) {
|
|
130
|
-
assetIDs := make([]string, 0, len(paths))
|
|
131
|
-
for _, path := range paths {
|
|
132
|
-
expanded, err := common.ExpandPath(path)
|
|
133
|
-
if err != nil {
|
|
134
|
-
return nil, err
|
|
135
|
-
}
|
|
136
|
-
result, err := common.UploadFile(ctx, common.UploadFileOptions{Path: expanded}, runner)
|
|
137
|
-
if err != nil {
|
|
138
|
-
return nil, err
|
|
139
|
-
}
|
|
140
|
-
assetIDs = append(assetIDs, result.AssetID)
|
|
141
|
-
}
|
|
142
|
-
return assetIDs, nil
|
|
143
|
-
}
|
|
144
|
-
|
|
145
131
|
func buildSubmitRunBody(opts *Options, modelKey string, imageAssetIDs []string) map[string]any {
|
|
146
132
|
ratio, _ := parseRatio(opts.Ratio)
|
|
147
133
|
body := map[string]any{
|
|
@@ -45,20 +45,16 @@ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, er
|
|
|
45
45
|
return nil, err
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
assetIDs, err := common.UploadMediaGroups(ctx, runner,
|
|
49
|
+
common.UploadMediaGroup{Label: "张参考图片", Paths: opts.ImagePaths},
|
|
50
|
+
common.UploadMediaGroup{Label: "个参考视频", Paths: opts.VideoPaths},
|
|
51
|
+
common.UploadMediaGroup{Label: "段参考音频", Paths: opts.AudioPaths},
|
|
52
|
+
)
|
|
49
53
|
if err != nil {
|
|
50
|
-
return nil,
|
|
51
|
-
}
|
|
52
|
-
videoAssetIDs, err := uploadMediaList(ctx, opts.VideoPaths, runner)
|
|
53
|
-
if err != nil {
|
|
54
|
-
return nil, fmt.Errorf("上传视频失败: %w", err)
|
|
55
|
-
}
|
|
56
|
-
audioAssetIDs, err := uploadMediaList(ctx, opts.AudioPaths, runner)
|
|
57
|
-
if err != nil {
|
|
58
|
-
return nil, fmt.Errorf("上传音频失败: %w", err)
|
|
54
|
+
return nil, err
|
|
59
55
|
}
|
|
60
56
|
|
|
61
|
-
body := buildSubmitRunBody(opts,
|
|
57
|
+
body := buildSubmitRunBody(opts, assetIDs[0], assetIDs[1], assetIDs[2])
|
|
62
58
|
// Business attribution belongs to this command, regardless of the selected model.
|
|
63
59
|
return common.SubmitRunWithBabiParam(ctx, "generate-video", body, runner, map[string]string{
|
|
64
60
|
"scene_lv1": "ai_agent",
|
|
@@ -99,22 +95,6 @@ func validateMediaExtensions(kind string, paths []string, allowed map[string]str
|
|
|
99
95
|
return nil
|
|
100
96
|
}
|
|
101
97
|
|
|
102
|
-
func uploadMediaList(ctx context.Context, paths []string, runner *common.Runner) ([]string, error) {
|
|
103
|
-
assetIDs := make([]string, 0, len(paths))
|
|
104
|
-
for _, path := range paths {
|
|
105
|
-
expanded, err := common.ExpandPath(path)
|
|
106
|
-
if err != nil {
|
|
107
|
-
return nil, err
|
|
108
|
-
}
|
|
109
|
-
result, err := common.UploadFile(ctx, common.UploadFileOptions{Path: expanded}, runner)
|
|
110
|
-
if err != nil {
|
|
111
|
-
return nil, err
|
|
112
|
-
}
|
|
113
|
-
assetIDs = append(assetIDs, result.AssetID)
|
|
114
|
-
}
|
|
115
|
-
return assetIDs, nil
|
|
116
|
-
}
|
|
117
|
-
|
|
118
98
|
func buildSubmitRunBody(opts *Options, imageAssetIDs []string, videoAssetIDs []string, audioAssetIDs []string) map[string]any {
|
|
119
99
|
prompt := strings.TrimSpace(opts.Prompt)
|
|
120
100
|
param := common.VideoPartToolParam{
|
|
@@ -6,17 +6,21 @@ import (
|
|
|
6
6
|
"encoding/json"
|
|
7
7
|
"fmt"
|
|
8
8
|
"io"
|
|
9
|
+
"sync"
|
|
9
10
|
)
|
|
10
11
|
|
|
11
12
|
// Session is one command's immutable account/origin/team choice. It is not an
|
|
12
13
|
// authorization grant: every request still uses the current credential.
|
|
13
14
|
// Store is optional when a command explicitly selects its space.
|
|
15
|
+
// Concurrent requests share one selection; local records and diagnostics are
|
|
16
|
+
// serialized without holding the lock during network uploads.
|
|
14
17
|
type Session struct {
|
|
15
18
|
Store *Store
|
|
16
19
|
Explicit *string
|
|
17
20
|
Expected *Binding
|
|
18
21
|
Identity func(context.Context) (string, error)
|
|
19
22
|
Stderr io.Writer
|
|
23
|
+
mu sync.Mutex
|
|
20
24
|
selected *Selection
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -49,6 +53,9 @@ func (s *Session) CheckIdentity(ctx context.Context) error {
|
|
|
49
53
|
// Resolve selects and freezes the command's space, checking each request's
|
|
50
54
|
// records even after freezing. CheckIdentity belongs at the request boundary.
|
|
51
55
|
func (s *Session) Resolve(ref References) (Selection, error) {
|
|
56
|
+
s.mu.Lock()
|
|
57
|
+
defer s.mu.Unlock()
|
|
58
|
+
|
|
52
59
|
explicit := s.Explicit
|
|
53
60
|
fixed := s.selected
|
|
54
61
|
if s.Expected != nil {
|
|
@@ -129,25 +136,40 @@ func (s *Session) Resolve(ref References) (Selection, error) {
|
|
|
129
136
|
}
|
|
130
137
|
|
|
131
138
|
func (s *Session) TeamID() string {
|
|
132
|
-
if s == nil
|
|
139
|
+
if s == nil {
|
|
140
|
+
return "0"
|
|
141
|
+
}
|
|
142
|
+
s.mu.Lock()
|
|
143
|
+
defer s.mu.Unlock()
|
|
144
|
+
if s.selected == nil {
|
|
133
145
|
return "0"
|
|
134
146
|
}
|
|
135
147
|
return s.selected.TeamID
|
|
136
148
|
}
|
|
137
149
|
|
|
138
150
|
func (s *Session) Token() string {
|
|
139
|
-
if s == nil
|
|
151
|
+
if s == nil {
|
|
140
152
|
return ""
|
|
141
153
|
}
|
|
142
|
-
|
|
154
|
+
s.mu.Lock()
|
|
155
|
+
defer s.mu.Unlock()
|
|
156
|
+
if s.selected == nil || s.Store == nil {
|
|
157
|
+
return ""
|
|
158
|
+
}
|
|
159
|
+
data, _ := json.Marshal(Binding{Selection: Selection{TeamID: s.selected.TeamID}, Account: s.Store.Account, Origin: s.Store.Origin})
|
|
143
160
|
return base64.RawURLEncoding.EncodeToString(data)
|
|
144
161
|
}
|
|
145
162
|
|
|
146
163
|
func (s *Session) Remember(ref References) {
|
|
147
|
-
if s == nil
|
|
164
|
+
if s == nil {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
s.mu.Lock()
|
|
168
|
+
defer s.mu.Unlock()
|
|
169
|
+
if s.selected == nil || s.Store == nil || s.Store.Account == "" {
|
|
148
170
|
return
|
|
149
171
|
}
|
|
150
172
|
if err := s.Store.Remember(ref, *s.selected); err != nil && s.Stderr != nil {
|
|
151
|
-
fmt.Fprintf(s.Stderr, "请求已成功,但空间记录保存失败:%v;保留 thread_id=%s run_id=%s asset_ids=%v,后续使用 --team-id %s 查询,不要重复提交。\n", err, ref.ThreadID, ref.RunID, ref.AssetIDs, s.TeamID
|
|
173
|
+
fmt.Fprintf(s.Stderr, "请求已成功,但空间记录保存失败:%v;保留 thread_id=%s run_id=%s asset_ids=%v,后续使用 --team-id %s 查询,不要重复提交。\n", err, ref.ThreadID, ref.RunID, ref.AssetIDs, s.selected.TeamID)
|
|
152
174
|
}
|
|
153
175
|
}
|
package/package.json
CHANGED
|
@@ -58,6 +58,8 @@ metadata:
|
|
|
58
58
|
- 用户明确要求生成或处理,即可在该范围内执行;仅咨询用法、费用或方案时不提交。范围、必填信息或消耗 credits 的授权不明确时,先确认,不重复索要已给出的授权。
|
|
59
59
|
- 提问优先使用宿主实际提供且当前模式允许的工具:Codex 的 `request_user_input` 或 `request_user_input_async`,WorkBuddy 的 `ask_user_question`;不可用时用普通聊天。需要答案时等待答复。
|
|
60
60
|
- 素材参数接收本地文件路径,CLI 内部上传。远程链接不能冒充本地路径;缺少可访问文件时先解决素材获取。单文件必须小于 500 MB(500000000 字节)。
|
|
61
|
+
- `generate-image` / `generate-video` 上传素材失败时,向用户说明 CLI 返回的素材类型、类型内序号、文件名、原始原因和可用的 LogID,并说明尚未提交生成任务。序号按同类型参数的传入顺序从 1 开始,不是所有附件的混合序号;只有服务端明确返回审核拒绝时才说明审核未通过,不能把网络、权限等失败都归为审核问题。
|
|
62
|
+
- `generate-image` / `generate-video` 将所有参考素材按最多 10 个并发上传,等待本批上传结束后统一返回全部失败项;某个素材失败不会跳过其他素材。错误按图片、视频、音频分组及各自输入顺序列出,生成引用也保留输入顺序,不按上传完成先后排序。任一素材失败均不提交生成;向用户一次性列全失败素材,不只转述第一项。网络失败、取消或超时不代表审核通过。根据失败原因等待用户替换对应素材或解决问题,不自动删减素材、重传或重新提交生成。旧 CLI 如首错即停或未返回文件定位,须如实说明其结果范围,不凭图片内容猜测未检查素材的审核结论。
|
|
61
63
|
- 提交成功后立即展示真实 `web_thread_link`;未返回链接时如实说明,保留任务 ID。后续查询和下载失败不能触发重复生成。
|
|
62
64
|
- 每个最终图片/视频都通过宿主文件交付或媒体渲染能力展示为真实附件或可预览媒体。URL、路径列表仅作补充;详细完成标准见共用交付流程。
|
|
63
65
|
|
package/dist/checksums.txt
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
8bf338b7fc4bb3de289778896059f364f91743e7fe3127380e193f29bb10fecf pippit-tool-cli-1.0.44-darwin-amd64.tar.gz
|
|
2
|
-
fd7aac74fda167481b76e3a7aa2f30e45271670cad11f0e969080db1b135411a pippit-tool-cli-1.0.44-darwin-arm64.tar.gz
|
|
3
|
-
526e3641047d7e997fb5c271d795337692b0a47c092622747f6e2b4e9d22e473 pippit-tool-cli-1.0.44-linux-amd64.tar.gz
|
|
4
|
-
6170436a3ec60dc2a2681e6f790c6a52d9daa4248ce8c3eede83136a62ccb4b6 pippit-tool-cli-1.0.44-linux-arm64.tar.gz
|
|
5
|
-
d3c7166314bb52deae5e0caf383ac7e3cdde27c61325d6556de43f8f46aa9963 pippit-tool-cli-1.0.44-windows-amd64.zip
|
|
6
|
-
9cb3d470688cd89a42a2267d9d181159dd624937c9590d3b744db25a66edbac9 pippit-tool-cli-1.0.44-windows-arm64.zip
|