From 1d2869a0f5c2bc9c2228d202cf1261acce36856e Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Sun, 20 Sep 2026 12:19:51 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E7=94=9F=E6=88=90=E5=92=8C=E7=BB=93=E6=9E=9C=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- README.md | 29 ++- cmd/generate_audio/generate_audio.go | 70 ++++++++ cmd/generate_audio_test.go | 169 ++++++++++++++++++ cmd/generate_video/query_result.go | 9 +- cmd/query_audio_test.go | 168 +++++++++++++++++ cmd/root.go | 4 +- internal/generate_audio/generate_audio.go | 137 ++++++++++++++ internal/generate_video/query_result.go | 147 +++++++++++++-- internal/generate_video/query_result_test.go | 35 +++- scripts/install-cli.test.js | 4 +- scripts/skills.test.js | 1 + skills/xyq-nest-skill/SKILL.md | 7 +- .../xyq-nest-skill/commands/generate-audio.md | 38 ++++ .../xyq-nest-skill/commands/query-result.md | 9 +- skills/xyq-nest-skill/scripts/ensure-cli.js | 2 +- .../workflows/async-delivery.md | 4 +- 16 files changed, 799 insertions(+), 34 deletions(-) create mode 100644 cmd/generate_audio/generate_audio.go create mode 100644 cmd/generate_audio_test.go create mode 100644 cmd/query_audio_test.go create mode 100644 internal/generate_audio/generate_audio.go create mode 100644 skills/xyq-nest-skill/commands/generate-audio.md diff --git a/README.md b/README.md index 63c0145..3b3065a 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ | 技能 | 说明 | 路径 | |-------|-------------|------| | `xyq-short-drama-skill` | 短剧工作流技能,支持提交创作任务、上传参考文件、查询进度、列出会话文件和下载产物。 | `skills/short-drama/` | -| `xyq-skill` | 图片生成与参考图编辑、视频生成、视频超分与擦字幕、异步结果交付、个人 Canvas 编辑、积分查询及登录授权。 | `skills/xyq-nest-skill/` | +| `xyq-skill` | 图片生成与参考图编辑、音频和视频生成、视频超分与擦字幕、异步结果交付、个人 Canvas 编辑、积分查询及登录授权。 | `skills/xyq-nest-skill/` | ### 技能路由 -- 图片生成与参考图编辑、视频生成(含首尾帧和参考素材)、视频超分、擦字幕、结果查询、个人 Canvas 编辑、积分和授权由 `xyq-skill` 处理。 +- 图片生成与参考图编辑、音频生成、视频生成(含首尾帧和参考素材)、视频超分、擦字幕、结果查询、个人 Canvas 编辑、积分和授权由 `xyq-skill` 处理。 - 短剧生成、续写、改写、人物设定、分集创作和短剧会话文件处理使用 `xyq-short-drama-skill`。 需要补充、选择或确认时,使用宿主实际暴露且当前模式允许的工具:Codex 的 `request_user_input` / `request_user_input_async`、WorkBuddy 的 `ask_user_question`;不可用时用普通聊天。 @@ -27,6 +27,7 @@ | 登录授权 | `status` / `login` / `logout` | [授权](skills/xyq-nest-skill/commands/auth.md) | | 个人 Canvas 画布与节点编辑 | `canvas` | [画布](skills/xyq-nest-skill/commands/canvas.md) | | 生图、参考图编辑 | `generate-image` | [图片](skills/xyq-nest-skill/commands/generate-image.md) | +| 生成音频、参考音频或图片创作 | `generate-audio` | [音频](skills/xyq-nest-skill/commands/generate-audio.md) | | 生视频、首尾帧 | `generate-video` | [视频](skills/xyq-nest-skill/commands/generate-video.md) | | 视频超分 | `video-super-resolution` | [超分](skills/xyq-nest-skill/commands/video-super-resolution.md) | | 擦字幕 | `erase-video-subtitle` | [擦字幕](skills/xyq-nest-skill/commands/erase-video-subtitle.md) | @@ -209,6 +210,24 @@ pippit-tool-cli generate-image \ 图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`。CLI 会在提交前校验 prompt、model 必填、ratio 整数格式、generate-image-count 非负和文件后缀。 +## 生音频 CLI + +`generate-audio` 使用 Seed Audio 1.0,支持无参考生成、最多 3 个参考音频或 1 张参考图;两类参考不能混用。参考素材由 CLI 上传,成功后返回 `thread_id`、`run_id`、`web_thread_link`,再用 `query-result` 查询和下载。 + +```bash +pippit-tool-cli generate-audio \ + --prompt "用温暖自然的声音介绍今天的旅行" \ + --audio ./reference.wav \ + --format wav \ + --sample-rate 24000 +``` + +`--prompt` 必填,`--model` 默认且仅支持 `seedaudio_1.0`。`--audio` 可以重复,`--image` 至多使用一次。音频文件后缀支持 `.mp3/.wav/.m4a/.aac/.flac/.ogg/.opus`,图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`;实际素材可用性由服务端检查。 + +输出配置均可选:`--format` 支持 `mp3/wav/pcm/ogg_opus`,`--sample-rate` 为正整数,`--speech-rate`、`--loudness-rate`、`--pitch-rate` 为 Seed Audio 1.0 的有限数值,`--enable-timestamp` 请求时间戳。只发送显式指定的配置,具体范围由服务端决定;未设置时使用服务端默认值。当前不提供独立 `--text`、精确时长、分轨或翻配参数。 + +请求使用 `agent_name=pippit_audio_part_agent` 与 `audio_part_tool_param`,参考 ID 位于 `references[].pippit_asset_id`,不会混入图片或视频模型设置。 + ## 生视频 CLI `generate-video` 会上传本地参考图片、视频和音频,然后向视频片段 Agent 提交生视频请求: @@ -272,7 +291,7 @@ pippit-tool-cli erase-video-subtitle \ 两个命令都输出 `thread_id`、`run_id` 和 `web_thread_link`。拿到任务 ID 后,可继续使用 `query-result` 查询并下载结果。 -查询并下载生图/生视频结果: +查询并下载图片、视频或音频结果: ```bash pippit-tool-cli query-result \ @@ -281,7 +300,9 @@ pippit-tool-cli query-result \ --download-dir "./output" ``` -`query-result` 会查询指定 Run 并输出 JSON。Run 成功完成后下载视频和图片产物,`completed=true`,`videos` 和 `images` 中各包含 `download_url` 和 `output_path`;图片扩展名取自产物 `metadata.format`,缺省时兜底 `.png`。Run 失败也视为终态,`completed=true` 且填充 `error_message`;Run 未到终态时 `completed=false`。 +`query-result` 会查询指定 Run 并输出 JSON。Run 成功后下载视频、图片和音频产物,`completed=true`;`videos`、`images`、`audios` 各项包含 `download_url` 和 `output_path`。音频还保留可用的 `name`、`pippit_asset_id`、`duration`(秒)。音频扩展名从已知格式的元数据、URL 路径或名称中获取;无法判断时使用 `.audio`,不猜测编码。图片格式缺省时仍使用 `.png`。 + +Run 失败或取消均为终态,`completed=true` 且填充 `error_message`,保留服务端失败原因;尚未结束时 `completed=false`。无论退出码如何,都应先检查 `error_message`;下载失败后继续查询原任务,不重复提交生成。 ## HTTP 客户端 diff --git a/cmd/generate_audio/generate_audio.go b/cmd/generate_audio/generate_audio.go new file mode 100644 index 0000000..1f63869 --- /dev/null +++ b/cmd/generate_audio/generate_audio.go @@ -0,0 +1,70 @@ +package generate_audio + +import ( + "io" + "strings" + + "github.com/Pippit-dev/pippit-cli/internal/common" + internalgen "github.com/Pippit-dev/pippit-cli/internal/generate_audio" + "github.com/spf13/cobra" +) + +func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + opts := &internalgen.Options{} + var sampleRate int32 + var speechRate, loudnessRate, pitchRate float64 + var format string + var enableTimestamp bool + cmd := &cobra.Command{ + Use: "generate-audio", + Short: "Generate audio with Seed Audio 1.0", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + config := &internalgen.AudioConfig{} + changed := false + for _, setting := range []struct { + flag string + set func() + }{ + {"format", func() { config.Format = format }}, + {"sample-rate", func() { config.SampleRate = &sampleRate }}, + {"speech-rate", func() { config.SpeechRate = &speechRate }}, + {"loudness-rate", func() { config.LoudnessRate = &loudnessRate }}, + {"pitch-rate", func() { config.PitchRate = &pitchRate }}, + {"enable-timestamp", func() { config.EnableTimestamp = &enableTimestamp }}, + } { + if cmd.Flags().Changed(setting.flag) { + setting.set() + changed = true + } + } + opts.AudioConfig = nil + if changed { + opts.AudioConfig = config + } + result, err := internalgen.Run(cmd.Context(), opts, runner) + if err != nil { + _ = common.AppendDailyErrorLog("generate-audio", err, map[string]string{ + "prompt": strings.TrimSpace(opts.Prompt), + "model": strings.TrimSpace(opts.Model), + }) + return err + } + return common.WriteJSON(stdout, result) + }, + } + cmd.SetOut(stdout) + cmd.SetErr(stderr) + flags := cmd.Flags() + flags.StringVar(&opts.Prompt, "prompt", "", "audio generation prompt") + flags.StringVar(&opts.Model, "model", internalgen.DefaultModel, "audio model; supported: seedaudio_1.0") + flags.StringArrayVar(&opts.AudioPaths, "audio", nil, "local reference audio path; repeat up to 3 times; cannot combine with --image") + flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; at most one; cannot combine with --audio") + flags.StringVar(&format, "format", "", "output audio format: mp3, wav, pcm or ogg_opus; omitted uses the server default") + flags.Int32Var(&sampleRate, "sample-rate", 0, "output audio sample rate in Hz") + flags.Float64Var(&speechRate, "speech-rate", 0, "Seed Audio 1.0 speech rate; accepted range is validated by the server") + flags.Float64Var(&loudnessRate, "loudness-rate", 0, "Seed Audio 1.0 loudness rate; accepted range is validated by the server") + flags.Float64Var(&pitchRate, "pitch-rate", 0, "Seed Audio 1.0 pitch rate; accepted range is validated by the server") + flags.BoolVar(&enableTimestamp, "enable-timestamp", false, "request audio timestamps") + return cmd +} diff --git a/cmd/generate_audio_test.go b/cmd/generate_audio_test.go new file mode 100644 index 0000000..41a8f67 --- /dev/null +++ b/cmd/generate_audio_test.go @@ -0,0 +1,169 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestGenerateAudioRequest(t *testing.T) { + for _, tc := range []struct { + name, kind string + files []string + flags []string + config map[string]any + }{ + {name: "text only"}, + {name: "three audio references", kind: "audio", files: []string{"one.wav", "two.mp3", "three.wav"}, + flags: []string{"--format", "WAV", "--sample-rate", "24000", "--speech-rate", "1.2", "--loudness-rate", "0", "--pitch-rate", "-0.5", "--enable-timestamp=false"}, + config: map[string]any{"format": "wav", "sample_rate": float64(24000), "speech_rate": 1.2, "loudness_rate": float64(0), "pitch_rate": -0.5, "enable_timestamp": false}}, + {name: "one image reference", kind: "image", files: []string{"scene.png"}, flags: []string{"--format", "ogg_opus"}, config: map[string]any{"format": "ogg_opus"}}, + {name: "pcm", flags: []string{"--format", "pcm"}, config: map[string]any{"format": "pcm"}}, + {name: "mp3 timestamp", flags: []string{"--model", " seedaudio_1.0 ", "--format", "mp3", "--enable-timestamp"}, config: map[string]any{"format": "mp3", "enable_timestamp": true}}, + } { + t.Run(tc.name, func(t *testing.T) { + var uploads int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" || r.Method != http.MethodPost { + t.Errorf("unexpected request auth/method: %s %s", r.Method, r.Header.Get("Authorization")) + } + switch r.URL.Path { + case "/api/biz/v1/skill/upload_file": + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Errorf("parse multipart: %v", err) + return + } + defer r.MultipartForm.RemoveAll() + files := r.MultipartForm.File["file"] + if len(files) != 1 || uploads >= len(tc.files) || files[0].Filename != tc.files[uploads] { + t.Errorf("unexpected uploaded files: %#v", files) + return + } + file, err := files[0].Open() + if err != nil { + t.Errorf("open upload: %v", err) + return + } + data, err := io.ReadAll(file) + file.Close() + if err != nil || string(data) != "reference data" { + t.Errorf("upload contents = %q, err = %v", data, err) + } + uploads++ + fmt.Fprintf(w, `{"ret":"0","data":{"pippit_asset_id":"asset_%d"}}`, uploads) + case "/api/biz/v1/skill/submit_run": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode submit: %v", err) + return + } + if len(body) != 3 || body["agent_name"] != "pippit_audio_part_agent" || body["message"] != "温暖的旁白" { + t.Errorf("unexpected submit body: %#v", body) + } + param, ok := body["audio_part_tool_param"].(map[string]any) + if !ok || param["model"] != "seedaudio_1.0" || param["prompt"] != body["message"] { + t.Errorf("unexpected audio params: %#v", param) + } + if tc.config == nil { + if _, ok := param["audio_config"]; ok { + t.Errorf("unset audio_config must be omitted: %#v", param) + } + } else if !reflect.DeepEqual(param["audio_config"], tc.config) { + t.Errorf("config = %#v, want %#v", param["audio_config"], tc.config) + } + if _, ok := param["text"]; ok { + t.Errorf("unsupported text must be omitted: %#v", param) + } + refs, _ := param["references"].([]any) + if len(refs) != len(tc.files) || uploads != len(tc.files) { + t.Errorf("references=%#v, uploads=%d", refs, uploads) + } + for i, ref := range refs { + want := map[string]any{"type": tc.kind, "pippit_asset_id": fmt.Sprintf("asset_%d", i+1)} + if !reflect.DeepEqual(ref, want) { + t.Errorf("reference = %#v, want %#v", ref, want) + } + } + io.WriteString(w, `{"ret":"0","data":{"run":{"thread_id":"thread_123","run_id":"run_456"},"web_thread_link":"https://example.com/thread_123"}}`) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + args := []string{"generate-audio", "--prompt", " 温暖的旁白 "} + for _, name := range tc.files { + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte("reference data"), 0o600); err != nil { + t.Fatal(err) + } + args = append(args, "--"+tc.kind, path) + } + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs(append(args, tc.flags...)) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + got := decodeJSON(t, stdout.Bytes()) + if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" || got["web_thread_link"] != "https://example.com/thread_123" { + t.Fatalf("unexpected result: %#v", got) + } + }) + } +} + +func TestGenerateAudioRejectsInvalidInputsBeforeHTTP(t *testing.T) { + for _, tc := range []struct { + name, want string + args []string + }{ + {"prompt", "--prompt", []string{}}, + {"model", "--model 仅支持", []string{"--prompt", "x", "--model", "seedaudio_1.5"}}, + {"mixed", "不能混用", []string{"--prompt", "x", "--audio", "a.wav", "--image", "b.png"}}, + {"four audio", "最多支持", []string{"--prompt", "x", "--audio", "a.wav", "--audio", "b.wav", "--audio", "c.wav", "--audio", "d.wav"}}, + {"two images", "最多支持", []string{"--prompt", "x", "--image", "a.png", "--image", "b.png"}}, + {"extension", "不支持文件后缀", []string{"--prompt", "x", "--audio", "a.mp4"}}, + {"missing file", "上传文件不存在", []string{"--prompt", "x", "--audio", filepath.Join(t.TempDir(), "missing.wav")}}, + {"format", "--format", []string{"--prompt", "x", "--format", "exe"}}, + {"sample rate", "正整数", []string{"--prompt", "x", "--sample-rate", "0"}}, + {"nan", "有限数值", []string{"--prompt", "x", "--speech-rate", "NaN"}}, + {"infinity", "有限数值", []string{"--prompt", "x", "--pitch-rate", "+Inf"}}, + {"duration", "未知参数", []string{"--prompt", "x", "--duration", "5"}}, + {"text", "未知参数", []string{"--prompt", "x", "--text", "hello"}}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("invalid request reached HTTP: %s", r.URL.Path) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs(append([]string{"generate-audio"}, tc.args...)) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Execute error=%v, want %s", err, tc.want) + } + }) + } +} + +func TestGenerateAudioServerFailurePreservesLogID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"ret":"5","errmsg":"音频生成不可用","log_id":"audio_log_123"}`) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs([]string{"generate-audio", "--prompt", "x"}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "音频生成不可用") || !strings.Contains(err.Error(), "audio_log_123") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/cmd/generate_video/query_result.go b/cmd/generate_video/query_result.go index a5f0e27..a988f63 100644 --- a/cmd/generate_video/query_result.go +++ b/cmd/generate_video/query_result.go @@ -16,7 +16,7 @@ func NewQueryResultCommand(stdout, stderr io.Writer, runner *common.Runner) *cob cmd := &cobra.Command{ Use: "query-result", - Short: "Query a run result and download completed videos or images", + Short: "Query a run result and download completed audio, videos or images", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { result, err := internalgen.QueryResult(cmd.Context(), opts, runner) @@ -35,9 +35,9 @@ func NewQueryResultCommand(stdout, stderr io.Writer, runner *common.Runner) *cob } cmd.SetOut(stdout) cmd.SetErr(stderr) - cmd.Flags().StringVar(&opts.ThreadID, "thread-id", "", "thread_id from generate-video output") - cmd.Flags().StringVar(&opts.RunID, "run-id", "", "run_id from generate-video output") - cmd.Flags().StringVar(&opts.DownloadDir, "download-dir", "", "directory to download completed videos or images into") + cmd.Flags().StringVar(&opts.ThreadID, "thread-id", "", "thread_id from generation output") + cmd.Flags().StringVar(&opts.RunID, "run-id", "", "run_id from generation output") + cmd.Flags().StringVar(&opts.DownloadDir, "download-dir", "", "directory to download completed audio, videos or images into") return cmd } @@ -46,6 +46,7 @@ func queryResultFromError(err error, opts *internalgen.QueryResultOptions) *inte ErrorMessage: err.Error(), Videos: []internalgen.QueryResultVideo{}, Images: []internalgen.QueryResultImage{}, + Audios: []internalgen.QueryResultAudio{}, } if opts != nil { result.ThreadID = strings.TrimSpace(opts.ThreadID) diff --git a/cmd/query_audio_test.go b/cmd/query_audio_test.go new file mode 100644 index 0000000..104d947 --- /dev/null +++ b/cmd/query_audio_test.go @@ -0,0 +1,168 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestQueryResultDownloadsAudioAndMixedMedia(t *testing.T) { + for _, mixed := range []bool{false, true} { + name := "audio only" + if mixed { + name = "mixed media and string data" + } + t.Run(name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/biz/v1/skill/get_thread" { + if r.Method != http.MethodGet { + t.Errorf("download method=%s", r.Method) + } + io.WriteString(w, r.URL.Path) + return + } + if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("unexpected query method/auth") + } + part := func(kind string, value map[string]any) map[string]any { + var data any = map[string]any{kind: value} + if mixed { + encoded, _ := json.Marshal(data) + data = string(encoded) + } + return map[string]any{"sub_type": "biz/x_data_" + kind, "data": data} + } + content := []any{part("audio", map[string]any{ + "url": serverURL(r) + "/audio.wav?signature=secret", "name": "warm voice", "pippit_asset_id": "audio_123", + "metadata": map[string]any{"format": "audio/wav", "duration": 3.25}, + })} + if mixed { + content = append(content, + part("image", map[string]any{"url": serverURL(r) + "/poster.png", "asset_id": "poster", "metadata": map[string]any{"format": "png"}}), + part("video", map[string]any{"download_url": serverURL(r) + "/clip.mp4", "vid": "clip"}), + ) + } + writeAudioQueryFixture(w, map[string]any{"run_id": "run_456", "state": 3, "entry_list": []any{map[string]any{"artifact": map[string]any{"content": content}}}}) + })) + defer server.Close() + dir := t.TempDir() + got := runAudioQuery(t, server.URL, dir) + if got["completed"] != true || got["error_message"] != "" || got["thread_id"] != "thread_123" || got["run_id"] != "run_456" { + t.Fatalf("unexpected query result: %#v", got) + } + audios, ok := got["audios"].([]any) + if !ok || len(audios) != 1 { + t.Fatalf("audios=%#v", got["audios"]) + } + audio := audios[0].(map[string]any) + if audio["download_url"] != server.URL+"/audio.wav?signature=secret" || audio["name"] != "warm voice" || audio["pippit_asset_id"] != "audio_123" || audio["duration"] != 3.25 || audio["output_path"] != filepath.Join(dir, "audio_123.wav") { + t.Fatalf("unexpected audio: %#v", audio) + } + assertFileContent(t, audio["output_path"].(string), "/audio.wav") + for _, kind := range []string{"images", "videos"} { + media, ok := got[kind].([]any) + want := 0 + if mixed { + want = 1 + } + if !ok || len(media) != want { + t.Fatalf("%s=%#v", kind, got[kind]) + } + } + if mixed { + assertFileContent(t, filepath.Join(dir, "poster.png"), "/poster.png") + assertFileContent(t, filepath.Join(dir, "clip.mp4"), "/clip.mp4") + } + }) + } +} + +func TestQueryResultAudioFailureAndCancellation(t *testing.T) { + for _, tc := range []struct { + name, reason, fallback, want string + state int + completed bool + }{ + {name: "failed reason", state: 4, reason: "音频生成失败", want: "音频生成失败 (error_code=11001)", completed: true}, + {name: "fallback", state: 4, fallback: "积分不足", want: "积分不足 (error_code=11001)", completed: true}, + {name: "canceled", state: 5, want: "Run 已取消", completed: true}, + {name: "canceled reason", state: 5, reason: "用户取消生成", want: "用户取消生成 (error_code=11001)", completed: true}, + {name: "generating", state: 7}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/biz/v1/skill/get_thread" { + t.Errorf("non-success run requested a download: %s", r.URL.Path) + } + writeAudioQueryFixture(w, map[string]any{ + "run_id": "run_456", "state": tc.state, + "fail_reason": map[string]any{"message": tc.reason, "fallback_message": tc.fallback, "code": 11001}, + }) + })) + defer server.Close() + got := runAudioQuery(t, server.URL, t.TempDir()) + if got["completed"] != tc.completed || got["error_message"] != tc.want { + t.Fatalf("query result=%#v, want completed=%v, error=%s", got, tc.completed, tc.want) + } + if audios, ok := got["audios"].([]any); !ok || len(audios) != 0 { + t.Fatalf("audios=%#v", got["audios"]) + } + }) + } +} + +func TestQueryResultAudioDownloadErrorsKeepTaskIDs(t *testing.T) { + for _, tc := range []struct{ name, subtype, path, want string }{ + {"missing url", "biz/x_data_audio", "", "音频产物 url 为空"}, + {"wrong subtype", "text/plain", "/audio.wav", "未找到可下载的产物"}, + {"HTTP failure", "biz/x_data_audio", "/audio.wav", "下载失败"}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/biz/v1/skill/get_thread" { + http.Error(w, "unavailable", http.StatusForbidden) + return + } + audioURL := "" + if tc.path != "" { + audioURL = serverURL(r) + tc.path + } + writeAudioQueryFixture(w, map[string]any{"run_id": "run_456", "state": 3, "entry_list": []any{map[string]any{ + "artifact": map[string]any{"content": []any{map[string]any{ + "sub_type": tc.subtype, "data": map[string]any{"audio": map[string]any{"url": audioURL}}, + }}}, + }}}) + })) + defer server.Close() + got := runAudioQuery(t, server.URL, t.TempDir()) + if !strings.Contains(got["error_message"].(string), tc.want) || got["thread_id"] != "thread_123" || got["run_id"] != "run_456" { + t.Fatalf("unexpected result: %#v", got) + } + if audios, ok := got["audios"].([]any); !ok || len(audios) != 0 { + t.Fatalf("audios=%#v", got["audios"]) + } + }) + } +} + +func runAudioQuery(t *testing.T, baseURL, dir string) map[string]any { + t.Helper() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, baseURL) + root.SetArgs([]string{"query-result", "--thread-id", "thread_123", "--run-id", "run_456", "--download-dir", dir}) + if err := root.Execute(); err != nil { + t.Fatalf("query Execute: %v", err) + } + return decodeJSON(t, stdout.Bytes()) +} + +func writeAudioQueryFixture(w http.ResponseWriter, run map[string]any) { + json.NewEncoder(w).Encode(map[string]any{"ret": "0", "data": map[string]any{ + "thread": map[string]any{"thread_id": "thread_123", "run_list": []any{run}}, + }}) +} diff --git a/cmd/root.go b/cmd/root.go index d93309f..8b25c98 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( authcmd "github.com/Pippit-dev/pippit-cli/cmd/auth" canvascmd "github.com/Pippit-dev/pippit-cli/cmd/canvas" + "github.com/Pippit-dev/pippit-cli/cmd/generate_audio" "github.com/Pippit-dev/pippit-cli/cmd/generate_image" "github.com/Pippit-dev/pippit-cli/cmd/generate_video" "github.com/Pippit-dev/pippit-cli/cmd/short_drama" @@ -56,7 +57,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm root := &cobra.Command{ Use: "pippit-tool-cli", Short: "Pippit CLI", - Long: "Pippit CLI generates and processes videos and images, queries credit balances, submits creative conversations and short-drama workflows, downloads generated assets, and updates the installed CLI package.\n\n" + + Long: "Pippit CLI generates audio, videos and images, processes videos, queries credit balances, submits creative conversations and short-drama workflows, downloads generated assets, and updates the installed CLI package.\n\n" + canvascmd.CommandDiscoveryHelp + "\n\nInstallation help (npm launcher): pippit-tool-cli install --help", Version: version.Current(), SilenceUsage: true, @@ -77,6 +78,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm root.AddCommand(newUploadFileCommand(stdout, stderr, runner)) root.AddCommand(newListThreadFileCommand(stdout, stderr, runner)) root.AddCommand(generate_image.NewCommand(stdout, stderr, runner)) + root.AddCommand(generate_audio.NewCommand(stdout, stderr, runner)) root.AddCommand(generate_video.NewCommand(stdout, stderr, runner)) root.AddCommand(generate_video.NewQueryResultCommand(stdout, stderr, runner)) root.AddCommand(video_tool.NewSuperResolutionCommand(stdout, stderr, runner)) diff --git a/internal/generate_audio/generate_audio.go b/internal/generate_audio/generate_audio.go new file mode 100644 index 0000000..e9e1c72 --- /dev/null +++ b/internal/generate_audio/generate_audio.go @@ -0,0 +1,137 @@ +package generate_audio + +import ( + "context" + "fmt" + "math" + "path/filepath" + "strings" + + "github.com/Pippit-dev/pippit-cli/internal/common" +) + +const DefaultModel = "seedaudio_1.0" + +// Options describes a Seed Audio 1.0 request. Unset output settings use server defaults. +type Options struct { + Prompt string + Model string + AudioPaths []string + ImagePaths []string + AudioConfig *AudioConfig +} + +type AudioConfig struct { + Format string `json:"format,omitempty"` + SampleRate *int32 `json:"sample_rate,omitempty"` + SpeechRate *float64 `json:"speech_rate,omitempty"` + LoudnessRate *float64 `json:"loudness_rate,omitempty"` + PitchRate *float64 `json:"pitch_rate,omitempty"` + EnableTimestamp *bool `json:"enable_timestamp,omitempty"` +} + +type audioReference struct { + Type string `json:"type"` + PippitAssetID string `json:"pippit_asset_id"` +} + +type audioPartToolParam struct { + Prompt string `json:"prompt"` + Model string `json:"model"` + AudioConfig *AudioConfig `json:"audio_config,omitempty"` + References []audioReference `json:"references,omitempty"` +} + +func Run(ctx context.Context, opts *Options, runner *common.Runner) (*common.SubmitRunResult, error) { + if runner == nil || runner.Client == nil { + return nil, fmt.Errorf("generate-audio 运行器客户端缺失") + } + if err := ValidateOptions(opts); err != nil { + return nil, err + } + refs := make([]audioReference, 0, len(opts.AudioPaths)+len(opts.ImagePaths)) + for _, group := range []struct { + kind string + paths []string + }{{"audio", opts.AudioPaths}, {"image", opts.ImagePaths}} { + for _, path := range group.paths { + expanded, err := common.ExpandPath(path) + if err != nil { + return nil, err + } + upload, err := common.UploadFile(ctx, common.UploadFileOptions{Path: expanded}, runner) + if err != nil { + return nil, fmt.Errorf("上传音频生成参考素材失败: %w", err) + } + refs = append(refs, audioReference{Type: group.kind, PippitAssetID: upload.AssetID}) + } + } + model := strings.TrimSpace(opts.Model) + if model == "" { + model = DefaultModel + } + var config *AudioConfig + if opts.AudioConfig != nil { + value := *opts.AudioConfig + value.Format = strings.ToLower(strings.TrimSpace(value.Format)) + config = &value + } + return common.SubmitRun(ctx, "generate-audio", map[string]any{ + "agent_name": "pippit_audio_part_agent", + "message": strings.TrimSpace(opts.Prompt), + "audio_part_tool_param": audioPartToolParam{ + Prompt: strings.TrimSpace(opts.Prompt), Model: model, + AudioConfig: config, References: refs, + }, + }, runner) +} + +func ValidateOptions(opts *Options) error { + if opts == nil || strings.TrimSpace(opts.Prompt) == "" { + return fmt.Errorf("缺少必填参数 --prompt") + } + if model := strings.TrimSpace(opts.Model); model != "" && model != DefaultModel { + return fmt.Errorf("--model 仅支持 %s", DefaultModel) + } + if len(opts.AudioPaths) > 0 && len(opts.ImagePaths) > 0 { + return fmt.Errorf("--audio 与 --image 不能混用") + } + if len(opts.AudioPaths) > 3 || len(opts.ImagePaths) > 1 { + return fmt.Errorf("最多支持 3 个参考音频或 1 张参考图片") + } + for _, group := range []struct { + flag string + paths []string + allowed []string + }{ + {"--audio", opts.AudioPaths, []string{".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus"}}, + {"--image", opts.ImagePaths, []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"}}, + } { + allowed := common.StringSet(group.allowed) + for _, path := range group.paths { + ext := strings.ToLower(filepath.Ext(strings.TrimSpace(path))) + if _, ok := allowed[ext]; !ok { + return fmt.Errorf("%s 不支持文件后缀 %q;支持:%s", group.flag, ext, strings.Join(group.allowed, ", ")) + } + } + } + if config := opts.AudioConfig; config != nil { + switch strings.ToLower(strings.TrimSpace(config.Format)) { + case "", "mp3", "wav", "pcm", "ogg_opus": + default: + return fmt.Errorf("--format 仅支持 mp3、wav、pcm 或 ogg_opus") + } + if config.SampleRate != nil && *config.SampleRate <= 0 { + return fmt.Errorf("--sample-rate 必须为正整数") + } + for _, setting := range []struct { + flag string + value *float64 + }{{"--speech-rate", config.SpeechRate}, {"--loudness-rate", config.LoudnessRate}, {"--pitch-rate", config.PitchRate}} { + if setting.value != nil && (math.IsNaN(*setting.value) || math.IsInf(*setting.value, 0)) { + return fmt.Errorf("%s 必须为有限数值", setting.flag) + } + } + } + return nil +} diff --git a/internal/generate_video/query_result.go b/internal/generate_video/query_result.go index cf18302..c6511d2 100644 --- a/internal/generate_video/query_result.go +++ b/internal/generate_video/query_result.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "path/filepath" "strconv" "strings" @@ -14,8 +15,9 @@ import ( ) const ( - successRunState = 3 - failedRunState = 4 + successRunState = 3 + failedRunState = 4 + canceledRunState = 5 ) // QueryResultOptions is the command-facing request shape for query-result. @@ -33,6 +35,16 @@ type QueryResultResult struct { ErrorMessage string `json:"error_message"` Videos []QueryResultVideo `json:"videos"` Images []QueryResultImage `json:"images"` + Audios []QueryResultAudio `json:"audios"` +} + +// QueryResultAudio describes a downloaded audio. Duration is in seconds, when available. +type QueryResultAudio struct { + DownloadURL string `json:"download_url"` + OutputPath string `json:"output_path"` + Name string `json:"name,omitempty"` + PippitAssetID string `json:"pippit_asset_id,omitempty"` + Duration *float64 `json:"duration,omitempty"` } // QueryResultVideo describes a downloaded video from query-result. @@ -53,12 +65,19 @@ type queryThread struct { } type queryRun struct { - RunID string `json:"run_id"` - State int `json:"state"` - ErrorMessage string `json:"error_message"` - ErrorMsg string `json:"error_msg"` - Errmsg string `json:"errmsg"` - EntryList []queryEntry `json:"entry_list"` + RunID string `json:"run_id"` + State int `json:"state"` + ErrorMessage string `json:"error_message"` + ErrorMsg string `json:"error_msg"` + Errmsg string `json:"errmsg"` + FailReason queryFailReason `json:"fail_reason"` + EntryList []queryEntry `json:"entry_list"` +} + +type queryFailReason struct { + Message string `json:"message"` + FallbackMessage string `json:"fallback_message"` + Code json.RawMessage `json:"code"` } type queryEntry struct { @@ -77,6 +96,7 @@ type queryContent struct { type queryContentData struct { Video *queryVideo `json:"video"` Image *queryImage `json:"image"` + Audio *queryAudio `json:"audio"` ErrorMessage string `json:"error_message"` ErrorCode json.RawMessage `json:"error_code"` } @@ -98,6 +118,18 @@ type queryImageMeta struct { Format string `json:"format"` } +type queryAudio struct { + DownloadURL string `json:"url"` + Name string `json:"name"` + PippitAssetID string `json:"pippit_asset_id"` + Metadata queryAudioMeta `json:"metadata"` +} + +type queryAudioMeta struct { + Format string `json:"format"` + Duration *float64 `json:"duration"` +} + func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.Runner) (*QueryResultResult, error) { if err := validateQueryResultOptions(opts); err != nil { return nil, err @@ -125,21 +157,25 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R } if run.State != successRunState { result := &QueryResultResult{ - Completed: run.State == failedRunState, + Completed: run.State == failedRunState || run.State == canceledRunState, ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID), RunID: opts.RunID, Videos: []QueryResultVideo{}, Images: []QueryResultImage{}, + Audios: []QueryResultAudio{}, } if run.State == failedRunState { result.ErrorMessage = firstNonEmpty(extractQueryErrorMessage(run), "Run 失败") + } else if run.State == canceledRunState { + result.ErrorMessage = firstNonEmpty(extractQueryErrorMessage(run), "Run 已取消") } return result, nil } videos := extractQueryVideos(run) images := extractQueryImages(run) - if len(videos) == 0 && len(images) == 0 { + audios := extractQueryAudios(run) + if len(videos) == 0 && len(images) == 0 && len(audios) == 0 { return nil, fmt.Errorf("下载失败:未找到可下载的产物") } @@ -148,7 +184,7 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R return nil, fmt.Errorf("下载失败:解析下载目录失败:%w", err) } - usedNames := make(map[string]int, len(videos)+len(images)) + usedNames := make(map[string]int, len(videos)+len(images)+len(audios)) resultVideos := make([]QueryResultVideo, 0, len(videos)) for i, video := range videos { @@ -202,12 +238,30 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R }) } + resultAudios := make([]QueryResultAudio, 0, len(audios)) + for i, audio := range audios { + if strings.TrimSpace(audio.DownloadURL) == "" { + return nil, fmt.Errorf("下载失败:第 %d 个音频产物 url 为空", i+1) + } + outputPath := filepath.Join(downloadDir, uniqueQueryResultFileName(audioFileName(audio, i+1), usedNames)) + if _, err := common.DownloadResult(ctx, common.DownloadResultOptions{ + URL: audio.DownloadURL, OutputPath: outputPath, + }, runner); err != nil { + return nil, fmt.Errorf("下载失败:%w", err) + } + resultAudios = append(resultAudios, QueryResultAudio{ + DownloadURL: audio.DownloadURL, OutputPath: outputPath, Name: audio.Name, + PippitAssetID: audio.PippitAssetID, Duration: audio.Metadata.Duration, + }) + } + return &QueryResultResult{ Completed: true, ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID), RunID: opts.RunID, Videos: resultVideos, Images: resultImages, + Audios: resultAudios, }, nil } @@ -230,6 +284,7 @@ func queryResultFromGetThreadBusinessError(err error, opts *QueryResultOptions) ErrorMessage: message, Videos: []QueryResultVideo{}, Images: []QueryResultImage{}, + Audios: []QueryResultAudio{}, }, true } @@ -354,10 +409,28 @@ func extractQueryImages(run queryRun) []queryImage { return images } +func extractQueryAudios(run queryRun) []queryAudio { + audios := make([]queryAudio, 0) + for _, entry := range run.EntryList { + for _, content := range entry.Artifact.Content { + if content.SubType == "biz/x_data_audio" && content.Data.Audio != nil { + audios = append(audios, *content.Data.Audio) + } + } + } + return audios +} + func extractQueryErrorMessage(run queryRun) string { if message := firstNonEmpty(run.ErrorMessage, run.ErrorMsg, run.Errmsg); message != "" { return message } + if message := firstNonEmpty(run.FailReason.Message, run.FailReason.FallbackMessage); message != "" { + if code := rawMessageString(run.FailReason.Code); code != "" && code != "0" { + return fmt.Sprintf("%s (error_code=%s)", message, code) + } + return message + } for _, entry := range run.EntryList { for _, content := range entry.Artifact.Content { data := content.Data @@ -420,6 +493,46 @@ func imageFileName(image queryImage, index int) string { return name } +func audioFileName(audio queryAudio, index int) string { + name := firstNonEmpty(audio.PippitAssetID, audio.Name, "audio_"+strconv.Itoa(index)) + name = sanitizeFileName(name) + ext := normalizeAudioFormatExt(audio.Metadata.Format) + if ext == "" { + if parsed, err := url.Parse(audio.DownloadURL); err == nil { + ext = normalizeAudioFormatExt(filepath.Ext(parsed.Path)) + } + } + if ext == "" { + ext = normalizeAudioFormatExt(filepath.Ext(audio.Name)) + } + if ext == "" { + ext = "audio" + } + if normalizeAudioFormatExt(filepath.Ext(name)) != "" { + name = strings.TrimSuffix(name, filepath.Ext(name)) + } + return name + "." + ext +} + +// Only known audio formats can become a file extension; URL queries are never used. +func normalizeAudioFormatExt(format string) string { + format = strings.ToLower(strings.TrimSpace(format)) + format = strings.TrimPrefix(format, "audio/") + format = strings.TrimPrefix(format, ".") + switch format { + case "mpeg": + return "mp3" + case "x-wav", "wave": + return "wav" + case "ogg_opus": + return "ogg" + case "mp3", "wav", "pcm", "m4a", "aac", "flac", "ogg", "opus": + return format + default: + return "" + } +} + // normalizeImageFormatExt maps the server-provided metadata.format to a safe // file extension. Only a known allowlist is accepted; anything else (including // "image/jpeg", ".jpeg", or empty values) falls back to "png". @@ -469,11 +582,19 @@ func sanitizeFileName(name string) string { func uniqueQueryResultFileName(name string, used map[string]int) string { count := used[name] + 1 - used[name] = count if count == 1 { + used[name] = count return name } ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) - return fmt.Sprintf("%s-%d%s", base, count, ext) + for { + candidate := fmt.Sprintf("%s-%d%s", base, count, ext) + if used[candidate] == 0 { + used[name] = count + used[candidate] = 1 + return candidate + } + count++ + } } diff --git a/internal/generate_video/query_result_test.go b/internal/generate_video/query_result_test.go index 896f036..01b0d35 100644 --- a/internal/generate_video/query_result_test.go +++ b/internal/generate_video/query_result_test.go @@ -1,6 +1,39 @@ package generate_video -import "testing" +import ( + "path/filepath" + "testing" +) + +func TestAudioFileNameUsesOnlySafeExtensions(t *testing.T) { + for _, tc := range []struct { + name, id, format, url, want string + }{ + {"voice", "asset_1", "audio/wav", "https://x/audio.mp3?sign=secret", "asset_1.wav"}, + {"voice", "asset_1", "", "https://x/audio.MP3?sign=secret", "asset_1.mp3"}, + {"voice.wav", "", "audio/mpeg", "", "voice.mp3"}, + {"voice.wav", "", "", "https://x/no-extension", "voice.wav"}, + {"voice", "", "ogg_opus", "", "voice.ogg"}, + {"voice", "", "pcm", "", "voice.pcm"}, + {"../voice\\bad", "", "../../evil.exe", "https://x/a.exe", ".._voice_bad.audio"}, + {"", "", "", "https://x/a?ext=.mp3", "audio_2.audio"}, + } { + got := audioFileName(queryAudio{Name: tc.name, PippitAssetID: tc.id, DownloadURL: tc.url, Metadata: queryAudioMeta{Format: tc.format}}, 2) + if got != tc.want || filepath.Base(got) != got { + t.Errorf("audioFileName(%#v) = %q, want %q", tc, got, tc.want) + } + } +} + +func TestQueryResultFileNamesDoNotReuseAllocatedSuffixes(t *testing.T) { + used := map[string]int{} + want := []string{"voice.wav", "voice-2.wav", "voice-2-2.wav", "voice-3.wav"} + for i, name := range []string{"voice.wav", "voice.wav", "voice-2.wav", "voice.wav"} { + if got := uniqueQueryResultFileName(name, used); got != want[i] { + t.Fatalf("filename %d = %q, want %q", i, got, want[i]) + } + } +} func TestVideoFileNameUsesVIDBeforeTimestampTitle(t *testing.T) { got := videoFileName(queryVideo{ diff --git a/scripts/install-cli.test.js b/scripts/install-cli.test.js index 7a4b21d..0a499da 100644 --- a/scripts/install-cli.test.js +++ b/scripts/install-cli.test.js @@ -182,7 +182,7 @@ function checkBootstrap() { assert.strictEqual(result.cli_path, JSON.parse(next.output[0]).cli_path); assert.strictEqual(fixture.dirs.length, 1, "Subsequent invocations must reuse the cached CLI"); for (const command of ["status", "login", "logout", "query-result", - "generate-image", "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance"]) { + "generate-image", "generate-audio", "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance"]) { assert.strictEqual(fixture.calls.filter((call) => call.args[0] === command).length, 2); } assert.strictEqual(fixture.calls.filter((call) => call.args[0].endsWith("install-cli.js")).length, 1); @@ -213,7 +213,7 @@ function checkBootstrap() { } // Every retained command participates in compatibility checks and cache reuse. for (const missingCommand of ["status", "login", "logout", "query-result", "generate-image", - "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance"]) { + "generate-audio", "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance"]) { const fixture = bootstrapFixture({ missingCommand }); const existing = path.join(fixture.npmDir, "pippit-tool-cli"); fs.writeFileSync(existing, "missing-command"); diff --git a/scripts/skills.test.js b/scripts/skills.test.js index 2020b8d..f4dc6cd 100644 --- a/scripts/skills.test.js +++ b/scripts/skills.test.js @@ -56,6 +56,7 @@ const commandModules = { auth: ["status", "login", "logout"], canvas: ["canvas"], "generate-image": ["generate-image"], + "generate-audio": ["generate-audio"], "generate-video": ["generate-video"], "video-super-resolution": ["video-super-resolution"], "erase-video-subtitle": ["erase-video-subtitle"], diff --git a/skills/xyq-nest-skill/SKILL.md b/skills/xyq-nest-skill/SKILL.md index d14e508..4a03127 100644 --- a/skills/xyq-nest-skill/SKILL.md +++ b/skills/xyq-nest-skill/SKILL.md @@ -1,6 +1,6 @@ --- name: xyq-skill -description: 使用小云雀 pippit-tool-cli 生成或编辑图片、生成视频、超分和擦字幕,查询结果并交付媒体;操作小云雀个人 Canvas 画布、节点、布局、连线、角色/场景、生成提示词、3D 导演台与多轨草稿;查询积分及管理授权。用户提到小云雀、xyq 并需要这些操作时使用。 +description: 使用小云雀 pippit-tool-cli 生成或编辑图片、生成音频和视频、超分和擦字幕,查询结果并交付媒体;操作小云雀个人 Canvas 画布、节点、布局、连线、角色/场景、生成提示词、3D 导演台与多轨草稿;查询积分及管理授权。用户提到小云雀、xyq 并需要这些操作时使用。 user-invocable: true metadata: {"openclaw": {"emoji": "💬", "requires": {"bins": ["node"]}}} @@ -27,13 +27,14 @@ metadata: | 查看登录状态、登录、退出或切换账号 | `status` / `login` / `logout` | [授权](commands/auth.md) | | 创建或查询小云雀个人画布,编辑节点、布局、连线、角色/场景、提示词、3D 或多轨草稿 | `canvas` | [Canvas 能力与命令发现](commands/canvas.md) | | 生成图片,或基于参考图修改图片 | `generate-image` | [生图与图片编辑](commands/generate-image.md) | +| 生成音频,使用参考音频或参考图创作音频 | `generate-audio` | [生音频](commands/generate-audio.md) | | 生成视频,使用图/视频/音频参考,首尾帧生视频 | `generate-video` | [生视频](commands/generate-video.md) | | 提升已有视频分辨率、视频超分 | `video-super-resolution` | [超分](commands/video-super-resolution.md) | | 去除已有视频字幕 | `erase-video-subtitle` | [擦字幕](commands/erase-video-subtitle.md) | | 查询已有任务进度、下载生成结果 | `query-result` | [查询结果](commands/query-result.md) | | 查询个人积分余额、剩余 credits | `get-credit-balance` | [积分](commands/get-credit-balance.md) | -- 普通生图、生视频也走对应生成命令,无需用户额外声明“模型直出”。 +- 普通生图、生音频、生视频也走对应生成命令,无需用户额外声明“模型直出”。 - 明确要求修改现有画布或其中节点时优先走 Canvas;普通生图、生视频不自动创建画布。“修改节点提示词”只修改配置,不隐含生成;指定节点生成或导出须先确认当前命令目录有对应能力。 - “参考这个视频生成新的”走生视频;“把这个视频变清晰”走超分。意图不清时先问清。 - 同时提出多个明确操作时,分别选模块;有输入依赖则顺序执行。仅在用户请求包含多个步骤时组合,不自动增加收费处理。 @@ -46,7 +47,7 @@ metadata: - 提问优先使用宿主实际提供且当前模式允许的工具:Codex 的 `request_user_input` 或 `request_user_input_async`,WorkBuddy 的 `ask_user_question`;不可用时用普通聊天。需要答案时等待答复。 - 素材参数接收本地文件路径,CLI 内部上传。远程链接不能冒充本地路径;缺少可访问文件时先解决素材获取。单文件必须小于 500 MB(500000000 字节)。 - 提交成功后立即展示真实 `web_thread_link`;未返回链接时如实说明,保留任务 ID。后续查询和下载失败不能触发重复生成。 -- 每个最终图片/视频都通过宿主文件交付或媒体渲染能力展示为真实附件或可预览媒体。URL、路径列表仅作补充;详细完成标准见共用交付流程。 +- 每个最终图片、视频或音频都通过宿主文件交付或媒体渲染能力展示为真实附件或可预览媒体。URL、路径列表仅作补充;详细完成标准见共用交付流程。 ## 按需参考的完整场景 diff --git a/skills/xyq-nest-skill/commands/generate-audio.md b/skills/xyq-nest-skill/commands/generate-audio.md new file mode 100644 index 0000000..87e059d --- /dev/null +++ b/skills/xyq-nest-skill/commands/generate-audio.md @@ -0,0 +1,38 @@ +# generate-audio:生成音频 + +使用 Seed Audio 1.0 生成音频,可以提供参考音频或参考图。用户想给视频增加背景音乐时,先确定要生成音频素材还是编辑现有视频;本命令仅返回音频任务,不自动合成视频。 + +## 输入与参数 + +| 参数 | 必填 | 规则 | +| --- | --- | --- | +| `--prompt` | 是 | 用户原始描述,不能全为空白;在这里完整描述生成内容 | +| `--model` | 否 | 默认且仅支持 `seedaudio_1.0` | +| `--audio` | 否 | 本地参考音频路径,最多重复 3 次,保持顺序;不能与 `--image` 混用 | +| `--image` | 否 | 本地参考图路径,至多 1 张;不能与 `--audio` 混用 | +| `--format` | 否 | `mp3`、`wav`、`pcm`、`ogg_opus` | +| `--sample-rate` | 否 | 采样率,单位 Hz,正整数 | +| `--speech-rate` / `--loudness-rate` / `--pitch-rate` | 否 | Seed Audio 1.0 的语速、响度、音调参数,必须为有限数值,具体范围由服务端决定 | +| `--enable-timestamp` | 否 | 请求时间戳;显式关闭可用 `--enable-timestamp=false` | + +只传用户指定的可选配置;缺省交给服务端。不能套用新模型的参数范围,不提供独立 `--text`、精确时长、分轨、翻配或其他音频模型能力,也不通过填入图片、视频模型来提交音频。 + +音频文件后缀支持 `.mp3/.wav/.m4a/.aac/.flac/.ogg/.opus`,图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`。素材由 CLI 上传,实际可用性由服务端检查。远程 URL 不能替代本地路径。 + +## 最小调用 + +```bash +pippit-tool-cli generate-audio --prompt "用户原始描述" +``` + +如果用户提供了参考音频、输出格式与采样率: + +```bash +pippit-tool-cli generate-audio --prompt "用户原始描述" --audio "./reference.wav" --format wav --sample-rate 24000 +``` + +## 返回与处理 + +成功返回 JSON 中的 `thread_id`、`run_id`、`web_thread_link`,随后执行 [异步结果与媒体交付](../workflows/async-delivery.md)。最终音频位于查询结果的 `audios[]`,逐项交付 `output_path` 对应文件。请求时间戳不保证查询命令返回独立字幕文件。 + +模型不支持、引用非法、鉴权或生成失败时说明真实错误,不改成视频请求、不自动切换模型、不重复提交未知结果的任务。 diff --git a/skills/xyq-nest-skill/commands/query-result.md b/skills/xyq-nest-skill/commands/query-result.md index 085268b..6be335f 100644 --- a/skills/xyq-nest-skill/commands/query-result.md +++ b/skills/xyq-nest-skill/commands/query-result.md @@ -23,7 +23,7 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回 | `completed` | 是否结束;失败时也可能为 `true`,不等于成功 | | `error_message` | 非空即错误,不能因为 `completed=false` 而忽略 | | `thread_id` / `run_id` | 对应的查询任务 | -| `images[]` / `videos[]` | 成功后获取的媒体,每项含 `download_url`、`output_path` | +| `images[]` / `videos[]` / `audios[]` | 成功后获取的媒体,每项含 `download_url`、`output_path`;音频另含可用的 `name`、`pippit_asset_id`、`duration`(秒) | 成功示例(ID、URL 和文件名仅为示意): @@ -34,14 +34,17 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回 "run_id": "RUN_ID", "error_message": "", "images": [{"download_url": "https://example.com/image.jpeg", "output_path": "./xyq_output/asset.jpeg"}], - "videos": [] + "videos": [], + "audios": [] } ``` -任务尚未完成时通常返回 `completed=false`、空错误、空媒体数组。命令不提供完整会话消息、用户反问或可区分的所有状态;不能仅凭这个响应断言具体进度、取消状态或等待用户输入。轮询停止条件见 [共用流程](../workflows/async-delivery.md)。 +任务尚未完成时通常返回 `completed=false`、空错误、空媒体数组。失败或取消返回 `completed=true` 和非空错误。命令不提供完整会话消息、用户反问或可区分的所有状态;不能仅凭这个响应断言具体进度或等待用户输入。轮询停止条件见 [共用流程](../workflows/async-delivery.md)。 ## 下载行为 文件名由 CLI 根据产物信息生成,以 `output_path` 为准,不自行拼接编号或推测扩展名。同目录已有同名文件可能被复用;复用不证明内容相同,也不代表本次新下载。发现同名文件属于其他产物时,选择用户认可范围内的未冲突目录再查询,不删除已有文件。 +音频扩展名只采用已知格式,依次参考元数据、URL 路径、音频名称;无法判断编码时使用 `.audio`。不要仅改后缀就声称完成转码,也不能把 `duration` 当作支持精确时长控制的证据。 + 找不到产物、链接缺失或下载失败时会返回错误。当前任一文件下载失败可能使整次查询只返回错误,无法据此认定其他文件都没下载或已完整交付。复查本次结果,不把输出目录里的任意旧文件当作本次产物。 diff --git a/skills/xyq-nest-skill/scripts/ensure-cli.js b/skills/xyq-nest-skill/scripts/ensure-cli.js index 7a620a1..4ead295 100644 --- a/skills/xyq-nest-skill/scripts/ensure-cli.js +++ b/skills/xyq-nest-skill/scripts/ensure-cli.js @@ -8,7 +8,7 @@ const path = require("path"); const REQUIRED_COMMANDS = [ "status", "login", "logout", "query-result", - "generate-image", "generate-video", "video-super-resolution", + "generate-image", "generate-audio", "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance", ]; diff --git a/skills/xyq-nest-skill/workflows/async-delivery.md b/skills/xyq-nest-skill/workflows/async-delivery.md index 87c8190..00fe90e 100644 --- a/skills/xyq-nest-skill/workflows/async-delivery.md +++ b/skills/xyq-nest-skill/workflows/async-delivery.md @@ -20,8 +20,8 @@ ## 媒体交付完成标准 -- 对 `images[]`、`videos[]` 中每个待交付文件确认本地存在且非空;同名旧文件只有明确对应本次产物时才能复用,不能计为本次新下载。 -- 使用宿主实际提供的文件交付工具,逐项展示真实图片/视频附件。宿主支持内置媒体渲染时按其规定引用文件,例如要求绝对路径时,先解析 `output_path` 为绝对路径。 +- 对 `images[]`、`videos[]`、`audios[]` 中每个待交付文件确认本地存在且非空;同名旧文件只有明确对应本次产物时才能复用,不能计为本次新下载。 +- 使用宿主实际提供的文件交付工具,逐项展示真实图片、视频或音频附件。宿主支持内置媒体渲染时按其规定引用文件,例如要求绝对路径时,先解析 `output_path` 为绝对路径。 - 产物 URL、任务链接或本地文件列表只能作为补充,不能替代媒体附件或可预览媒体。 - 所有待交付产物均经宿主交付成功后,才能宣称“交付完成”。生成、下载、附件交付分别判断。 - 某项下载或展示失败时明确未交付项目及原因,仍交付其他可确认属于本任务的可用媒体。宿主不支持媒体展示时如实说明限制,不宣称全部交付完成。 From 8f189519ea969c2a6fb862efb088de03604a8a20 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Sun, 20 Sep 2026 15:12:56 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=E6=A0=B9=E6=8D=AE=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E7=8A=B6=E6=80=81=E5=88=A4=E6=96=AD=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=BB=88=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- README.md | 2 + cmd/generate_video_test.go | 6 +- cmd/query_audio_test.go | 93 +++++++++++++++++++ internal/common/error_log.go | 2 + internal/common/get_thread.go | 7 +- internal/common/get_thread_test.go | 19 ++++ internal/generate_video/query_result.go | 17 +++- .../xyq-nest-skill/commands/query-result.md | 2 + 8 files changed, 142 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3b3065a..6643f5a 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,8 @@ pippit-tool-cli query-result \ Run 失败或取消均为终态,`completed=true` 且填充 `error_message`,保留服务端失败原因;尚未结束时 `completed=false`。无论退出码如何,都应先检查 `error_message`;下载失败后继续查询原任务,不重复提交生成。 +API 错误响应只有包含与请求 Run ID 匹配的结构化失败或取消状态,才视为已结束;否则返回 `completed=false` 和“查询失败:”错误,保留 LogID 与任务 ID,排障后继续查询同一任务。 + ## HTTP 客户端 命令模块通过 `common.Runner` 发起服务调用。运行时配置,例如基础地址、HTTP 超时时间和接口路径,由 `internal/config` 加载,并在运行器中与 `common.Client` 组合使用。 diff --git a/cmd/generate_video_test.go b/cmd/generate_video_test.go index 5d4dda0..2234966 100644 --- a/cmd/generate_video_test.go +++ b/cmd/generate_video_test.go @@ -527,13 +527,13 @@ func TestQueryResultGetThreadBusinessErrorReturnsErrorMessage(t *testing.T) { t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String()) } got := decodeJSON(t, stdout.Bytes()) - if got["completed"] != true { - t.Fatalf("completed = %v, want true", got["completed"]) + if got["completed"] != false { + t.Fatalf("completed = %v, want false because Run state is unknown", got["completed"]) } if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" { t.Fatalf("ids = (%v, %v), want thread/run ids", got["thread_id"], got["run_id"]) } - if got["error_message"] != "创作失败:暂时无法生成 log_id=log_456" { + if got["error_message"] != "查询失败:创作失败:暂时无法生成 log_id=log_456" { t.Fatalf("error_message = %v, want get_thread business error", got["error_message"]) } videos, ok := got["videos"].([]any) diff --git a/cmd/query_audio_test.go b/cmd/query_audio_test.go index 104d947..cc8441d 100644 --- a/cmd/query_audio_test.go +++ b/cmd/query_audio_test.go @@ -150,6 +150,99 @@ func TestQueryResultAudioDownloadErrorsKeepTaskIDs(t *testing.T) { } } +func TestQueryResultCanRetrySameRunAfterQueryBusinessError(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/biz/v1/skill/get_thread" { + t.Errorf("retry must not upload or submit: %s", r.URL.Path) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode query: %v", err) + return + } + if body["thread_id"] != "thread_123" || body["run_id"] != "run_456" { + t.Errorf("query changed task identity: %#v", body) + } + requests++ + if requests == 1 { + io.WriteString(w, `{"ret":"5","errmsg":"服务器繁忙","log_id":"query_log_1"}`) + return + } + writeAudioQueryFixture(w, map[string]any{ + "run_id": "run_456", "state": 4, + "fail_reason": map[string]any{"message": "下游音频生成失败", "code": 11001}, + }) + })) + defer server.Close() + dir := t.TempDir() + first := runAudioQuery(t, server.URL, dir) + if first["completed"] != false || first["error_message"] != "查询失败:服务器繁忙 log_id=query_log_1" { + t.Fatalf("query error must leave Run state unresolved: %#v", first) + } + second := runAudioQuery(t, server.URL, dir) + if second["completed"] != true || second["error_message"] != "下游音频生成失败 (error_code=11001)" { + t.Fatalf("second query should report observed Run failure: %#v", second) + } + if requests != 2 || first["thread_id"] != second["thread_id"] || first["run_id"] != second["run_id"] { + t.Fatalf("unexpected query sequence: requests=%d first=%#v second=%#v", requests, first, second) + } +} + +func TestQueryResultStructuredBusinessErrorsRequireMatchingTerminalRun(t *testing.T) { + for _, tc := range []struct { + name, threadID, runID string + state int + completed bool + data any + }{ + {name: "failed", threadID: "thread_123", runID: "run_456", state: 4, completed: true}, + {name: "canceled", threadID: "thread_123", runID: "run_456", state: 5, completed: true}, + {name: "wrong run", threadID: "thread_123", runID: "other_run", state: 4}, + {name: "wrong thread", threadID: "other_thread", runID: "run_456", state: 4}, + {name: "unknown state", threadID: "thread_123", runID: "run_456", state: 99}, + {name: "working state", threadID: "thread_123", runID: "run_456", state: 2}, + {name: "success state with error ret", threadID: "thread_123", runID: "run_456", state: 3}, + {name: "missing data", data: json.RawMessage(`null`)}, + {name: "missing run", data: map[string]any{"thread": map[string]any{"thread_id": "thread_123"}}}, + {name: "malformed structured data", data: "not a structured thread"}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/biz/v1/skill/get_thread" { + t.Errorf("error result must not download or submit: %s", r.URL.Path) + return + } + data := tc.data + if data == nil { + data = map[string]any{"thread": map[string]any{ + "thread_id": tc.threadID, "run_list": []any{map[string]any{ + "run_id": tc.runID, "state": tc.state, + "fail_reason": map[string]any{"code": 1, "message": "请求的Run已终止"}, + }}, + }} + } + json.NewEncoder(w).Encode(map[string]any{"ret": "5", "errmsg": "服务器繁忙", "log_id": "structured_log", "data": data}) + })) + defer server.Close() + got := runAudioQuery(t, server.URL, t.TempDir()) + want := "查询失败:服务器繁忙 log_id=structured_log" + if tc.completed { + want = "请求的Run已终止 (error_code=1) log_id=structured_log" + } + if got["completed"] != tc.completed || got["error_message"] != want || got["thread_id"] != "thread_123" || got["run_id"] != "run_456" { + t.Fatalf("unexpected error outcome: %#v", got) + } + for _, kind := range []string{"audios", "images", "videos"} { + if items, ok := got[kind].([]any); !ok || len(items) != 0 { + t.Fatalf("%s=%#v, want empty media", kind, got[kind]) + } + } + }) + } +} + func runAudioQuery(t *testing.T, baseURL, dir string) map[string]any { t.Helper() var stdout, stderr bytes.Buffer diff --git a/internal/common/error_log.go b/internal/common/error_log.go index 7e09970..5bdb7c4 100644 --- a/internal/common/error_log.go +++ b/internal/common/error_log.go @@ -30,6 +30,8 @@ type logIDCarrier interface { type LogIDError struct { Message string ID string + // RawData preserves structured API error data for callers, never error text or logs. + RawData []byte `json:"-"` } func NewLogIDError(message string, logID string) error { diff --git a/internal/common/get_thread.go b/internal/common/get_thread.go index 9426c81..2da8b3c 100644 --- a/internal/common/get_thread.go +++ b/internal/common/get_thread.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/Pippit-dev/pippit-cli/internal/config" ) @@ -53,7 +54,11 @@ func GetThread(ctx context.Context, opts *GetThreadOptions, runner *Runner) (*Ge if resp.Errmsg == "" { resp.Errmsg = "未知错误" } - return nil, NewLogIDError(fmt.Sprintf("获取线程请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID) + return nil, &LogIDError{ + Message: strings.TrimSpace(fmt.Sprintf("获取线程请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)), + ID: strings.TrimSpace(resp.LogID), + RawData: resp.Data, + } } if len(resp.Data) == 0 { return nil, fmt.Errorf("get_thread 响应缺少 data") diff --git a/internal/common/get_thread_test.go b/internal/common/get_thread_test.go index 2139b36..74c99d7 100644 --- a/internal/common/get_thread_test.go +++ b/internal/common/get_thread_test.go @@ -2,6 +2,8 @@ package common import ( "context" + "encoding/json" + "errors" "strings" "testing" @@ -72,3 +74,20 @@ func TestGetThreadV2RequiresReadableText(t *testing.T) { t.Fatalf("error = %q, want readable_text validation", err) } } + +func TestGetThreadPreservesStructuredErrorDataWithoutLoggingIt(t *testing.T) { + _, err := GetThread(context.Background(), &GetThreadOptions{ThreadID: "thread_123", RunID: "run_456"}, &Runner{ + Client: getThreadFakeClient{response: `{"ret":"5","errmsg":"生成失败","log_id":"log_123","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":4}]},"private_marker":"not-for-error-output"}}`}, + }) + var logErr *LogIDError + if !errors.As(err, &logErr) || logErr.LogID() != "log_123" || !strings.Contains(string(logErr.RawData), `"state":4`) { + t.Fatalf("structured error data or LogID lost: %#v", err) + } + encoded, marshalErr := json.Marshal(logErr) + if marshalErr != nil { + t.Fatal(marshalErr) + } + if strings.Contains(err.Error(), "not-for-error-output") || strings.Contains(string(encoded), "not-for-error-output") || strings.Contains(string(encoded), "RawData") { + t.Fatalf("structured error data leaked into error output") + } +} diff --git a/internal/generate_video/query_result.go b/internal/generate_video/query_result.go index c6511d2..a97ed50 100644 --- a/internal/generate_video/query_result.go +++ b/internal/generate_video/query_result.go @@ -272,13 +272,26 @@ func queryResultFromGetThreadBusinessError(err error, opts *QueryResultOptions) } message := getThreadBusinessErrorMessage(logErr.Message) if message == "" { - message = "查询失败" + message = "未知错误" + } + completed := false + // Nonzero ret can represent either a query failure or an observed Run failure. + // Only a matching structured Run in the error payload can establish a terminal state. + thread, parseErr := parseQueryThread(&common.GetThreadResult{RawData: logErr.RawData}) + if parseErr == nil && (thread.ThreadID == "" || thread.ThreadID == opts.ThreadID) { + if run, ok := findQueryRun(thread, opts.RunID); ok && (run.State == failedRunState || run.State == canceledRunState) { + completed = true + message = firstNonEmpty(extractQueryErrorMessage(run), message) + } + } + if !completed { + message = "查询失败:" + message } if logID := logErr.LogID(); logID != "" { message = fmt.Sprintf("%s log_id=%s", message, logID) } return &QueryResultResult{ - Completed: true, + Completed: completed, ThreadID: opts.ThreadID, RunID: opts.RunID, ErrorMessage: message, diff --git a/skills/xyq-nest-skill/commands/query-result.md b/skills/xyq-nest-skill/commands/query-result.md index 6be335f..1cb9546 100644 --- a/skills/xyq-nest-skill/commands/query-result.md +++ b/skills/xyq-nest-skill/commands/query-result.md @@ -41,6 +41,8 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回 任务尚未完成时通常返回 `completed=false`、空错误、空媒体数组。失败或取消返回 `completed=true` 和非空错误。命令不提供完整会话消息、用户反问或可区分的所有状态;不能仅凭这个响应断言具体进度或等待用户输入。轮询停止条件见 [共用流程](../workflows/async-delivery.md)。 +API 错误响应中,只有与请求 Run ID 匹配的结构化状态明确为失败或取消,CLI 才返回 `completed=true`、Run 失败原因及可用 LogID。错误响应缺少匹配的终态时,返回 `completed=false` 和以“查询失败:”开头的错误,保留 LogID 与任务 ID。此时未确认 Run 状态;排障后可查询同一 Run,不从错误文案推断生成结果,也不自动重新生成或无限轮询。 + ## 下载行为 文件名由 CLI 根据产物信息生成,以 `output_path` 为准,不自行拼接编号或推测扩展名。同目录已有同名文件可能被复用;复用不证明内容相同,也不代表本次新下载。发现同名文件属于其他产物时,选择用户认可范围内的未冲突目录再查询,不删除已有文件。 From 5704d9dd06dc35335a84c68b17d04769058bc3d2 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Sun, 20 Sep 2026 15:49:05 +0800 Subject: [PATCH 3/8] =?UTF-8?q?docs:=20=E6=98=8E=E7=A1=AE=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E6=A8=A1=E5=9E=8B=E5=92=8C=E9=AA=8C=E6=94=B6=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- README.md | 10 +++++++--- cmd/generate_audio/generate_audio.go | 8 +++++--- skills/xyq-nest-skill/SKILL.md | 2 +- skills/xyq-nest-skill/commands/generate-audio.md | 12 +++++++++--- skills/xyq-nest-skill/tests/agent_test_cases.md | 11 +++++++++++ 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6643f5a..9ea36bc 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ | 登录授权 | `status` / `login` / `logout` | [授权](skills/xyq-nest-skill/commands/auth.md) | | 个人 Canvas 画布与节点编辑 | `canvas` | [画布](skills/xyq-nest-skill/commands/canvas.md) | | 生图、参考图编辑 | `generate-image` | [图片](skills/xyq-nest-skill/commands/generate-image.md) | -| 生成音频、参考音频或图片创作 | `generate-audio` | [音频](skills/xyq-nest-skill/commands/generate-audio.md) | +| 生成音频、参考音频创作;参考图生成尚未验收 | `generate-audio` | [音频](skills/xyq-nest-skill/commands/generate-audio.md) | | 生视频、首尾帧 | `generate-video` | [视频](skills/xyq-nest-skill/commands/generate-video.md) | | 视频超分 | `video-super-resolution` | [超分](skills/xyq-nest-skill/commands/video-super-resolution.md) | | 擦字幕 | `erase-video-subtitle` | [擦字幕](skills/xyq-nest-skill/commands/erase-video-subtitle.md) | @@ -212,7 +212,9 @@ pippit-tool-cli generate-image \ ## 生音频 CLI -`generate-audio` 使用 Seed Audio 1.0,支持无参考生成、最多 3 个参考音频或 1 张参考图;两类参考不能混用。参考素材由 CLI 上传,成功后返回 `thread_id`、`run_id`、`web_thread_link`,再用 `query-result` 查询和下载。 +`generate-audio` 使用 Seed Audio 1.0,支持无参考生成或最多 3 个参考音频。参考素材由 CLI 上传,任务提交成功后返回 `thread_id`、`run_id`、`web_thread_link`,再用 `query-result` 查询和下载。 + +单张参考图的上传和任务提交链路已接通,但尚未通过真实音频生成验收,目前不能承诺稳定可用。参考图与参考音频不能混用;提交成功不代表已经生成音频。 ```bash pippit-tool-cli generate-audio \ @@ -222,10 +224,12 @@ pippit-tool-cli generate-audio \ --sample-rate 24000 ``` -`--prompt` 必填,`--model` 默认且仅支持 `seedaudio_1.0`。`--audio` 可以重复,`--image` 至多使用一次。音频文件后缀支持 `.mp3/.wav/.m4a/.aac/.flac/.ogg/.opus`,图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`;实际素材可用性由服务端检查。 +`--prompt` 必填,`--model` 默认且仅支持 `seedaudio_1.0`,不能切换到其他音频模型。其他模型和模式尚未接入,本命令不代表已覆盖网页端的全部音频能力。`--audio` 可以重复,`--image` 至多使用一次。音频文件后缀支持 `.mp3/.wav/.m4a/.aac/.flac/.ogg/.opus`,图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`;实际素材可用性由服务端检查。 输出配置均可选:`--format` 支持 `mp3/wav/pcm/ogg_opus`,`--sample-rate` 为正整数,`--speech-rate`、`--loudness-rate`、`--pitch-rate` 为 Seed Audio 1.0 的有限数值,`--enable-timestamp` 请求时间戳。只发送显式指定的配置,具体范围由服务端决定;未设置时使用服务端默认值。当前不提供独立 `--text`、精确时长、分轨或翻配参数。 +参数校验尚未与网页端逐项对齐:CLI 没有限定采样率选项和调音参数范围,也未增加按音频模型区分的 prompt 长度、参考文件大小和时长校验。通过本地校验不代表服务端一定接受;不能把这些缺口当作模型支持更宽参数的依据。 + 请求使用 `agent_name=pippit_audio_part_agent` 与 `audio_part_tool_param`,参考 ID 位于 `references[].pippit_asset_id`,不会混入图片或视频模型设置。 ## 生视频 CLI diff --git a/cmd/generate_audio/generate_audio.go b/cmd/generate_audio/generate_audio.go index 1f63869..51fe1e0 100644 --- a/cmd/generate_audio/generate_audio.go +++ b/cmd/generate_audio/generate_audio.go @@ -18,7 +18,9 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command cmd := &cobra.Command{ Use: "generate-audio", Short: "Generate audio with Seed Audio 1.0", - Args: cobra.NoArgs, + Long: "Generate audio with Seed Audio 1.0. Only seedaudio_1.0 is supported; other audio models and modes are not integrated. This command does not cover all audio features in the web app.\n\n" + + "Reference image upload and task submission are connected, but successful audio generation from an image has not been verified. Stable availability is not guaranteed; successful submission does not mean audio was generated.", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { config := &internalgen.AudioConfig{} changed := false @@ -57,9 +59,9 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command cmd.SetErr(stderr) flags := cmd.Flags() flags.StringVar(&opts.Prompt, "prompt", "", "audio generation prompt") - flags.StringVar(&opts.Model, "model", internalgen.DefaultModel, "audio model; supported: seedaudio_1.0") + flags.StringVar(&opts.Model, "model", internalgen.DefaultModel, "audio model; only seedaudio_1.0 is supported; model switching is not available") flags.StringArrayVar(&opts.AudioPaths, "audio", nil, "local reference audio path; repeat up to 3 times; cannot combine with --image") - flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; at most one; cannot combine with --audio") + flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; generation not yet verified; at most one; cannot combine with --audio") flags.StringVar(&format, "format", "", "output audio format: mp3, wav, pcm or ogg_opus; omitted uses the server default") flags.Int32Var(&sampleRate, "sample-rate", 0, "output audio sample rate in Hz") flags.Float64Var(&speechRate, "speech-rate", 0, "Seed Audio 1.0 speech rate; accepted range is validated by the server") diff --git a/skills/xyq-nest-skill/SKILL.md b/skills/xyq-nest-skill/SKILL.md index 4a03127..e491de5 100644 --- a/skills/xyq-nest-skill/SKILL.md +++ b/skills/xyq-nest-skill/SKILL.md @@ -27,7 +27,7 @@ metadata: | 查看登录状态、登录、退出或切换账号 | `status` / `login` / `logout` | [授权](commands/auth.md) | | 创建或查询小云雀个人画布,编辑节点、布局、连线、角色/场景、提示词、3D 或多轨草稿 | `canvas` | [Canvas 能力与命令发现](commands/canvas.md) | | 生成图片,或基于参考图修改图片 | `generate-image` | [生图与图片编辑](commands/generate-image.md) | -| 生成音频,使用参考音频或参考图创作音频 | `generate-audio` | [生音频](commands/generate-audio.md) | +| 生成音频,使用参考音频;参考图生成尚未验收 | `generate-audio` | [生音频](commands/generate-audio.md) | | 生成视频,使用图/视频/音频参考,首尾帧生视频 | `generate-video` | [生视频](commands/generate-video.md) | | 提升已有视频分辨率、视频超分 | `video-super-resolution` | [超分](commands/video-super-resolution.md) | | 去除已有视频字幕 | `erase-video-subtitle` | [擦字幕](commands/erase-video-subtitle.md) | diff --git a/skills/xyq-nest-skill/commands/generate-audio.md b/skills/xyq-nest-skill/commands/generate-audio.md index 87e059d..c5e06ae 100644 --- a/skills/xyq-nest-skill/commands/generate-audio.md +++ b/skills/xyq-nest-skill/commands/generate-audio.md @@ -1,15 +1,19 @@ # generate-audio:生成音频 -使用 Seed Audio 1.0 生成音频,可以提供参考音频或参考图。用户想给视频增加背景音乐时,先确定要生成音频素材还是编辑现有视频;本命令仅返回音频任务,不自动合成视频。 +使用 Seed Audio 1.0 生成音频,可以提供参考音频。当前仅支持 `seedaudio_1.0`,不能切换到其他音频模型;其他模型和模式尚未接入,不代表已覆盖网页端的全部音频能力。 + +单张参考图的上传和任务提交链路已接通,但尚未通过真实音频生成验收,目前不能承诺稳定可用。用户要求参考图生成时先说明这一边界;提交成功不代表已生成音频,最终以原任务的查询结果为准。 + +用户想给视频增加背景音乐时,先确定要生成音频素材还是编辑现有视频;本命令仅返回音频任务,不自动合成视频。 ## 输入与参数 | 参数 | 必填 | 规则 | | --- | --- | --- | | `--prompt` | 是 | 用户原始描述,不能全为空白;在这里完整描述生成内容 | -| `--model` | 否 | 默认且仅支持 `seedaudio_1.0` | +| `--model` | 否 | 默认且仅支持 `seedaudio_1.0`,不支持切换模型 | | `--audio` | 否 | 本地参考音频路径,最多重复 3 次,保持顺序;不能与 `--image` 混用 | -| `--image` | 否 | 本地参考图路径,至多 1 张;不能与 `--audio` 混用 | +| `--image` | 否 | 本地参考图路径,至多 1 张;不能与 `--audio` 混用;生成能力尚未通过真实验收 | | `--format` | 否 | `mp3`、`wav`、`pcm`、`ogg_opus` | | `--sample-rate` | 否 | 采样率,单位 Hz,正整数 | | `--speech-rate` / `--loudness-rate` / `--pitch-rate` | 否 | Seed Audio 1.0 的语速、响度、音调参数,必须为有限数值,具体范围由服务端决定 | @@ -17,6 +21,8 @@ 只传用户指定的可选配置;缺省交给服务端。不能套用新模型的参数范围,不提供独立 `--text`、精确时长、分轨、翻配或其他音频模型能力,也不通过填入图片、视频模型来提交音频。 +本地校验尚未逐项对齐网页端:采样率只检查正整数,调音只检查有限数值;没有按音频模型校验 prompt 长度、参考文件大小和时长。不能据此推断模型允许任意数值或素材,也不能把未通过服务端验证的输入宣称为支持。 + 音频文件后缀支持 `.mp3/.wav/.m4a/.aac/.flac/.ogg/.opus`,图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`。素材由 CLI 上传,实际可用性由服务端检查。远程 URL 不能替代本地路径。 ## 最小调用 diff --git a/skills/xyq-nest-skill/tests/agent_test_cases.md b/skills/xyq-nest-skill/tests/agent_test_cases.md index 430686e..c20357e 100644 --- a/skills/xyq-nest-skill/tests/agent_test_cases.md +++ b/skills/xyq-nest-skill/tests/agent_test_cases.md @@ -11,6 +11,14 @@ | 仅咨询 | 怎么生成一张猫咪图片? | 解释命令,不发起收费生成 | | 图片编辑 | 图1是底图,图2只提供猫形象,替换猫但保留背景;已指定模型 | 两次 --image 顺序与用户角色一致,原始指令不改写 | | 生图比例 | 用 seedream_5.0_pro,16:9 生图 | --ratio 2;不把视频比例字符串用在生图命令中 | +| 普通生音频 | 用温暖自然的声音介绍今天的旅行 | 读取生音频模块与异步交付流程,保留 prompt,使用默认 seedaudio_1.0;不询问不存在的模型选项,不补可选配置 | +| 音频缺少描述 | 只要求生成音频,未提供生成内容或 prompt 全为空白 | 先补齐生成描述,不提交空 prompt | +| 音频参考与配置 | 参考三个本地音频生成旁白,输出 WAV、24000 Hz | 按用户顺序传三个 --audio,--format wav、--sample-rate 24000;不添加其他配置 | +| 音频误用视频命令 | 为视频生成一段背景音乐素材 | 走 generate-audio,不用 generate-video;仅交付音频,不声称已把音乐合成到视频 | +| 音频混合参考 | 同时提供音频和图片作为音频生成参考 | 说明两类参考不能混用,先确认使用哪类;不上传、不擅自丢弃参考素材 | +| 不支持的音频模型 | 明确要求使用其他音频模型或切换模型 | 说明当前仅支持 seedaudio_1.0;不提交其他模型,不擅自改用默认模型或视频模型 | +| 音频参考图 | 用本地图片创作音频 | 说明上传与提交链路已接通,但真实生成尚未验收,不承诺稳定可用;执行时只提交一次并查询原任务,不因失败自动换模型或重交 | +| 未接入的音频模式 | 要求精确时长、分轨或翻配 | 说明当前命令不提供这些能力,不套用其他模型或网页端参数 | | 普通生视频 | 生成一个猫咪跳舞的视频 | 读取生视频模块,不要求用户说“模型直出”,未指定模型时省略 | | 视频参数 | 用 Seedance_2.5,9:16,720p,8 秒生成猫咪视频 | 保留 prompt,--ratio 9:16、--duration 8,不改成整数比例或时长范围 | | 时长范围 | 生成一个 5 到 10 秒的视频 | 先询问具体秒数,不臆造范围参数 | @@ -31,9 +39,12 @@ | --- | --- | | 查询退出码 0,error_message 非空 | 按错误处理,不能判成功,也不能因 completed=false 继续空轮询 | | completed=true 且有错误 | 失败,保留原任务 ID,不重新生成 | +| 音频 Run 明确失败或取消 | completed=true 仍按失败处理,保留失败原因和原任务 ID;没有音频时不宣称生成或交付成功 | +| 音频查询报错且 completed=false | 说明查询错误,不能据此断言 Run 终态;排障后查询同一 Run,不忽略错误无限轮询或重新生成 | | completed=false、空错误 | 仅说明暂未取得最终结果,按共用流程有界等待,不编造进度或具体状态 | | completed=true、空错误、空产物 | 报告结果异常,不宣称交付完成 | | 成功返回多张图/多个视频 | 检查所有 output_path,通过宿主逐项展示真实媒体 | +| 音频完成且 audios 非空 | 检查每个 output_path 文件存在且非空,通过宿主交付真实音频;使用 .audio 后缀时不猜测编码,不把改后缀当转码 | | 本地缺文件、空文件或已知是同名旧文件 | 不视为成功交付,报告对应项目;不得凭目录中的任意文件补结果 | | 部分媒体不能展示 | 交付其余可用媒体,明确未交付项,不宣称全部完成 | | 宿主不支持附件或预览 | 说明交付限制,链接/路径仅作补充 | From eb307b4dd7488073626c0ea26dcad3ac4a490bf6 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Sun, 20 Sep 2026 15:52:34 +0800 Subject: [PATCH 4/8] =?UTF-8?q?test:=20=E5=90=8C=E6=AD=A5=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E5=91=BD=E4=BB=A4=E7=9A=84=E5=B8=AE=E5=8A=A9=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- cmd/short_drama_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/short_drama_test.go b/cmd/short_drama_test.go index 6668687..9b88bce 100644 --- a/cmd/short_drama_test.go +++ b/cmd/short_drama_test.go @@ -124,8 +124,9 @@ func TestRootHelpListsSupportedCommands(t *testing.T) { } got := stdout.String() for _, want := range []string{ - "Pippit CLI generates and processes videos", + "Pippit CLI generates audio, videos and images, processes videos", "erase-video-subtitle", + "\n generate-audio ", "generate-video", "download-result", "get-credit-balance", From d81bbaf81da3cc7944627acb5dbf1ea5a8f76692 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Sun, 20 Sep 2026 16:38:18 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=E9=9F=B3=E9=A2=91=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=92=8C=20JSON=20=E5=8F=82=E6=95=B0=E5=8E=9F?= =?UTF-8?q?=E6=A0=B7=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- README.md | 26 +- cmd/generate_audio/generate_audio.go | 140 ++++++++--- cmd/generate_audio/input.go | 115 +++++++++ cmd/generate_audio_input_test.go | 229 ++++++++++++++++++ cmd/generate_audio_test.go | 35 ++- internal/generate_audio/generate_audio.go | 179 ++++++-------- skills/xyq-nest-skill/SKILL.md | 2 +- .../xyq-nest-skill/commands/generate-audio.md | 56 +++-- .../xyq-nest-skill/tests/agent_test_cases.md | 13 +- 9 files changed, 611 insertions(+), 184 deletions(-) create mode 100644 cmd/generate_audio/input.go create mode 100644 cmd/generate_audio_input_test.go diff --git a/README.md b/README.md index 9ea36bc..2240e9a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ | 登录授权 | `status` / `login` / `logout` | [授权](skills/xyq-nest-skill/commands/auth.md) | | 个人 Canvas 画布与节点编辑 | `canvas` | [画布](skills/xyq-nest-skill/commands/canvas.md) | | 生图、参考图编辑 | `generate-image` | [图片](skills/xyq-nest-skill/commands/generate-image.md) | -| 生成音频、参考音频创作;参考图生成尚未验收 | `generate-audio` | [音频](skills/xyq-nest-skill/commands/generate-audio.md) | +| 音频生成与模型参数透传、本地参考素材上传 | `generate-audio` | [音频](skills/xyq-nest-skill/commands/generate-audio.md) | | 生视频、首尾帧 | `generate-video` | [视频](skills/xyq-nest-skill/commands/generate-video.md) | | 视频超分 | `video-super-resolution` | [超分](skills/xyq-nest-skill/commands/video-super-resolution.md) | | 擦字幕 | `erase-video-subtitle` | [擦字幕](skills/xyq-nest-skill/commands/erase-video-subtitle.md) | @@ -212,9 +212,9 @@ pippit-tool-cli generate-image \ ## 生音频 CLI -`generate-audio` 使用 Seed Audio 1.0,支持无参考生成或最多 3 个参考音频。参考素材由 CLI 上传,任务提交成功后返回 `thread_id`、`run_id`、`web_thread_link`,再用 `query-result` 查询和下载。 +`generate-audio` 将音频参数交给服务端校验和执行。CLI 不维护模型、输出格式、参考数量或混用规则的白名单,也不会在失败时切换模型。已接入参数不代表所有模型和模式都可用,实际支持范围以服务端为准。 -单张参考图的上传和任务提交链路已接通,但尚未通过真实音频生成验收,目前不能承诺稳定可用。参考图与参考音频不能混用;提交成功不代表已经生成音频。 +原有便捷 flags 保留;不用 JSON 且没有显式指定 `--model` 时,兼容默认值仍为 `seedaudio_1.0`。显式 model 按原值发送,包括空字符串和空白,不修剪或替换。JSON 输入模式不添加默认模型,由服务端解释缺省值。 ```bash pippit-tool-cli generate-audio \ @@ -224,13 +224,25 @@ pippit-tool-cli generate-audio \ --sample-rate 24000 ``` -`--prompt` 必填,`--model` 默认且仅支持 `seedaudio_1.0`,不能切换到其他音频模型。其他模型和模式尚未接入,本命令不代表已覆盖网页端的全部音频能力。`--audio` 可以重复,`--image` 至多使用一次。音频文件后缀支持 `.mp3/.wav/.m4a/.aac/.flac/.ogg/.opus`,图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`;实际素材可用性由服务端检查。 +`--format`、`--sample-rate`、`--speech-rate`、`--loudness-rate`、`--pitch-rate`、`--enable-timestamp` 只写入旧 `audio_config` 的对应字段。只发送显式设置的值,保留 `0` 和 `false`;CLI 仅检查参数类型与数值能否编码为 JSON,语义和范围交给服务端。使用 `audio_config_v2`、`output_format`、`task_type`、`dubbing_config` 等参数时,通过 JSON 提供,CLI 不按模型自动转换旧配置。 -输出配置均可选:`--format` 支持 `mp3/wav/pcm/ogg_opus`,`--sample-rate` 为正整数,`--speech-rate`、`--loudness-rate`、`--pitch-rate` 为 Seed Audio 1.0 的有限数值,`--enable-timestamp` 请求时间戳。只发送显式指定的配置,具体范围由服务端决定;未设置时使用服务端默认值。当前不提供独立 `--text`、精确时长、分轨或翻配参数。 +### 通用 JSON 输入 -参数校验尚未与网页端逐项对齐:CLI 没有限定采样率选项和调音参数范围,也未增加按音频模型区分的 prompt 长度、参考文件大小和时长校验。通过本地校验不代表服务端一定接受;不能把这些缺口当作模型支持更宽参数的依据。 +`--input ''` 与 `--file path.json` 互斥,`--file -` 从 stdin 读取;必须是单个 JSON 对象,最多 64 MiB,重复键和尾随内容会报错。对象直接对应 `audio_part_tool_param`,不要再套一层请求体。 -请求使用 `agent_name=pippit_audio_part_agent` 与 `audio_part_tool_param`,参考 ID 位于 `references[].pippit_asset_id`,不会混入图片或视频模型设置。 +```bash +pippit-tool-cli generate-audio --input '{"model":"seedaudio_1.0","prompt":"用自然的声音说你好","audio_config":{"format":"wav","sample_rate":24000}}' +pippit-tool-cli generate-audio --file ./audio-params.json +pippit-tool-cli generate-audio --file - < ./audio-params.json +``` + +JSON 中的已提供字段、未知字段、数值精度、`null`、`0` 和 `false` 保留到 HTTP 请求。CLI 能发送字段不表示服务端已经支持它;服务端仍按当前协议解析,未定义字段可能被忽略。模型专属参数及是否允许省略 prompt 由服务端决定,CLI 不填入虚构的 prompt 或 text。 + +便捷 flag 与 JSON 同字段冲突时直接报错,即使值相同也不覆盖。例如 `--model` 不能与 JSON `model` 同时出现;`--format` 不能与 `audio_config.format` 同时出现,但可以与 `audio_config` 的其他字段组合。已有 JSON `references` 保序,再按 `--audio`、`--image`、`--video` 在命令行出现的顺序追加本地上传结果。已有资产 ID 和 `references[].speaker` 通过 JSON 提供,CLI 不上传或改写这些引用,也不去重;追加本地文件时 `references` 必须是数组。 + +本地素材参数接收可读的普通文件路径,不接收远程 URL。CLI 会在开始上传前检查所有本地文件;文件类型、参考组合、speaker 与资产 ID 的兼容性仍由服务端判断。参考图的上传与提交链路已接通,但尚未通过真实音频生成验收,不能承诺稳定可用。 + +外层请求始终使用 `agent_name=pippit_audio_part_agent`;JSON 不能修改 agent、鉴权、团队或外层协议字段。`message` 优先取非空 prompt,其次 text、task_type 描述;均无内容时使用中性任务描述,不改写音频参数。任务提交成功返回 `thread_id`、`run_id`、`web_thread_link`,再用 `query-result` 查询和下载;提交成功不等于生成成功。命令不会自动把音频合成到视频,也不把返回的 duration 当作精确时长控制的保证。 ## 生视频 CLI diff --git a/cmd/generate_audio/generate_audio.go b/cmd/generate_audio/generate_audio.go index 51fe1e0..d5af255 100644 --- a/cmd/generate_audio/generate_audio.go +++ b/cmd/generate_audio/generate_audio.go @@ -1,6 +1,9 @@ package generate_audio import ( + "bytes" + "encoding/json" + "fmt" "io" "strings" @@ -10,46 +13,82 @@ import ( ) func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { - opts := &internalgen.Options{} + var prompt, model, input, filePath, format string var sampleRate int32 var speechRate, loudnessRate, pitchRate float64 - var format string var enableTimestamp bool + var references []internalgen.LocalReference cmd := &cobra.Command{ Use: "generate-audio", - Short: "Generate audio with Seed Audio 1.0", - Long: "Generate audio with Seed Audio 1.0. Only seedaudio_1.0 is supported; other audio models and modes are not integrated. This command does not cover all audio features in the web app.\n\n" + - "Reference image upload and task submission are connected, but successful audio generation from an image has not been verified. Stable availability is not guaranteed; successful submission does not mean audio was generated.", + Short: "Generate audio with model parameters validated by the service", + Long: "Generate audio using convenience flags or an audio_part_tool_param JSON object via --input or --file (use - for stdin). Models and parameter combinations are validated by the service. Explicit values are passed unchanged.\n\n" + + "JSON mode does not add a model default. Calls without JSON retain seedaudio_1.0 as a compatibility default, not a model allowlist. Legacy output flags only populate audio_config; use JSON for other configurations and modes.\n\n" + + "Conflicting JSON fields and explicit flags are rejected. Local audio/image/video references are appended after JSON references in flag order. Reference image submission is connected, but successful generation has not been verified.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - config := &internalgen.AudioConfig{} - changed := false - for _, setting := range []struct { - flag string - set func() + flags := cmd.Flags() + if flags.Changed("input") && flags.Changed("file") { + return fmt.Errorf("--input 和 --file 不能同时使用") + } + jsonMode := flags.Changed("input") || flags.Changed("file") + params := make(map[string]json.RawMessage) + if jsonMode { + if flags.Changed("file") && strings.TrimSpace(filePath) == "" { + return fmt.Errorf("--file 不能为空") + } + var err error + params, err = readParameters(input, filePath, cmd.InOrStdin()) + if err != nil { + return err + } + } else if flags.NFlag() == 0 { + return fmt.Errorf("请提供 --prompt 或 --input/--file 音频参数") + } + for _, setting := range []struct{ key, value string }{{"prompt", prompt}, {"model", model}} { + if flags.Changed(setting.key) { + if err := addFlag(params, setting.key, setting.key, setting.value); err != nil { + return err + } + } + } + if !jsonMode && !flags.Changed("model") { + params["model"] = json.RawMessage(`"` + internalgen.DefaultModel + `"`) + } + settings := []struct { + flag, key string + value any }{ - {"format", func() { config.Format = format }}, - {"sample-rate", func() { config.SampleRate = &sampleRate }}, - {"speech-rate", func() { config.SpeechRate = &speechRate }}, - {"loudness-rate", func() { config.LoudnessRate = &loudnessRate }}, - {"pitch-rate", func() { config.PitchRate = &pitchRate }}, - {"enable-timestamp", func() { config.EnableTimestamp = &enableTimestamp }}, - } { - if cmd.Flags().Changed(setting.flag) { - setting.set() - changed = true + {"format", "format", format}, {"sample-rate", "sample_rate", sampleRate}, + {"speech-rate", "speech_rate", speechRate}, {"loudness-rate", "loudness_rate", loudnessRate}, + {"pitch-rate", "pitch_rate", pitchRate}, {"enable-timestamp", "enable_timestamp", enableTimestamp}, + } + var config map[string]json.RawMessage + for _, setting := range settings { + if !flags.Changed(setting.flag) { + continue + } + if config == nil { + config = make(map[string]json.RawMessage) + if raw, exists := params["audio_config"]; exists { + if !bytes.HasPrefix(bytes.TrimSpace(raw), []byte("{")) || json.Unmarshal(raw, &config) != nil { + return fmt.Errorf("--%s 需要合并 audio_config,但 JSON audio_config 不是对象", setting.flag) + } + } + } + if err := addFlag(config, setting.key, setting.flag, setting.value); err != nil { + return fmt.Errorf("audio_config: %w", err) } } - opts.AudioConfig = nil - if changed { - opts.AudioConfig = config + if config != nil { + raw, err := json.Marshal(config) + if err != nil { + return err + } + params["audio_config"] = raw } - result, err := internalgen.Run(cmd.Context(), opts, runner) + result, err := internalgen.Run(cmd.Context(), &internalgen.Options{Parameters: params, LocalReferences: references}, runner) if err != nil { - _ = common.AppendDailyErrorLog("generate-audio", err, map[string]string{ - "prompt": strings.TrimSpace(opts.Prompt), - "model": strings.TrimSpace(opts.Model), - }) + _ = common.AppendDailyErrorLog("generate-audio", err, nil) return err } return common.WriteJSON(stdout, result) @@ -58,15 +97,40 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command cmd.SetOut(stdout) cmd.SetErr(stderr) flags := cmd.Flags() - flags.StringVar(&opts.Prompt, "prompt", "", "audio generation prompt") - flags.StringVar(&opts.Model, "model", internalgen.DefaultModel, "audio model; only seedaudio_1.0 is supported; model switching is not available") - flags.StringArrayVar(&opts.AudioPaths, "audio", nil, "local reference audio path; repeat up to 3 times; cannot combine with --image") - flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; generation not yet verified; at most one; cannot combine with --audio") - flags.StringVar(&format, "format", "", "output audio format: mp3, wav, pcm or ogg_opus; omitted uses the server default") - flags.Int32Var(&sampleRate, "sample-rate", 0, "output audio sample rate in Hz") - flags.Float64Var(&speechRate, "speech-rate", 0, "Seed Audio 1.0 speech rate; accepted range is validated by the server") - flags.Float64Var(&loudnessRate, "loudness-rate", 0, "Seed Audio 1.0 loudness rate; accepted range is validated by the server") - flags.Float64Var(&pitchRate, "pitch-rate", 0, "Seed Audio 1.0 pitch rate; accepted range is validated by the server") - flags.BoolVar(&enableTimestamp, "enable-timestamp", false, "request audio timestamps") + flags.StringVar(&input, "input", "", "audio_part_tool_param JSON object; cannot combine with --file") + flags.StringVar(&filePath, "file", "", "audio parameter JSON file, or - for stdin; cannot combine with --input") + flags.StringVar(&prompt, "prompt", "", "audio prompt passed unchanged; conflicts with JSON prompt") + flags.StringVar(&model, "model", internalgen.DefaultModel, "model passed unchanged; service validates support; default only applies without JSON input") + for _, kind := range []string{"audio", "image", "video"} { + flags.Var(&referenceFlag{kind: kind, references: &references}, kind, "local reference "+kind+" path; repeat to append after JSON references, in flag order") + } + flags.StringVar(&format, "format", "", "legacy audio_config.format; passed unchanged, support is validated by the service") + flags.Int32Var(&sampleRate, "sample-rate", 0, "legacy audio_config.sample_rate in Hz") + flags.Float64Var(&speechRate, "speech-rate", 0, "legacy audio_config.speech_rate") + flags.Float64Var(&loudnessRate, "loudness-rate", 0, "legacy audio_config.loudness_rate") + flags.Float64Var(&pitchRate, "pitch-rate", 0, "legacy audio_config.pitch_rate") + flags.BoolVar(&enableTimestamp, "enable-timestamp", false, "legacy audio_config.enable_timestamp") return cmd } + +// Each flag shares one ordered list, including when different media types are interleaved. +type referenceFlag struct { + kind string + references *[]internalgen.LocalReference +} + +func (f *referenceFlag) Set(value string) error { + *f.references = append(*f.references, internalgen.LocalReference{Type: f.kind, Path: value}) + return nil +} +func (f *referenceFlag) Type() string { return "stringArray" } +func (f *referenceFlag) String() string { + values := make([]string, 0) + for _, ref := range *f.references { + if ref.Type == f.kind { + values = append(values, ref.Path) + } + } + raw, _ := json.Marshal(values) + return string(raw) +} diff --git a/cmd/generate_audio/input.go b/cmd/generate_audio/input.go new file mode 100644 index 0000000..9aa4732 --- /dev/null +++ b/cmd/generate_audio/input.go @@ -0,0 +1,115 @@ +package generate_audio + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/Pippit-dev/pippit-cli/internal/common" +) + +// Match the existing Canvas JSON input limit; this is a local I/O bound, +// independent of model parameter validation. +const maxInputBytes = 64 << 20 + +func readParameters(input, filePath string, stdin io.Reader) (map[string]json.RawMessage, error) { + var reader io.Reader = strings.NewReader(input) + if filePath != "" { + if filePath == "-" { + reader = stdin + } else { + path, err := common.ExpandPath(filePath) + if err != nil { + return nil, err + } + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("读取 --file 失败: %w", err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("--file 必须是普通文件或 -") + } + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("打开 --file 失败: %w", err) + } + defer file.Close() + reader = file + } + } + payload, err := io.ReadAll(io.LimitReader(reader, maxInputBytes+1)) + if err != nil { + return nil, fmt.Errorf("读取音频参数失败: %w", err) + } + if len(payload) > maxInputBytes { + return nil, fmt.Errorf("音频参数超过 64 MiB 限制") + } + payload = bytes.TrimSpace(payload) + if len(payload) == 0 || payload[0] != '{' || !json.Valid(payload) { + return nil, fmt.Errorf("音频参数必须是单个合法 JSON 对象,不能包含尾随内容") + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.UseNumber() + if err := checkJSONKeys(decoder); err != nil { + return nil, err + } + var params map[string]json.RawMessage + if err := json.Unmarshal(payload, ¶ms); err != nil { + return nil, err + } + for key := range params { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) + switch normalized { + case "agentname", "message", "audioparttoolparam", "videoparttoolparam", "generalagentsettings", "assetids", "threadid", "runid", "authorization", "accesskey", "ak", "token", "teamid", "uid", "userid", "headers", "baseurl": + return nil, fmt.Errorf("音频参数不能包含请求外层或身份字段 %q;--input/--file 只接受 audio_part_tool_param 对象", key) + } + } + return params, nil +} + +// Reject duplicate keys instead of silently keeping the last value. UseNumber +// avoids converting arbitrary JSON numbers to float64 during this check. +func checkJSONKeys(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delim, container := token.(json.Delim) + if !container { + return nil + } + seen := make(map[string]bool) + for decoder.More() { + if delim == '{' { + token, err := decoder.Token() + if err != nil { + return err + } + key := token.(string) + if seen[key] { + return fmt.Errorf("JSON 对象包含重复字段 %q", key) + } + seen[key] = true + } + if err := checkJSONKeys(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err +} + +func addFlag(params map[string]json.RawMessage, key, flag string, value any) error { + if _, exists := params[key]; exists { + return fmt.Errorf("--%s 与 JSON 字段 %s 冲突,请只保留一个来源", flag, key) + } + raw, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("--%s 必须可编码为 JSON,数值必须为有限数值: %w", flag, err) + } + params[key] = raw + return nil +} diff --git a/cmd/generate_audio_input_test.go b/cmd/generate_audio_input_test.go new file mode 100644 index 0000000..9e35da2 --- /dev/null +++ b/cmd/generate_audio_input_test.go @@ -0,0 +1,229 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestGenerateAudioJSONParameters(t *testing.T) { + for _, tc := range []struct { + name, source, input, expected, message string + flags []string + }{ + {name: "unknown fields and precise values", source: "input", input: `{"model":" future/model ","prompt":" unchanged prompt ","audio_config_v2":{"sample_rate":24000,"speech_rate":0},"watermark":false,"include":null,"future":{"large":9007199254740993123,"decimal":1.2300,"negative_zero":-0,"list":[false,null,0]}}`, message: " unchanged prompt "}, + {name: "file text", source: "file", input: `{"model":"future","text":"read this","references":[{"type":"audio","speaker":"speaker://example/voice"}]}`, message: "read this"}, + {name: "stdin task without prompt", source: "stdin", input: `{"model":"future","task_type":"dubbing","dubbing_config":{"target_language":"en"}}`, message: "音频任务:dubbing"}, + {name: "neutral message preserves null", source: "input", input: `{"model":"future","prompt":null,"text":null,"task_type":null,"audio_config":null,"references":null}`, message: "音频生成任务"}, + {name: "no injected model", source: "input", input: `{"prompt":"hi"}`, message: "hi"}, + {name: "empty object", source: "input", input: `{}`, message: "音频生成任务"}, + {name: "merge disjoint flags", source: "input", input: `{"audio_config":{"future":9007199254740993123}}`, flags: []string{"--model", " Other ", "--prompt", "hello", "--speech-rate", "0", "--enable-timestamp=false"}, expected: `{"model":" Other ","prompt":"hello","audio_config":{"future":9007199254740993123,"speech_rate":0,"enable_timestamp":false}}`, message: "hello"}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.URL.Path != "/api/biz/v1/skill/submit_run" || r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + var agent, message string + _ = json.Unmarshal(body["agent_name"], &agent) + _ = json.Unmarshal(body["message"], &message) + if len(body) != 3 || agent != "pippit_audio_part_agent" || message != tc.message { + t.Errorf("unexpected envelope: %s", body) + } + expected := tc.expected + if expected == "" { + expected = tc.input + } + if !reflect.DeepEqual(audioJSON(t, body["audio_part_tool_param"]), audioJSON(t, []byte(expected))) { + t.Errorf("parameters=%s, want %s", body["audio_part_tool_param"], expected) + } + io.WriteString(w, `{"ret":"0","data":{"run":{"thread_id":"thread","run_id":"run"}}}`) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + args := []string{"generate-audio"} + switch tc.source { + case "file": + path := filepath.Join(t.TempDir(), "params.json") + if err := os.WriteFile(path, []byte(tc.input), 0o600); err != nil { + t.Fatal(err) + } + args = append(args, "--file", path) + case "stdin": + root.SetIn(strings.NewReader(tc.input)) + args = append(args, "--file", "-") + default: + args = append(args, "--input", tc.input) + } + root.SetArgs(append(args, tc.flags...)) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if requests != 1 { + t.Fatalf("requests=%d, want one submit and no uploads", requests) + } + }) + } +} + +func TestGenerateAudioRejectsJSONBeforeUpload(t *testing.T) { + for _, tc := range []struct { + name, want string + args []string + }{ + {"both sources", "不能同时", []string{"--input", "{}", "--file", "-"}}, + {"empty sources", "不能同时", []string{"--input=", "--file="}}, + {"empty file", "不能为空", []string{"--file="}}, + {"invalid", "JSON 对象", []string{"--input", "{"}}, + {"array", "JSON 对象", []string{"--input", "[]"}}, + {"null", "JSON 对象", []string{"--input", "null"}}, + {"scalar", "JSON 对象", []string{"--input", "1"}}, + {"trailing", "尾随", []string{"--input", "{} {}"}}, + {"duplicate", "重复字段", []string{"--input", `{"model":"a","model":"b"}`}}, + {"nested duplicate", "重复字段", []string{"--input", `{"future":[{"a":0,"a":1}]}`}}, + {"model conflict", "冲突", []string{"--input", `{"model":null}`, "--model", "future"}}, + {"prompt conflict", "冲突", []string{"--input", `{"prompt":"hello"}`, "--prompt", "hello"}}, + {"config conflict", "冲突", []string{"--input", `{"audio_config":{"enable_timestamp":false}}`, "--enable-timestamp=false"}}, + {"config null", "不是对象", []string{"--input", `{"audio_config":null}`, "--speech-rate", "0"}}, + {"references null", "必须是 JSON 数组", []string{"--input", `{"references":null}`}}, + {"references object", "必须是 JSON 数组", []string{"--input", `{"references":{}}`}}, + } { + t.Run(tc.name, func(t *testing.T) { rejectAudioBeforeHTTP(t, tc.args, tc.want) }) + } + for _, key := range []string{"agent_name", "message", "audio_part_tool_param", "video_part_tool_param", "general_agent_settings", "authorization", "AK", "team_id", "TeamID", "headers", "base_url"} { + t.Run("protected "+key, func(t *testing.T) { + rejectAudioBeforeHTTP(t, []string{"--input", fmt.Sprintf(`{%q:"value"}`, key)}, "身份字段") + }) + } +} + +func TestGenerateAudioReferenceAppendOrder(t *testing.T) { + dir := t.TempDir() + kinds := []string{"video", "audio", "image", "audio"} + args := []string{"generate-audio", "--input", `{"model":"future","references":[{"type":"audio","speaker":"speaker://example/voice","future":false},{"type":"image","pippit_asset_id":"existing"}]}`} + for i, kind := range kinds { + path := filepath.Join(dir, fmt.Sprintf("reference-%d.bin", i)) + if err := os.WriteFile(path, []byte(kind), 0o600); err != nil { + t.Fatal(err) + } + args = append(args, "--"+kind, path) + } + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/biz/v1/skill/upload_file": + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Error(err) + return + } + defer r.MultipartForm.RemoveAll() + files := r.MultipartForm.File["file"] + if len(files) != 1 || files[0].Filename != fmt.Sprintf("reference-%d.bin", len(calls)) { + t.Errorf("upload order: %#v", files) + } + calls = append(calls, "upload") + fmt.Fprintf(w, `{"ret":"0","data":{"pippit_asset_id":"asset_%d"}}`, len(calls)) + case "/api/biz/v1/skill/submit_run": + calls = append(calls, "submit") + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + params := audioJSON(t, body["audio_part_tool_param"]).(map[string]any) + refs := params["references"].([]any) + want := []any{map[string]any{"type": "audio", "speaker": "speaker://example/voice", "future": false}, map[string]any{"type": "image", "pippit_asset_id": "existing"}} + for i, kind := range kinds { + want = append(want, map[string]any{"type": kind, "pippit_asset_id": fmt.Sprintf("asset_%d", i+1)}) + } + if !reflect.DeepEqual(refs, want) { + t.Errorf("references=%#v, want %#v", refs, want) + } + io.WriteString(w, `{"ret":"0","data":{"run":{"thread_id":"thread","run_id":"run"}}}`) + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if strings.Join(calls, ",") != "upload,upload,upload,upload,submit" { + t.Fatalf("calls=%v", calls) + } +} + +func TestGenerateAudioPreflightsAllLocalFiles(t *testing.T) { + for _, kind := range []string{"missing", "directory", "unreadable"} { + t.Run(kind, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "invalid.wav") + if kind == "directory" { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + } else if kind == "unreadable" { + if err := os.WriteFile(path, []byte("data"), 0o000); err != nil { + t.Fatal(err) + } + if file, err := os.Open(path); err == nil { + file.Close() + t.Skip("current user can read mode 000 files") + } + } + rejectAudioBeforeHTTP(t, []string{"--prompt", "hello", "--video", path}, "参考") + }) + } +} + +func rejectAudioBeforeHTTP(t *testing.T, args []string, want string) { + t.Helper() + path := filepath.Join(t.TempDir(), "readable.wav") + if err := os.WriteFile(path, []byte("valid"), 0o600); err != nil { + t.Fatal(err) + } + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + t.Errorf("invalid input reached HTTP: %s", r.URL.Path) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs(append([]string{"generate-audio", "--audio", path}, args...)) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("error=%v, want %q", err, want) + } + if requests != 0 { + t.Fatalf("requests=%d, want no requests", requests) + } +} + +func audioJSON(t *testing.T, raw []byte) any { + t.Helper() + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + t.Fatal(err) + } + return value +} diff --git a/cmd/generate_audio_test.go b/cmd/generate_audio_test.go index 41a8f67..ff61b1f 100644 --- a/cmd/generate_audio_test.go +++ b/cmd/generate_audio_test.go @@ -17,17 +17,26 @@ import ( func TestGenerateAudioRequest(t *testing.T) { for _, tc := range []struct { name, kind string + model *string files []string flags []string config map[string]any }{ {name: "text only"}, + {name: "arbitrary model", model: audioString(" future/model ")}, + {name: "explicit empty model", model: audioString("")}, + {name: "explicit whitespace model", model: audioString(" ")}, + {name: "four audio references", kind: "audio", files: []string{"a.wav", "b.wav", "c.wav", "d.wav"}}, + {name: "two images", kind: "image", files: []string{"a.png", "b.png"}}, + {name: "video", kind: "video", files: []string{"clip.mp4"}}, + {name: "unlisted extension", kind: "audio", files: []string{"audio.custom"}}, + {name: "format and zero rate", flags: []string{"--format", " NewFormat ", "--sample-rate", "0"}, config: map[string]any{"format": " NewFormat ", "sample_rate": float64(0)}}, {name: "three audio references", kind: "audio", files: []string{"one.wav", "two.mp3", "three.wav"}, flags: []string{"--format", "WAV", "--sample-rate", "24000", "--speech-rate", "1.2", "--loudness-rate", "0", "--pitch-rate", "-0.5", "--enable-timestamp=false"}, - config: map[string]any{"format": "wav", "sample_rate": float64(24000), "speech_rate": 1.2, "loudness_rate": float64(0), "pitch_rate": -0.5, "enable_timestamp": false}}, + config: map[string]any{"format": "WAV", "sample_rate": float64(24000), "speech_rate": 1.2, "loudness_rate": float64(0), "pitch_rate": -0.5, "enable_timestamp": false}}, {name: "one image reference", kind: "image", files: []string{"scene.png"}, flags: []string{"--format", "ogg_opus"}, config: map[string]any{"format": "ogg_opus"}}, {name: "pcm", flags: []string{"--format", "pcm"}, config: map[string]any{"format": "pcm"}}, - {name: "mp3 timestamp", flags: []string{"--model", " seedaudio_1.0 ", "--format", "mp3", "--enable-timestamp"}, config: map[string]any{"format": "mp3", "enable_timestamp": true}}, + {name: "mp3 timestamp", flags: []string{"--format", "mp3", "--enable-timestamp"}, config: map[string]any{"format": "mp3", "enable_timestamp": true}}, } { t.Run(tc.name, func(t *testing.T) { var uploads int @@ -65,11 +74,15 @@ func TestGenerateAudioRequest(t *testing.T) { t.Errorf("decode submit: %v", err) return } - if len(body) != 3 || body["agent_name"] != "pippit_audio_part_agent" || body["message"] != "温暖的旁白" { + if len(body) != 3 || body["agent_name"] != "pippit_audio_part_agent" || body["message"] != " 温暖的旁白 " { t.Errorf("unexpected submit body: %#v", body) } param, ok := body["audio_part_tool_param"].(map[string]any) - if !ok || param["model"] != "seedaudio_1.0" || param["prompt"] != body["message"] { + wantModel := "seedaudio_1.0" + if tc.model != nil { + wantModel = *tc.model + } + if !ok || param["model"] != wantModel || param["prompt"] != body["message"] { t.Errorf("unexpected audio params: %#v", param) } if tc.config == nil { @@ -99,6 +112,9 @@ func TestGenerateAudioRequest(t *testing.T) { })) defer server.Close() args := []string{"generate-audio", "--prompt", " 温暖的旁白 "} + if tc.model != nil { + args = append(args, "--model", *tc.model) + } for _, name := range tc.files { path := filepath.Join(t.TempDir(), name) if err := os.WriteFile(path, []byte("reference data"), 0o600); err != nil { @@ -126,14 +142,7 @@ func TestGenerateAudioRejectsInvalidInputsBeforeHTTP(t *testing.T) { args []string }{ {"prompt", "--prompt", []string{}}, - {"model", "--model 仅支持", []string{"--prompt", "x", "--model", "seedaudio_1.5"}}, - {"mixed", "不能混用", []string{"--prompt", "x", "--audio", "a.wav", "--image", "b.png"}}, - {"four audio", "最多支持", []string{"--prompt", "x", "--audio", "a.wav", "--audio", "b.wav", "--audio", "c.wav", "--audio", "d.wav"}}, - {"two images", "最多支持", []string{"--prompt", "x", "--image", "a.png", "--image", "b.png"}}, - {"extension", "不支持文件后缀", []string{"--prompt", "x", "--audio", "a.mp4"}}, - {"missing file", "上传文件不存在", []string{"--prompt", "x", "--audio", filepath.Join(t.TempDir(), "missing.wav")}}, - {"format", "--format", []string{"--prompt", "x", "--format", "exe"}}, - {"sample rate", "正整数", []string{"--prompt", "x", "--sample-rate", "0"}}, + {"missing file", "读取参考文件失败", []string{"--prompt", "x", "--audio", filepath.Join(t.TempDir(), "missing.wav")}}, {"nan", "有限数值", []string{"--prompt", "x", "--speech-rate", "NaN"}}, {"infinity", "有限数值", []string{"--prompt", "x", "--pitch-rate", "+Inf"}}, {"duration", "未知参数", []string{"--prompt", "x", "--duration", "5"}}, @@ -167,3 +176,5 @@ func TestGenerateAudioServerFailurePreservesLogID(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func audioString(value string) *string { return &value } diff --git a/internal/generate_audio/generate_audio.go b/internal/generate_audio/generate_audio.go index e9e1c72..4d250d7 100644 --- a/internal/generate_audio/generate_audio.go +++ b/internal/generate_audio/generate_audio.go @@ -1,137 +1,118 @@ package generate_audio import ( + "bytes" "context" + "encoding/json" "fmt" - "math" - "path/filepath" + "io" + "os" "strings" "github.com/Pippit-dev/pippit-cli/internal/common" ) +// DefaultModel is retained only for calls using legacy convenience flags. const DefaultModel = "seedaudio_1.0" -// Options describes a Seed Audio 1.0 request. Unset output settings use server defaults. +// Options keeps the audio parameter object independent of the request envelope. type Options struct { - Prompt string - Model string - AudioPaths []string - ImagePaths []string - AudioConfig *AudioConfig + Parameters map[string]json.RawMessage + LocalReferences []LocalReference } -type AudioConfig struct { - Format string `json:"format,omitempty"` - SampleRate *int32 `json:"sample_rate,omitempty"` - SpeechRate *float64 `json:"speech_rate,omitempty"` - LoudnessRate *float64 `json:"loudness_rate,omitempty"` - PitchRate *float64 `json:"pitch_rate,omitempty"` - EnableTimestamp *bool `json:"enable_timestamp,omitempty"` -} - -type audioReference struct { - Type string `json:"type"` - PippitAssetID string `json:"pippit_asset_id"` -} - -type audioPartToolParam struct { - Prompt string `json:"prompt"` - Model string `json:"model"` - AudioConfig *AudioConfig `json:"audio_config,omitempty"` - References []audioReference `json:"references,omitempty"` +type LocalReference struct { + Type string + Path string } func Run(ctx context.Context, opts *Options, runner *common.Runner) (*common.SubmitRunResult, error) { if runner == nil || runner.Client == nil { return nil, fmt.Errorf("generate-audio 运行器客户端缺失") } - if err := ValidateOptions(opts); err != nil { - return nil, err + if opts == nil { + return nil, fmt.Errorf("generate-audio 参数缺失") } - refs := make([]audioReference, 0, len(opts.AudioPaths)+len(opts.ImagePaths)) - for _, group := range []struct { - kind string - paths []string - }{{"audio", opts.AudioPaths}, {"image", opts.ImagePaths}} { - for _, path := range group.paths { - expanded, err := common.ExpandPath(path) - if err != nil { - return nil, err - } - upload, err := common.UploadFile(ctx, common.UploadFileOptions{Path: expanded}, runner) - if err != nil { - return nil, fmt.Errorf("上传音频生成参考素材失败: %w", err) + params := make(map[string]json.RawMessage, len(opts.Parameters)+1) + for key, value := range opts.Parameters { + params[key] = value + } + var refs []json.RawMessage + if len(opts.LocalReferences) > 0 { + if raw, exists := params["references"]; exists { + if !bytes.HasPrefix(bytes.TrimSpace(raw), []byte("[")) || json.Unmarshal(raw, &refs) != nil { + return nil, fmt.Errorf("追加本地参考素材时,references 必须是 JSON 数组") } - refs = append(refs, audioReference{Type: group.kind, PippitAssetID: upload.AssetID}) } } - model := strings.TrimSpace(opts.Model) - if model == "" { - model = DefaultModel + // Check every local file before the first upload. UploadFile still checks each + // file when used, since a file can change after this preflight. + paths := make([]string, len(opts.LocalReferences)) + for i, ref := range opts.LocalReferences { + path, err := readableReferencePath(ref.Path) + if err != nil { + return nil, fmt.Errorf("--%s: %w", ref.Type, err) + } + paths[i] = path } - var config *AudioConfig - if opts.AudioConfig != nil { - value := *opts.AudioConfig - value.Format = strings.ToLower(strings.TrimSpace(value.Format)) - config = &value + for i, ref := range opts.LocalReferences { + upload, err := common.UploadFile(ctx, common.UploadFileOptions{Path: paths[i]}, runner) + if err != nil { + return nil, fmt.Errorf("上传音频生成参考素材失败: %w", err) + } + raw, err := json.Marshal(map[string]string{"type": ref.Type, "pippit_asset_id": upload.AssetID}) + if err != nil { + return nil, err + } + refs = append(refs, raw) + } + if len(opts.LocalReferences) > 0 { + raw, err := json.Marshal(refs) + if err != nil { + return nil, err + } + params["references"] = raw } return common.SubmitRun(ctx, "generate-audio", map[string]any{ - "agent_name": "pippit_audio_part_agent", - "message": strings.TrimSpace(opts.Prompt), - "audio_part_tool_param": audioPartToolParam{ - Prompt: strings.TrimSpace(opts.Prompt), Model: model, - AudioConfig: config, References: refs, - }, + "agent_name": "pippit_audio_part_agent", + "message": requestMessage(params), + "audio_part_tool_param": params, }, runner) } -func ValidateOptions(opts *Options) error { - if opts == nil || strings.TrimSpace(opts.Prompt) == "" { - return fmt.Errorf("缺少必填参数 --prompt") +func requestMessage(params map[string]json.RawMessage) string { + for _, key := range []string{"prompt", "text", "task_type"} { + var value string + if json.Unmarshal(params[key], &value) == nil && strings.TrimSpace(value) != "" { + if key == "task_type" { + return "音频任务:" + value + } + return value + } } - if model := strings.TrimSpace(opts.Model); model != "" && model != DefaultModel { - return fmt.Errorf("--model 仅支持 %s", DefaultModel) + return "音频生成任务" +} + +func readableReferencePath(value string) (string, error) { + path, err := common.ExpandPath(value) + if err != nil { + return "", err } - if len(opts.AudioPaths) > 0 && len(opts.ImagePaths) > 0 { - return fmt.Errorf("--audio 与 --image 不能混用") + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("读取参考文件失败: %w", err) } - if len(opts.AudioPaths) > 3 || len(opts.ImagePaths) > 1 { - return fmt.Errorf("最多支持 3 个参考音频或 1 张参考图片") + if !info.Mode().IsRegular() { + return "", fmt.Errorf("参考路径 %q 不是普通文件", path) } - for _, group := range []struct { - flag string - paths []string - allowed []string - }{ - {"--audio", opts.AudioPaths, []string{".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus"}}, - {"--image", opts.ImagePaths, []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"}}, - } { - allowed := common.StringSet(group.allowed) - for _, path := range group.paths { - ext := strings.ToLower(filepath.Ext(strings.TrimSpace(path))) - if _, ok := allowed[ext]; !ok { - return fmt.Errorf("%s 不支持文件后缀 %q;支持:%s", group.flag, ext, strings.Join(group.allowed, ", ")) - } - } + file, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("打开参考文件失败: %w", err) } - if config := opts.AudioConfig; config != nil { - switch strings.ToLower(strings.TrimSpace(config.Format)) { - case "", "mp3", "wav", "pcm", "ogg_opus": - default: - return fmt.Errorf("--format 仅支持 mp3、wav、pcm 或 ogg_opus") - } - if config.SampleRate != nil && *config.SampleRate <= 0 { - return fmt.Errorf("--sample-rate 必须为正整数") - } - for _, setting := range []struct { - flag string - value *float64 - }{{"--speech-rate", config.SpeechRate}, {"--loudness-rate", config.LoudnessRate}, {"--pitch-rate", config.PitchRate}} { - if setting.value != nil && (math.IsNaN(*setting.value) || math.IsInf(*setting.value, 0)) { - return fmt.Errorf("%s 必须为有限数值", setting.flag) - } - } + defer file.Close() + var sample [1]byte + if _, err := file.Read(sample[:]); err != nil && err != io.EOF { + return "", fmt.Errorf("读取参考文件失败: %w", err) } - return nil + return path, nil } diff --git a/skills/xyq-nest-skill/SKILL.md b/skills/xyq-nest-skill/SKILL.md index e491de5..e9dde65 100644 --- a/skills/xyq-nest-skill/SKILL.md +++ b/skills/xyq-nest-skill/SKILL.md @@ -27,7 +27,7 @@ metadata: | 查看登录状态、登录、退出或切换账号 | `status` / `login` / `logout` | [授权](commands/auth.md) | | 创建或查询小云雀个人画布,编辑节点、布局、连线、角色/场景、提示词、3D 或多轨草稿 | `canvas` | [Canvas 能力与命令发现](commands/canvas.md) | | 生成图片,或基于参考图修改图片 | `generate-image` | [生图与图片编辑](commands/generate-image.md) | -| 生成音频,使用参考音频;参考图生成尚未验收 | `generate-audio` | [生音频](commands/generate-audio.md) | +| 生成音频、透传音频模型参数、上传本地音频/图/视频参考 | `generate-audio` | [生音频](commands/generate-audio.md) | | 生成视频,使用图/视频/音频参考,首尾帧生视频 | `generate-video` | [生视频](commands/generate-video.md) | | 提升已有视频分辨率、视频超分 | `video-super-resolution` | [超分](commands/video-super-resolution.md) | | 去除已有视频字幕 | `erase-video-subtitle` | [擦字幕](commands/erase-video-subtitle.md) | diff --git a/skills/xyq-nest-skill/commands/generate-audio.md b/skills/xyq-nest-skill/commands/generate-audio.md index c5e06ae..1191119 100644 --- a/skills/xyq-nest-skill/commands/generate-audio.md +++ b/skills/xyq-nest-skill/commands/generate-audio.md @@ -1,44 +1,56 @@ # generate-audio:生成音频 -使用 Seed Audio 1.0 生成音频,可以提供参考音频。当前仅支持 `seedaudio_1.0`,不能切换到其他音频模型;其他模型和模式尚未接入,不代表已覆盖网页端的全部音频能力。 +将音频模型参数提交给服务端。模型和参数组合由服务端校验,CLI 不维护模型白名单,不自动切换模型、补充模型专属配置或修改用户原始描述。通用 JSON 能发送一个字段,不代表服务端已经支持它;未定义字段可能被当前服务端协议忽略。 -单张参考图的上传和任务提交链路已接通,但尚未通过真实音频生成验收,目前不能承诺稳定可用。用户要求参考图生成时先说明这一边界;提交成功不代表已生成音频,最终以原任务的查询结果为准。 - -用户想给视频增加背景音乐时,先确定要生成音频素材还是编辑现有视频;本命令仅返回音频任务,不自动合成视频。 +用户想给视频增加背景音乐时,先确定要生成音频素材还是编辑现有视频;本命令仅返回音频任务,不自动合成视频。参考图的上传和提交链路已接通,但尚未通过真实音频生成验收,不能承诺稳定可用;最终以原任务查询结果为准。 ## 输入与参数 -| 参数 | 必填 | 规则 | -| --- | --- | --- | -| `--prompt` | 是 | 用户原始描述,不能全为空白;在这里完整描述生成内容 | -| `--model` | 否 | 默认且仅支持 `seedaudio_1.0`,不支持切换模型 | -| `--audio` | 否 | 本地参考音频路径,最多重复 3 次,保持顺序;不能与 `--image` 混用 | -| `--image` | 否 | 本地参考图路径,至多 1 张;不能与 `--audio` 混用;生成能力尚未通过真实验收 | -| `--format` | 否 | `mp3`、`wav`、`pcm`、`ogg_opus` | -| `--sample-rate` | 否 | 采样率,单位 Hz,正整数 | -| `--speech-rate` / `--loudness-rate` / `--pitch-rate` | 否 | Seed Audio 1.0 的语速、响度、音调参数,必须为有限数值,具体范围由服务端决定 | -| `--enable-timestamp` | 否 | 请求时间戳;显式关闭可用 `--enable-timestamp=false` | +| 参数 | 规则 | +| --- | --- | +| `--input` | 单个 `audio_part_tool_param` JSON 对象,与 `--file` 互斥 | +| `--file` | JSON 文件路径,或 `-` 从 stdin 读取;与 `--input` 互斥 | +| `--prompt` | 用户原始描述,原样写入 prompt;与 JSON prompt 冲突 | +| `--model` | 原样写入 model,由服务端判断支持情况;与 JSON model 冲突 | +| `--audio` / `--image` / `--video` | 本地可读文件路径,可重复;按命令行顺序追加到 JSON references 后面 | +| `--format` | 旧 `audio_config.format`,不转换大小写或校验格式枚举 | +| `--sample-rate` | 旧 `audio_config.sample_rate`,接受 int32 | +| `--speech-rate` / `--loudness-rate` / `--pitch-rate` | 旧 audio_config 对应字段,只检查有限数值,不复制模型数值范围 | +| `--enable-timestamp` | 旧 `audio_config.enable_timestamp`;显式 false 会保留 | + +只传用户指定的可选配置。不用 JSON 且未显式提供 model 时,历史兼容默认值为 `seedaudio_1.0`,不是白名单;显式 model 原样发送,不修剪或替换空字符串。使用 `--input` 或 `--file` 时不补默认模型,不补 prompt/text;缺省语义由服务端决定。 -只传用户指定的可选配置;缺省交给服务端。不能套用新模型的参数范围,不提供独立 `--text`、精确时长、分轨、翻配或其他音频模型能力,也不通过填入图片、视频模型来提交音频。 +旧配置 flags 不会按模型自动转换成 `audio_config_v2` 或 `output_format`。V2 配置、任务模式、翻配参数、输出选择等通过 JSON 的 `audio_config_v2`、`output_format`、`task_type`、`dubbing_config`、`include` 等字段表达。取值来自用户或已核实的服务端契约,不能从其他模型照搬;不要把透传能力描述成所有网页端模式均已支持或所有配置已验证。 -本地校验尚未逐项对齐网页端:采样率只检查正整数,调音只检查有限数值;没有按音频模型校验 prompt 长度、参考文件大小和时长。不能据此推断模型允许任意数值或素材,也不能把未通过服务端验证的输入宣称为支持。 +JSON 必须是单个对象,最多 64 MiB,不能有重复键或尾随内容。对象只放音频参数,不含外层 agent_name/message/audio_part_tool_param 包装、鉴权、团队或请求头。CLI 固定外层 agent 身份,用非空 prompt、text 或 task_type 生成 message;没有文本时使用中性任务描述,不写回 JSON 参数。 -音频文件后缀支持 `.mp3/.wav/.m4a/.aac/.flac/.ogg/.opus`,图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`。素材由 CLI 上传,实际可用性由服务端检查。远程 URL 不能替代本地路径。 +## 合并与参考素材 + +- 同一个字段同时由 JSON 和显式 flag 提供时直接报错,不比较值、不覆盖。旧配置 flags 可补充 audio_config 中不存在的字段;audio_config 不是对象时不能合并。 +- JSON references 保留原始顺序和字段;已有 `pippit_asset_id`、`speaker` 直接通过 JSON 提供,不重新上传。speaker 是引用项字段,不是本地文件路径。 +- `--audio`、`--image`、`--video` 上传后生成 type/pippit_asset_id 引用,按命令行出现顺序追加。CLI 不去重、不删引用;需要追加时,JSON references 必须是数组。 +- 所有本地文件均在首次上传前检查可读性。远程 URL 不能代替本地路径。素材类型、数量、混用和 speaker 组合是否合法由服务端判断,CLI 不复制前端常量。 ## 最小调用 +传统调用保留默认模型,只发送用户给出的配置: + ```bash -pippit-tool-cli generate-audio --prompt "用户原始描述" +pippit-tool-cli generate-audio --prompt "用户原始描述" --audio "./reference.wav" --format wav --sample-rate 24000 ``` -如果用户提供了参考音频、输出格式与采样率: +JSON 直接表示音频参数对象,可与不冲突的便捷 flags 组合: ```bash -pippit-tool-cli generate-audio --prompt "用户原始描述" --audio "./reference.wav" --format wav --sample-rate 24000 +pippit-tool-cli generate-audio --input '{"model":"seedaudio_1.0","audio_config":{"format":"wav"}}' --prompt "用户原始描述" +pippit-tool-cli generate-audio --file "./audio-params.json" +pippit-tool-cli generate-audio --file - < "./audio-params.json" ``` +已有引用与本地素材组合时,把完整有序 references 写入参数文件,再追加本地素材。只有用户确实要求这些参考,且目标模型/模式支持该组合时才提交。 + ## 返回与处理 -成功返回 JSON 中的 `thread_id`、`run_id`、`web_thread_link`,随后执行 [异步结果与媒体交付](../workflows/async-delivery.md)。最终音频位于查询结果的 `audios[]`,逐项交付 `output_path` 对应文件。请求时间戳不保证查询命令返回独立字幕文件。 +成功提交返回 JSON 中的 `thread_id`、`run_id`、`web_thread_link`,随后执行 [异步结果与媒体交付](../workflows/async-delivery.md)。最终音频位于 `audios[]`,逐项交付 output_path 对应文件;提交成功不是生成成功。请求时间戳不保证查询命令返回独立字幕文件,实际 duration 不等于精确时长控制能力。 -模型不支持、引用非法、鉴权或生成失败时说明真实错误,不改成视频请求、不自动切换模型、不重复提交未知结果的任务。 +模型不支持、引用非法、鉴权或生成失败时说明真实错误,不改成视频请求、不自动切换模型、不重复提交未知结果的任务。查询报错与已确认的失败终态按 [查询契约](query-result.md) 区分。 diff --git a/skills/xyq-nest-skill/tests/agent_test_cases.md b/skills/xyq-nest-skill/tests/agent_test_cases.md index c20357e..17fc258 100644 --- a/skills/xyq-nest-skill/tests/agent_test_cases.md +++ b/skills/xyq-nest-skill/tests/agent_test_cases.md @@ -11,14 +11,17 @@ | 仅咨询 | 怎么生成一张猫咪图片? | 解释命令,不发起收费生成 | | 图片编辑 | 图1是底图,图2只提供猫形象,替换猫但保留背景;已指定模型 | 两次 --image 顺序与用户角色一致,原始指令不改写 | | 生图比例 | 用 seedream_5.0_pro,16:9 生图 | --ratio 2;不把视频比例字符串用在生图命令中 | -| 普通生音频 | 用温暖自然的声音介绍今天的旅行 | 读取生音频模块与异步交付流程,保留 prompt,使用默认 seedaudio_1.0;不询问不存在的模型选项,不补可选配置 | -| 音频缺少描述 | 只要求生成音频,未提供生成内容或 prompt 全为空白 | 先补齐生成描述,不提交空 prompt | +| 普通生音频 | 用温暖自然的声音介绍今天的旅行 | 读取生音频模块与异步交付流程,保留 prompt;未给模型的传统 flags 调用保留历史默认 seedaudio_1.0,不补可选配置 | +| 音频缺少描述 | 只要求生成音频,未提供内容或模式 | 先补齐创作意图,不用中性 message 代替用户创作描述;已明确无需 prompt 的模式按服务端契约执行 | | 音频参考与配置 | 参考三个本地音频生成旁白,输出 WAV、24000 Hz | 按用户顺序传三个 --audio,--format wav、--sample-rate 24000;不添加其他配置 | | 音频误用视频命令 | 为视频生成一段背景音乐素材 | 走 generate-audio,不用 generate-video;仅交付音频,不声称已把音乐合成到视频 | -| 音频混合参考 | 同时提供音频和图片作为音频生成参考 | 说明两类参考不能混用,先确认使用哪类;不上传、不擅自丢弃参考素材 | -| 不支持的音频模型 | 明确要求使用其他音频模型或切换模型 | 说明当前仅支持 seedaudio_1.0;不提交其他模型,不擅自改用默认模型或视频模型 | +| 音频组合参考 | 用户提供已有资产、speaker 和本地音频/图/视频参考 | JSON references 保序,再按 flags 顺序追加本地文件;不套用其他模型混用规则,不擅自删减素材,实际合法性由服务端判断 | +| 指定音频模型 | 用户明确提供音频模型名 | 模型字符串原样透传,不用本地白名单拦截,不改成默认模型;服务端拒绝时报告真实错误,不降级重交 | | 音频参考图 | 用本地图片创作音频 | 说明上传与提交链路已接通,但真实生成尚未验收,不承诺稳定可用;执行时只提交一次并查询原任务,不因失败自动换模型或重交 | -| 未接入的音频模式 | 要求精确时长、分轨或翻配 | 说明当前命令不提供这些能力,不套用其他模型或网页端参数 | +| 模型专属音频参数 | 用户给定 V2 配置、分轨或翻配参数 | 使用裸音频参数 JSON;不把旧 rates flags 自动映射到 V2,不伪造取值或承诺服务端支持所有模式 | +| JSON 无 prompt | 用户已给合法的无需 prompt 的任务参数 | 保留 prompt/text 缺省和显式 0/false/null,不补默认模型或虚构生成文本;仅外层 message 使用任务描述 | +| 参数来源冲突 | JSON model 与 --model 同时出现,或 audio_config.format 与 --format 同时出现 | 上传前明确报错,请用户保留一个来源,不静默覆盖;--input 和 --file 也不能混用 | +| 后续本地文件不可读 | 首个参考文件正常,后续文件缺失或不可读 | 首次上传前失败,不产生部分上传或提交 | | 普通生视频 | 生成一个猫咪跳舞的视频 | 读取生视频模块,不要求用户说“模型直出”,未指定模型时省略 | | 视频参数 | 用 Seedance_2.5,9:16,720p,8 秒生成猫咪视频 | 保留 prompt,--ratio 9:16、--duration 8,不改成整数比例或时长范围 | | 时长范围 | 生成一个 5 到 10 秒的视频 | 先询问具体秒数,不臆造范围参数 | From e08e230ced89a0a2d02b0f396daf12029775f336 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Sun, 20 Sep 2026 17:33:13 +0800 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E6=A8=A1=E5=BC=8F=E5=AE=9E=E6=B5=8B=E4=B8=8E=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E7=B4=A0=E6=9D=90=E8=A6=81=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- README.md | 4 ++- .../xyq-nest-skill/commands/generate-audio.md | 33 +++++++++++++++++-- .../xyq-nest-skill/tests/agent_test_cases.md | 3 ++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2240e9a..f42e850 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,8 @@ pippit-tool-cli generate-image \ `generate-audio` 将音频参数交给服务端校验和执行。CLI 不维护模型、输出格式、参考数量或混用规则的白名单,也不会在失败时切换模型。已接入参数不代表所有模型和模式都可用,实际支持范围以服务端为准。 +真实用例已验证 `seedaudio_1.0` 的 JSON 生成与下载,以及 `seedaudio_1.5` 的 `reference`、`separate` 和合规视频翻配 `dubbing`;翻配用例返回并下载了音频和视频。1.0 参考图用例仍未成功。具体结果和验证边界见 [音频命令说明](skills/xyq-nest-skill/commands/generate-audio.md#已验证范围)。这些结果不是模型白名单,也不代表所有素材与参数组合均可用;模型到生成服务的映射由服务端配置决定,CLI 不改写 model。 + 原有便捷 flags 保留;不用 JSON 且没有显式指定 `--model` 时,兼容默认值仍为 `seedaudio_1.0`。显式 model 按原值发送,包括空字符串和空白,不修剪或替换。JSON 输入模式不添加默认模型,由服务端解释缺省值。 ```bash @@ -242,7 +244,7 @@ JSON 中的已提供字段、未知字段、数值精度、`null`、`0` 和 `fal 本地素材参数接收可读的普通文件路径,不接收远程 URL。CLI 会在开始上传前检查所有本地文件;文件类型、参考组合、speaker 与资产 ID 的兼容性仍由服务端判断。参考图的上传与提交链路已接通,但尚未通过真实音频生成验收,不能承诺稳定可用。 -外层请求始终使用 `agent_name=pippit_audio_part_agent`;JSON 不能修改 agent、鉴权、团队或外层协议字段。`message` 优先取非空 prompt,其次 text、task_type 描述;均无内容时使用中性任务描述,不改写音频参数。任务提交成功返回 `thread_id`、`run_id`、`web_thread_link`,再用 `query-result` 查询和下载;提交成功不等于生成成功。命令不会自动把音频合成到视频,也不把返回的 duration 当作精确时长控制的保证。 +外层请求始终使用 `agent_name=pippit_audio_part_agent`;JSON 不能修改 agent、鉴权、团队或外层协议字段。`message` 优先取非空 prompt,其次 text、task_type 描述;均无内容时使用中性任务描述,不改写音频参数。任务提交成功返回 `thread_id`、`run_id`、`web_thread_link`,再用 `query-result` 查询和下载;提交成功不等于生成成功。结果按模式包含音频或视频,例如 dubbing 请求 `include=["video_url"]` 可返回翻配视频;普通音频生成不会自动把产物配回任意源视频。返回的 duration 也不代表精确时长控制能力。 ## 生视频 CLI diff --git a/skills/xyq-nest-skill/commands/generate-audio.md b/skills/xyq-nest-skill/commands/generate-audio.md index 1191119..0ccec0c 100644 --- a/skills/xyq-nest-skill/commands/generate-audio.md +++ b/skills/xyq-nest-skill/commands/generate-audio.md @@ -2,7 +2,22 @@ 将音频模型参数提交给服务端。模型和参数组合由服务端校验,CLI 不维护模型白名单,不自动切换模型、补充模型专属配置或修改用户原始描述。通用 JSON 能发送一个字段,不代表服务端已经支持它;未定义字段可能被当前服务端协议忽略。 -用户想给视频增加背景音乐时,先确定要生成音频素材还是编辑现有视频;本命令仅返回音频任务,不自动合成视频。参考图的上传和提交链路已接通,但尚未通过真实音频生成验收,不能承诺稳定可用;最终以原任务查询结果为准。 +用户想给视频增加背景音乐时,先确定要生成音频素材还是编辑现有视频。结果按模式可包含音频和视频,例如 dubbing 可返回翻配视频;普通音频生成不会自动把产物配回任意源视频。参考图的上传和提交链路已接通,但尚未通过真实音频生成验收,不能承诺稳定可用;最终以原任务查询结果为准。 + +## 已验证范围 + +以下为已执行真实用例的结果,不是模型白名单,也不代表全部输入和参数组合均可用。模型到生成服务的映射由服务端配置决定,CLI 原样发送用户的 model。 + +| 用例 | 实际结果 | +| --- | --- | +| `seedaudio_1.0`,JSON 参数 | 成功生成并下载 24 kHz WAV;三个 rate 的显式 0 和 enable_timestamp=false 在下游保留 | +| `seedaudio_1.5`,`task_type=reference` | 成功生成并下载 24 kHz、3 秒 WAV | +| `seedaudio_1.5`,`task_type=separate` | 成功生成并下载两份 3 秒 WAV;返回的单轨 duration 均为 6 秒,与文件实际时长不一致 | +| 未知模型;separate 缺少 prompt | 服务端明确拒绝,CLI 不改模型、不补造 prompt | +| `seedaudio_1.5`,`task_type=dubbing` | 使用符合模式要求的 6 秒、864×480、24 fps MP4,翻配英语并请求 video_url;成功返回并下载 1 个音频和 1 个视频 | +| `seedaudio_1.0`,参考图 | 用例失败,尚未取得成功生成结果;不能据此断言所有参考图必然失败 | + +reference 和 separate 的文件时长来自实际下载文件,不是精确时长控制承诺。separate 返回的单轨 duration 当前可能使用请求总时长,不能作为可靠的单轨时长;这个已知问题尚未修复,应以实际媒体文件为准。dubbing 早期失败用例的素材不符合目标模式要求;后续合规素材用例成功,不代表任意视频均可翻配。无 prompt 的参数对象能被 CLI 发送,也不等于目标模式允许省略 prompt。 ## 输入与参数 @@ -47,10 +62,24 @@ pippit-tool-cli generate-audio --file "./audio-params.json" pippit-tool-cli generate-audio --file - < "./audio-params.json" ``` +`seedaudio_1.5` 的 reference 模式使用 V2 参数,例如: + +```bash +pippit-tool-cli generate-audio --input '{"model":"seedaudio_1.5","task_type":"reference","prompt":"生成约3秒轻柔鸟鸣。","output_format":"wav","audio_config_v2":{"sample_rate":24000,"speech_rate":0,"loudness_rate":0,"pitch_rate":0},"watermark":false}' +``` + +英语翻配并请求视频产物,可将 JSON 参数与本地视频组合: + +```bash +pippit-tool-cli generate-audio --input '{"model":"seedaudio_1.5","task_type":"dubbing","dubbing_config":{"target_language":"en"},"include":["video_url"]}' --video "./source.mp4" +``` + +当前 `seedaudio_1.5` 视频输入要求:时长 4–360 秒,宽和高均为 300–6000 像素,宽×高为 407696–2086876 像素,宽高比 0.4–2.5,帧率 12–60 fps,格式为 MP4 或 MOV。这些是当前服务模式的素材要求,不是 CLI 硬编码的白名单;CLI 不复制这些限制,也不会修改视频来绕过服务端校验。 + 已有引用与本地素材组合时,把完整有序 references 写入参数文件,再追加本地素材。只有用户确实要求这些参考,且目标模型/模式支持该组合时才提交。 ## 返回与处理 -成功提交返回 JSON 中的 `thread_id`、`run_id`、`web_thread_link`,随后执行 [异步结果与媒体交付](../workflows/async-delivery.md)。最终音频位于 `audios[]`,逐项交付 output_path 对应文件;提交成功不是生成成功。请求时间戳不保证查询命令返回独立字幕文件,实际 duration 不等于精确时长控制能力。 +成功提交返回 JSON 中的 `thread_id`、`run_id`、`web_thread_link`,随后执行 [异步结果与媒体交付](../workflows/async-delivery.md)。音频位于 `audios[]`,视频位于 `videos[]`,逐项交付 output_path 对应文件;提交成功不是生成成功。请求时间戳不保证查询命令返回独立字幕文件,实际 duration 不等于精确时长控制能力。 模型不支持、引用非法、鉴权或生成失败时说明真实错误,不改成视频请求、不自动切换模型、不重复提交未知结果的任务。查询报错与已确认的失败终态按 [查询契约](query-result.md) 区分。 diff --git a/skills/xyq-nest-skill/tests/agent_test_cases.md b/skills/xyq-nest-skill/tests/agent_test_cases.md index 17fc258..951d64d 100644 --- a/skills/xyq-nest-skill/tests/agent_test_cases.md +++ b/skills/xyq-nest-skill/tests/agent_test_cases.md @@ -19,6 +19,9 @@ | 指定音频模型 | 用户明确提供音频模型名 | 模型字符串原样透传,不用本地白名单拦截,不改成默认模型;服务端拒绝时报告真实错误,不降级重交 | | 音频参考图 | 用本地图片创作音频 | 说明上传与提交链路已接通,但真实生成尚未验收,不承诺稳定可用;执行时只提交一次并查询原任务,不因失败自动换模型或重交 | | 模型专属音频参数 | 用户给定 V2 配置、分轨或翻配参数 | 使用裸音频参数 JSON;不把旧 rates flags 自动映射到 V2,不伪造取值或承诺服务端支持所有模式 | +| 音频验证状态 | 询问 reference、separate、dubbing 是否可用 | 按音频命令文档说明已成功的具体输入与模式,不扩大为所有组合均可用,不把验证表当模型白名单 | +| 视频翻配与交付 | 使用符合目标模式规则的视频翻配英语,并请求 video_url | 通过 JSON dubbing 参数与 --video 提交;查询成功后同时检查并交付 audios/videos,不只交音频,也不擅自修改不合规素材 | +| separate 缺少 prompt | 请求分轨但缺少该模式所需 prompt | 补齐用户描述,不以外层中性 message 替代;服务端拒绝时报告真实错误,不重交空参数 | | JSON 无 prompt | 用户已给合法的无需 prompt 的任务参数 | 保留 prompt/text 缺省和显式 0/false/null,不补默认模型或虚构生成文本;仅外层 message 使用任务描述 | | 参数来源冲突 | JSON model 与 --model 同时出现,或 audio_config.format 与 --format 同时出现 | 上传前明确报错,请用户保留一个来源,不静默覆盖;--input 和 --file 也不能混用 | | 后续本地文件不可读 | 首个参考文件正常,后续文件缺失或不可读 | 首次上传前失败,不产生部分上传或提交 | From 4c50669565271c3ce637600f7ef6c40a8d0acb91 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Sun, 20 Sep 2026 18:24:31 +0800 Subject: [PATCH 7/8] =?UTF-8?q?test:=20=E9=AA=8C=E8=AF=81=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E4=B8=8A=E4=BC=A0=E5=A4=B1=E8=B4=A5=E5=92=8C=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E9=87=8D=E8=AF=95=E5=B9=B6=E8=A1=A5=E5=85=85=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- cmd/generate_audio_input_test.go | 49 ++++++++++ cmd/query_audio_test.go | 97 +++++++++++++++++++ .../xyq-nest-skill/commands/generate-audio.md | 6 +- 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/cmd/generate_audio_input_test.go b/cmd/generate_audio_input_test.go index 9e35da2..52eab00 100644 --- a/cmd/generate_audio_input_test.go +++ b/cmd/generate_audio_input_test.go @@ -194,6 +194,55 @@ func TestGenerateAudioPreflightsAllLocalFiles(t *testing.T) { } } +func TestGenerateAudioStopsAfterSecondUploadFailure(t *testing.T) { + args := []string{"generate-audio", "--prompt", "use these references"} + for _, name := range []string{"first.wav", "second.wav", "third.wav"} { + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(name), 0o600); err != nil { + t.Fatal(err) + } + args = append(args, "--audio", path) + } + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/biz/v1/skill/upload_file" || r.Method != http.MethodPost { + calls = append(calls, r.URL.Path) + t.Errorf("upload failure must prevent later requests: %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected request", http.StatusBadRequest) + return + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Error(err) + return + } + defer r.MultipartForm.RemoveAll() + files := r.MultipartForm.File["file"] + if len(files) != 1 { + t.Errorf("uploaded files=%d, want one", len(files)) + return + } + calls = append(calls, files[0].Filename) + if files[0].Filename == "second.wav" { + http.Error(w, "upload unavailable", http.StatusServiceUnavailable) + return + } + io.WriteString(w, `{"ret":"0","data":{"pippit_asset_id":"first_asset"}}`) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs(args) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "上传音频生成参考素材失败") || !strings.Contains(err.Error(), "HTTP 503") { + t.Fatalf("error=%v, want second upload HTTP failure", err) + } + if got := strings.Join(calls, ","); got != "first.wav,second.wav" { + t.Fatalf("requests=%s, want only the first and second uploads", got) + } + if stdout.Len() != 0 { + t.Fatalf("failed upload must not print a submitted task: %s", stdout.String()) + } +} + func rejectAudioBeforeHTTP(t *testing.T, args []string, want string) { t.Helper() path := filepath.Join(t.TempDir(), "readable.wav") diff --git a/cmd/query_audio_test.go b/cmd/query_audio_test.go index cc8441d..14a8300 100644 --- a/cmd/query_audio_test.go +++ b/cmd/query_audio_test.go @@ -2,10 +2,12 @@ package cmd import ( "bytes" + "crypto/sha256" "encoding/json" "io" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -150,6 +152,101 @@ func TestQueryResultAudioDownloadErrorsKeepTaskIDs(t *testing.T) { } } +func TestQueryResultRetriesOnlyFailedAudioDownload(t *testing.T) { + var calls []string + secondDownloads := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.URL.Path) + switch r.URL.Path { + case "/api/biz/v1/skill/get_thread": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + if r.Method != http.MethodPost || body["thread_id"] != "thread_123" || body["run_id"] != "run_456" { + t.Errorf("query must retain the original task: %s %#v", r.Method, body) + } + var content []any + for _, id := range []string{"first", "second"} { + content = append(content, map[string]any{ + "sub_type": "biz/x_data_audio", "data": map[string]any{"audio": map[string]any{ + "url": serverURL(r) + "/" + id + ".wav", "pippit_asset_id": id, + }}, + }) + } + writeAudioQueryFixture(w, map[string]any{"run_id": "run_456", "state": 3, "entry_list": []any{ + map[string]any{"artifact": map[string]any{"content": content}}, + }}) + case "/first.wav", "/second.wav": + if r.Method != http.MethodGet { + t.Errorf("download method=%s, want GET", r.Method) + } + if r.URL.Path == "/second.wav" { + secondDownloads++ + if secondDownloads == 1 { + http.Error(w, "download unavailable", http.StatusForbidden) + return + } + } + io.WriteString(w, r.URL.Path) + default: + t.Errorf("query retry must not upload or submit: %s", r.URL.Path) + http.Error(w, "unexpected request", http.StatusBadRequest) + } + })) + defer server.Close() + dir := t.TempDir() + first := runAudioQuery(t, server.URL, dir) + if first["completed"] != false || !strings.Contains(first["error_message"].(string), "下载失败") || first["thread_id"] != "thread_123" || first["run_id"] != "run_456" { + t.Fatalf("failed download must retain task IDs and report incomplete delivery: %#v", first) + } + firstPath := filepath.Join(dir, "first.wav") + assertFileContent(t, firstPath, "/first.wav") + beforeData, err := os.ReadFile(firstPath) + if err != nil { + t.Fatal(err) + } + beforeInfo, err := os.Stat(firstPath) + if err != nil { + t.Fatal(err) + } + beforeSHA := sha256.Sum256(beforeData) + secondPath := filepath.Join(dir, "second.wav") + if _, err := os.Stat(secondPath); !os.IsNotExist(err) { + t.Fatalf("failed download left a final output: err=%v", err) + } + second := runAudioQuery(t, server.URL, dir) + if second["completed"] != true || second["error_message"] != "" || second["thread_id"] != first["thread_id"] || second["run_id"] != first["run_id"] { + t.Fatalf("same task should finish delivery on retry: %#v", second) + } + audios, ok := second["audios"].([]any) + if !ok || len(audios) != 2 { + t.Fatalf("audios=%#v, want both downloaded tracks", second["audios"]) + } + for i, path := range []string{firstPath, secondPath} { + if got := audios[i].(map[string]any)["output_path"]; got != path { + t.Fatalf("audio %d output_path=%v, want %s", i, got, path) + } + } + assertFileContent(t, secondPath, "/second.wav") + afterData, err := os.ReadFile(firstPath) + if err != nil { + t.Fatal(err) + } + afterInfo, err := os.Stat(firstPath) + if err != nil { + t.Fatal(err) + } + if sha256.Sum256(afterData) != beforeSHA || !afterInfo.ModTime().Equal(beforeInfo.ModTime()) { + t.Fatal("retry changed the already downloaded first track's SHA or mtime") + } + wantCalls := "/api/biz/v1/skill/get_thread,/first.wav,/second.wav,/api/biz/v1/skill/get_thread,/second.wav" + if got := strings.Join(calls, ","); got != wantCalls { + t.Fatalf("requests=%s, want %s", got, wantCalls) + } +} + func TestQueryResultCanRetrySameRunAfterQueryBusinessError(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/skills/xyq-nest-skill/commands/generate-audio.md b/skills/xyq-nest-skill/commands/generate-audio.md index 0ccec0c..b622a1f 100644 --- a/skills/xyq-nest-skill/commands/generate-audio.md +++ b/skills/xyq-nest-skill/commands/generate-audio.md @@ -12,9 +12,11 @@ | --- | --- | | `seedaudio_1.0`,JSON 参数 | 成功生成并下载 24 kHz WAV;三个 rate 的显式 0 和 enable_timestamp=false 在下游保留 | | `seedaudio_1.5`,`task_type=reference` | 成功生成并下载 24 kHz、3 秒 WAV | +| `seedaudio_1.5`,MP3 与非默认参数 | 成功生成并下载 48 kHz MP3;语速 10、响度 5、音调 2 和 watermark=true 已确认传至生成服务;未验证调音的听感或水印检测效果 | | `seedaudio_1.5`,`task_type=separate` | 成功生成并下载两份 3 秒 WAV;返回的单轨 duration 均为 6 秒,与文件实际时长不一致 | | 未知模型;separate 缺少 prompt | 服务端明确拒绝,CLI 不改模型、不补造 prompt | | `seedaudio_1.5`,`task_type=dubbing` | 使用符合模式要求的 6 秒、864×480、24 fps MP4,翻配英语并请求 video_url;成功返回并下载 1 个音频和 1 个视频 | +| `seedaudio_1.5`,翻配词汇表与字幕 | 省略 source_language,传入词汇表及 video_url/subtitles.srt/subtitles_source.srt,成功生成音视频和两份 SRT;源字幕中文、目标英文,本例“你好”对应“Hello”。字幕由服务侧单独核验,CLI 尚不交付独立字幕文件 | | `seedaudio_1.0`,参考图 | 用例失败,尚未取得成功生成结果;不能据此断言所有参考图必然失败 | reference 和 separate 的文件时长来自实际下载文件,不是精确时长控制承诺。separate 返回的单轨 duration 当前可能使用请求总时长,不能作为可靠的单轨时长;这个已知问题尚未修复,应以实际媒体文件为准。dubbing 早期失败用例的素材不符合目标模式要求;后续合规素材用例成功,不代表任意视频均可翻配。无 prompt 的参数对象能被 CLI 发送,也不等于目标模式允许省略 prompt。 @@ -76,10 +78,12 @@ pippit-tool-cli generate-audio --input '{"model":"seedaudio_1.5","task_type":"du 当前 `seedaudio_1.5` 视频输入要求:时长 4–360 秒,宽和高均为 300–6000 像素,宽×高为 407696–2086876 像素,宽高比 0.4–2.5,帧率 12–60 fps,格式为 MP4 或 MOV。这些是当前服务模式的素材要求,不是 CLI 硬编码的白名单;CLI 不复制这些限制,也不会修改视频来绕过服务端校验。 +当前翻配示例省略 `source_language`,让服务识别源语言。实测显式传 `source_language=zh` 会被生成服务拒绝;CLI 不会擅自改成其它语言或删除用户指定值,失败时应报告该限制。 + 已有引用与本地素材组合时,把完整有序 references 写入参数文件,再追加本地素材。只有用户确实要求这些参考,且目标模型/模式支持该组合时才提交。 ## 返回与处理 -成功提交返回 JSON 中的 `thread_id`、`run_id`、`web_thread_link`,随后执行 [异步结果与媒体交付](../workflows/async-delivery.md)。音频位于 `audios[]`,视频位于 `videos[]`,逐项交付 output_path 对应文件;提交成功不是生成成功。请求时间戳不保证查询命令返回独立字幕文件,实际 duration 不等于精确时长控制能力。 +成功提交返回 JSON 中的 `thread_id`、`run_id`、`web_thread_link`,随后执行 [异步结果与媒体交付](../workflows/async-delivery.md)。音频位于 `audios[]`,视频位于 `videos[]`,逐项交付 output_path 对应文件;提交成功不是生成成功。当前 query-result 只交付音频、视频和图片;即使 include 请求并生成了字幕,也不会输出或下载独立字幕文件。实际 duration 不等于精确时长控制能力。 模型不支持、引用非法、鉴权或生成失败时说明真实错误,不改成视频请求、不自动切换模型、不重复提交未知结果的任务。查询报错与已确认的失败终态按 [查询契约](query-result.md) 区分。 From 2c5ddc883db3e9795b132a5cd33020985e13ad0a Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Mon, 21 Sep 2026 14:43:26 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E9=9A=94=E7=A6=BB=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=B9=B6=E4=BF=9D=E7=95=99=E6=97=A7=E5=9B=BE?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- README.md | 21 +- cmd/generate_audio/generate_audio.go | 3 +- cmd/generate_video/query_audio_result.go | 31 ++ cmd/generate_video/query_result.go | 14 +- cmd/generate_video_test.go | 6 +- cmd/query_audio_test.go | 2 +- cmd/query_result_compatibility_test.go | 98 ++++ internal/common/get_thread.go | 5 + internal/common/get_thread_test.go | 12 +- internal/generate_video/query_audio_result.go | 454 ++++++++++++++++++ internal/generate_video/query_result.go | 164 +------ internal/generate_video/query_result_test.go | 15 +- scripts/install-cli.test.js | 26 +- skills/xyq-nest-skill/SKILL.md | 3 +- .../xyq-nest-skill/commands/generate-audio.md | 8 +- .../xyq-nest-skill/commands/query-result.md | 22 +- skills/xyq-nest-skill/scripts/ensure-cli.js | 14 +- skills/xyq-nest-skill/scripts/install.md | 4 + .../xyq-nest-skill/tests/agent_test_cases.md | 10 +- .../workflows/async-delivery.md | 6 +- 20 files changed, 724 insertions(+), 194 deletions(-) create mode 100644 cmd/generate_video/query_audio_result.go create mode 100644 cmd/query_result_compatibility_test.go create mode 100644 internal/generate_video/query_audio_result.go diff --git a/README.md b/README.md index f42e850..f0da97f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ node /path/to/xyq-skill/scripts/ensure-cli.js Canvas 任务使用 `ensure-cli.js --canvas`,额外返回 `canvas_entry`;原生资产命令使用 `cli_path`,语义命令通过 `node "CANVAS_ENTRY" canvas command ...` 执行。检查会真实加载 npm 内的离线命令目录,避免把原生帮助误当作运行时已就绪。画布编辑使用独立的 [查询、编辑与回读流程](skills/xyq-nest-skill/workflows/canvas-edit.md),不套用媒体轮询。 +音频生成及其结果查询使用 `ensure-cli.js --audio`,额外检查音频生成命令和 `query-result --audio`。原有图视频等任务不要求音频能力,也不会为缺少音频命令而升级已有 CLI。 + 登录后选择生成或处理命令,统一接入 [异步结果与媒体交付](skills/xyq-nest-skill/workflows/async-delivery.md)。完整基础案例见 [生成一张图并交付](skills/xyq-nest-skill/examples/generate-and-deliver.md),组合案例由入口按需引导。 ### 模块维护 @@ -51,7 +53,7 @@ Canvas 任务使用 `ensure-cli.js --canvas`,额外返回 `canvas_entry`;原 - `SKILL.md` 维护能力边界、意图到命令的路由及必要执行规则。 - `commands/` 每个模块维护适用场景、必填与可选参数、最小调用、真实返回契约及失败处理;授权相关命令合并在同一文档。 - `workflows/` 维护共用轮询与媒体交付规则;`examples/` 展示基础完整流程及易混淆的组合场景,引用规则,不复制参数手册。 -- 新增 CLI 时补命令文档、入口路由、`ensure-cli.js` 必需命令集合和安装测试;声明是同步结果还是异步任务,是否需要附加运行时及其检查方式,按需接入交付流程,补正常、缺输入和易混淆场景用例。 +- 新增 CLI 时补命令文档、入口路由、`ensure-cli.js` 对应场景的命令检查和安装测试;声明是同步结果还是异步任务,是否需要附加运行时及其检查方式,按需接入交付流程,补正常、缺输入和易混淆场景用例。 - 文档使用 Skill 内相对链接,打包时保留结构。规范副本位于 `skills/xyq-nest-skill/`,项目发现入口 `.agents/skills/xyq-skill` 指向该目录。 - 修改后运行 `node scripts/skills.test.js` 与 `node scripts/install-cli.test.js`,检查引用完整、保留命令与安装检查一致及缺命令升级/缓存复用;Agent 行为用例见 [测试场景](skills/xyq-nest-skill/tests/agent_test_cases.md)。这些检查不代表真实生成已验证。 @@ -244,7 +246,7 @@ JSON 中的已提供字段、未知字段、数值精度、`null`、`0` 和 `fal 本地素材参数接收可读的普通文件路径,不接收远程 URL。CLI 会在开始上传前检查所有本地文件;文件类型、参考组合、speaker 与资产 ID 的兼容性仍由服务端判断。参考图的上传与提交链路已接通,但尚未通过真实音频生成验收,不能承诺稳定可用。 -外层请求始终使用 `agent_name=pippit_audio_part_agent`;JSON 不能修改 agent、鉴权、团队或外层协议字段。`message` 优先取非空 prompt,其次 text、task_type 描述;均无内容时使用中性任务描述,不改写音频参数。任务提交成功返回 `thread_id`、`run_id`、`web_thread_link`,再用 `query-result` 查询和下载;提交成功不等于生成成功。结果按模式包含音频或视频,例如 dubbing 请求 `include=["video_url"]` 可返回翻配视频;普通音频生成不会自动把产物配回任意源视频。返回的 duration 也不代表精确时长控制能力。 +外层请求始终使用 `agent_name=pippit_audio_part_agent`;JSON 不能修改 agent、鉴权、团队或外层协议字段。`message` 优先取非空 prompt,其次 text、task_type 描述;均无内容时使用中性任务描述,不改写音频参数。任务提交成功返回 `thread_id`、`run_id`、`web_thread_link`,再用 `query-result --audio` 查询和下载;提交成功不等于生成成功。结果按模式包含音频或视频,例如 dubbing 请求 `include=["video_url"]` 可返回翻配视频,该任务仍用 `--audio` 查询;普通音频生成不会自动把产物配回任意源视频。返回的 duration 也不代表精确时长控制能力。 ## 生视频 CLI @@ -309,7 +311,7 @@ pippit-tool-cli erase-video-subtitle \ 两个命令都输出 `thread_id`、`run_id` 和 `web_thread_link`。拿到任务 ID 后,可继续使用 `query-result` 查询并下载结果。 -查询并下载图片、视频或音频结果: +查询并下载生图/生视频结果: ```bash pippit-tool-cli query-result \ @@ -318,11 +320,18 @@ pippit-tool-cli query-result \ --download-dir "./output" ``` -`query-result` 会查询指定 Run 并输出 JSON。Run 成功后下载视频、图片和音频产物,`completed=true`;`videos`、`images`、`audios` 各项包含 `download_url` 和 `output_path`。音频还保留可用的 `name`、`pippit_asset_id`、`duration`(秒)。音频扩展名从已知格式的元数据、URL 路径或名称中获取;无法判断时使用 `.audio`,不猜测编码。图片格式缺省时仍使用 `.png`。 +`query-result` 会查询指定 Run 并输出 JSON。Run 成功完成后下载视频和图片产物,`completed=true`,`videos` 和 `images` 中各包含 `download_url` 和 `output_path`;图片扩展名取自产物 `metadata.format`,缺省时兜底 `.png`。Run 失败也视为终态,`completed=true` 且填充 `error_message`;Run 未到终态时 `completed=false`。 + +`generate-audio` 创建的任务使用显式音频查询模式,包含 dubbing 返回视频的情况: -Run 失败或取消均为终态,`completed=true` 且填充 `error_message`,保留服务端失败原因;尚未结束时 `completed=false`。无论退出码如何,都应先检查 `error_message`;下载失败后继续查询原任务,不重复提交生成。 +```bash +pippit-tool-cli query-result --audio \ + --thread-id "skill_xxx" \ + --run-id "skill_xxx" \ + --download-dir "./audio-output" +``` -API 错误响应只有包含与请求 Run ID 匹配的结构化失败或取消状态,才视为已结束;否则返回 `completed=false` 和“查询失败:”错误,保留 LogID 与任务 ID,排障后继续查询同一任务。 +`--audio` 增加 `audios` 数组并下载音频及同任务中的视频、图片,保留可用的音频名称、资产 ID 和 duration。该模式对 API 错误响应要求匹配的结构化失败或取消状态,才认定 Run 已结束;缺少匹配终态时返回 `completed=false` 和“查询失败:”错误,保留 LogID 与任务 ID。音频扩展名未知时使用 `.audio`,不猜测编码。默认查询保留原有图视频输出字段、错误判断和下载行为,不自动按产物推断查询模式。无论使用哪个模式,都先检查 `error_message`;下载失败后继续查询原任务,不重复提交生成。 ## HTTP 客户端 diff --git a/cmd/generate_audio/generate_audio.go b/cmd/generate_audio/generate_audio.go index d5af255..f22bff2 100644 --- a/cmd/generate_audio/generate_audio.go +++ b/cmd/generate_audio/generate_audio.go @@ -23,7 +23,8 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command Short: "Generate audio with model parameters validated by the service", Long: "Generate audio using convenience flags or an audio_part_tool_param JSON object via --input or --file (use - for stdin). Models and parameter combinations are validated by the service. Explicit values are passed unchanged.\n\n" + "JSON mode does not add a model default. Calls without JSON retain seedaudio_1.0 as a compatibility default, not a model allowlist. Legacy output flags only populate audio_config; use JSON for other configurations and modes.\n\n" + - "Conflicting JSON fields and explicit flags are rejected. Local audio/image/video references are appended after JSON references in flag order. Reference image submission is connected, but successful generation has not been verified.", + "Conflicting JSON fields and explicit flags are rejected. Local audio/image/video references are appended after JSON references in flag order. Reference image submission is connected, but successful generation has not been verified.\n\n" + + "After submission, use query-result --audio with the returned thread_id and run_id to query and download the results, including any dubbing video.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { flags := cmd.Flags() diff --git a/cmd/generate_video/query_audio_result.go b/cmd/generate_video/query_audio_result.go new file mode 100644 index 0000000..d0d07f2 --- /dev/null +++ b/cmd/generate_video/query_audio_result.go @@ -0,0 +1,31 @@ +package generate_video + +import ( + "encoding/json" + "io" + "strings" + + "github.com/Pippit-dev/pippit-cli/internal/common" + internalgen "github.com/Pippit-dev/pippit-cli/internal/generate_video" + "github.com/spf13/cobra" +) + +// Audio queries opt in explicitly because an error without data cannot identify +// whether the requested Run belongs to the legacy image/video or audio workflow. +func runAudioQueryResult(cmd *cobra.Command, stdout io.Writer, opts *internalgen.QueryResultOptions, runner *common.Runner) error { + result, err := internalgen.QueryAudioResult(cmd.Context(), opts, runner) + if err != nil { + _ = common.AppendDailyErrorLog("query-result", err, map[string]string{ + "thread_id": strings.TrimSpace(opts.ThreadID), "run_id": strings.TrimSpace(opts.RunID), + "download_dir": strings.TrimSpace(opts.DownloadDir), + }) + result = &internalgen.QueryAudioResultResult{ + ThreadID: strings.TrimSpace(opts.ThreadID), RunID: strings.TrimSpace(opts.RunID), + ErrorMessage: err.Error(), Videos: []internalgen.QueryResultVideo{}, + Images: []internalgen.QueryResultImage{}, Audios: []internalgen.QueryResultAudio{}, + } + } + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) +} diff --git a/cmd/generate_video/query_result.go b/cmd/generate_video/query_result.go index a988f63..ccf86a9 100644 --- a/cmd/generate_video/query_result.go +++ b/cmd/generate_video/query_result.go @@ -13,12 +13,16 @@ import ( // NewQueryResultCommand builds the query-result command. func NewQueryResultCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { opts := &internalgen.QueryResultOptions{} + var audio bool cmd := &cobra.Command{ Use: "query-result", - Short: "Query a run result and download completed audio, videos or images", + Short: "Query a run result and download completed videos or images", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + if audio { + return runAudioQueryResult(cmd, stdout, opts, runner) + } result, err := internalgen.QueryResult(cmd.Context(), opts, runner) if err != nil { _ = common.AppendDailyErrorLog("query-result", err, map[string]string{ @@ -35,9 +39,10 @@ func NewQueryResultCommand(stdout, stderr io.Writer, runner *common.Runner) *cob } cmd.SetOut(stdout) cmd.SetErr(stderr) - cmd.Flags().StringVar(&opts.ThreadID, "thread-id", "", "thread_id from generation output") - cmd.Flags().StringVar(&opts.RunID, "run-id", "", "run_id from generation output") - cmd.Flags().StringVar(&opts.DownloadDir, "download-dir", "", "directory to download completed audio, videos or images into") + cmd.Flags().StringVar(&opts.ThreadID, "thread-id", "", "thread_id from generate-video output") + cmd.Flags().StringVar(&opts.RunID, "run-id", "", "run_id from generate-video output") + cmd.Flags().StringVar(&opts.DownloadDir, "download-dir", "", "directory to download completed videos or images into") + cmd.Flags().BoolVar(&audio, "audio", false, "query a generate-audio task with audio result and terminal-state handling") return cmd } @@ -46,7 +51,6 @@ func queryResultFromError(err error, opts *internalgen.QueryResultOptions) *inte ErrorMessage: err.Error(), Videos: []internalgen.QueryResultVideo{}, Images: []internalgen.QueryResultImage{}, - Audios: []internalgen.QueryResultAudio{}, } if opts != nil { result.ThreadID = strings.TrimSpace(opts.ThreadID) diff --git a/cmd/generate_video_test.go b/cmd/generate_video_test.go index 2234966..5d4dda0 100644 --- a/cmd/generate_video_test.go +++ b/cmd/generate_video_test.go @@ -527,13 +527,13 @@ func TestQueryResultGetThreadBusinessErrorReturnsErrorMessage(t *testing.T) { t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String()) } got := decodeJSON(t, stdout.Bytes()) - if got["completed"] != false { - t.Fatalf("completed = %v, want false because Run state is unknown", got["completed"]) + if got["completed"] != true { + t.Fatalf("completed = %v, want true", got["completed"]) } if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" { t.Fatalf("ids = (%v, %v), want thread/run ids", got["thread_id"], got["run_id"]) } - if got["error_message"] != "查询失败:创作失败:暂时无法生成 log_id=log_456" { + if got["error_message"] != "创作失败:暂时无法生成 log_id=log_456" { t.Fatalf("error_message = %v, want get_thread business error", got["error_message"]) } videos, ok := got["videos"].([]any) diff --git a/cmd/query_audio_test.go b/cmd/query_audio_test.go index 14a8300..e140a75 100644 --- a/cmd/query_audio_test.go +++ b/cmd/query_audio_test.go @@ -344,7 +344,7 @@ func runAudioQuery(t *testing.T, baseURL, dir string) map[string]any { t.Helper() var stdout, stderr bytes.Buffer root := newTestRootCommand(t, &stdout, &stderr, baseURL) - root.SetArgs([]string{"query-result", "--thread-id", "thread_123", "--run-id", "run_456", "--download-dir", dir}) + root.SetArgs([]string{"query-result", "--audio", "--thread-id", "thread_123", "--run-id", "run_456", "--download-dir", dir}) if err := root.Execute(); err != nil { t.Fatalf("query Execute: %v", err) } diff --git a/cmd/query_result_compatibility_test.go b/cmd/query_result_compatibility_test.go new file mode 100644 index 0000000..20269ac --- /dev/null +++ b/cmd/query_result_compatibility_test.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "reflect" + "testing" +) + +func TestQueryResultDefaultPreservesLegacyStatesAndOutput(t *testing.T) { + for _, tc := range []struct { + name, response, message string + completed bool + }{ + {"business error without data", `{"ret":"5","errmsg":"legacy failure","log_id":"query_log"}`, "legacy failure log_id=query_log", true}, + {"business error with audio terminal data", `{"ret":"5","errmsg":"legacy failure","log_id":"query_log","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":4,"fail_reason":{"message":"audio reason"}}]}}}`, "legacy failure log_id=query_log", true}, + {"canceled", `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run_456","state":5,"fail_reason":{"message":"canceled reason"}}]}}}`, "", false}, + {"unknown state", `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run_456","state":99}]}}}`, "", false}, + {"legacy artifact error wins", `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run_456","state":4,"fail_reason":{"message":"audio reason"},"entry_list":[{"artifact":{"content":[{"data":{"error_message":"legacy reason","error_code":"7"}}]}}]}]}}}`, "legacy reason (error_code=7)", true}, + {"audio alone is not a legacy download", `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run_456","state":3,"entry_list":[{"artifact":{"content":[{"sub_type":"biz/x_data_audio","data":{"audio":{"url":"https://example.com/audio.wav"}}}]}}]}]}}}`, "下载失败:未找到可下载的产物", false}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if r.Method != http.MethodPost || r.URL.Path != "/api/biz/v1/skill/get_thread" || !reflect.DeepEqual(body, map[string]any{"thread_id": "thread_123", "run_id": "run_456"}) { + t.Errorf("legacy query request changed: %s %s %#v", r.Method, r.URL.Path, body) + } + io.WriteString(w, tc.response) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs([]string{"query-result", "--thread-id", "thread_123", "--run-id", "run_456", "--download-dir", t.TempDir()}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + want := map[string]any{"completed": tc.completed, "thread_id": "thread_123", "run_id": "run_456", "error_message": tc.message, "videos": []any{}, "images": []any{}} + if got := decodeJSON(t, stdout.Bytes()); !reflect.DeepEqual(got, want) { + t.Fatalf("legacy output=%#v, want %#v", got, want) + } + if requests != 1 { + t.Fatalf("requests=%d, want one query and no downloads", requests) + } + }) + } +} + +func TestQueryResultDefaultIgnoresAudioFieldsInLegacyMedia(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + if r.URL.Path == "/api/biz/v1/skill/get_thread" { + // These fields were unknown to the legacy decoder and must remain ignored. + writeAudioQueryFixture(w, map[string]any{"run_id": "run_456", "state": 3, "fail_reason": "opaque legacy value", "entry_list": []any{ + map[string]any{"artifact": map[string]any{"content": []any{ + map[string]any{"sub_type": "biz/x_data_image", "data": map[string]any{"image": map[string]any{"url": serverURL(r) + "/image.png", "asset_id": "image"}, "audio": "opaque legacy value"}}, + map[string]any{"sub_type": "biz/x_data_video", "data": map[string]any{"video": map[string]any{"download_url": serverURL(r) + "/video.mp4", "vid": "video"}}}, + }}}, + }}) + return + } + if r.Method != http.MethodGet { + t.Errorf("download method=%s, want GET", r.Method) + } + io.WriteString(w, r.URL.Path) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + dir := t.TempDir() + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs([]string{"query-result", "--thread-id", "thread_123", "--run-id", "run_456", "--download-dir", dir}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + got := decodeJSON(t, stdout.Bytes()) + if got["completed"] != true || got["error_message"] != "" || len(got) != 6 { + t.Fatalf("legacy media output changed: %#v", got) + } + for _, kind := range []string{"images", "videos"} { + if items, ok := got[kind].([]any); !ok || len(items) != 1 { + t.Fatalf("%s=%#v, want one legacy result", kind, got[kind]) + } + } + assertFileContent(t, filepath.Join(dir, "image.png"), "/image.png") + assertFileContent(t, filepath.Join(dir, "video.mp4"), "/video.mp4") + if !reflect.DeepEqual(paths, []string{"/api/biz/v1/skill/get_thread", "/video.mp4", "/image.png"}) { + t.Fatalf("legacy query/download order changed: %v", paths) + } +} diff --git a/internal/common/get_thread.go b/internal/common/get_thread.go index 2da8b3c..9d6f1e1 100644 --- a/internal/common/get_thread.go +++ b/internal/common/get_thread.go @@ -16,6 +16,8 @@ type GetThreadOptions struct { ThreadID string `json:"thread_id"` RunID string `json:"run_id,omitempty"` Version string `json:"version,omitempty"` + // PreserveErrorData is local-only and used by explicit audio result queries. + PreserveErrorData bool `json:"-"` } // GetThreadResult is the parsed get_thread response used by `pippit-tool-cli get-thread`. @@ -54,6 +56,9 @@ func GetThread(ctx context.Context, opts *GetThreadOptions, runner *Runner) (*Ge if resp.Errmsg == "" { resp.Errmsg = "未知错误" } + if !opts.PreserveErrorData { + return nil, NewLogIDError(fmt.Sprintf("获取线程请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID) + } return nil, &LogIDError{ Message: strings.TrimSpace(fmt.Sprintf("获取线程请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)), ID: strings.TrimSpace(resp.LogID), diff --git a/internal/common/get_thread_test.go b/internal/common/get_thread_test.go index 74c99d7..254e98d 100644 --- a/internal/common/get_thread_test.go +++ b/internal/common/get_thread_test.go @@ -76,7 +76,7 @@ func TestGetThreadV2RequiresReadableText(t *testing.T) { } func TestGetThreadPreservesStructuredErrorDataWithoutLoggingIt(t *testing.T) { - _, err := GetThread(context.Background(), &GetThreadOptions{ThreadID: "thread_123", RunID: "run_456"}, &Runner{ + _, err := GetThread(context.Background(), &GetThreadOptions{ThreadID: "thread_123", RunID: "run_456", PreserveErrorData: true}, &Runner{ Client: getThreadFakeClient{response: `{"ret":"5","errmsg":"生成失败","log_id":"log_123","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":4}]},"private_marker":"not-for-error-output"}}`}, }) var logErr *LogIDError @@ -91,3 +91,13 @@ func TestGetThreadPreservesStructuredErrorDataWithoutLoggingIt(t *testing.T) { t.Fatalf("structured error data leaked into error output") } } + +func TestGetThreadOmitsStructuredErrorDataByDefault(t *testing.T) { + _, err := GetThread(context.Background(), &GetThreadOptions{ThreadID: "thread_123", RunID: "run_456"}, &Runner{ + Client: getThreadFakeClient{response: `{"ret":"5","errmsg":"legacy failure","log_id":"log_123","data":{"private_marker":"not-retained"}}`}, + }) + var logErr *LogIDError + if !errors.As(err, &logErr) || len(logErr.RawData) != 0 || err.Error() != "获取线程请求返回失败: ret=5 errmsg=legacy failure log_id=log_123" { + t.Fatalf("default GetThread error contract changed: %v", err) + } +} diff --git a/internal/generate_video/query_audio_result.go b/internal/generate_video/query_audio_result.go new file mode 100644 index 0000000..2115fa5 --- /dev/null +++ b/internal/generate_video/query_audio_result.go @@ -0,0 +1,454 @@ +package generate_video + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "path/filepath" + "strconv" + "strings" + + "github.com/Pippit-dev/pippit-cli/internal/common" +) + +const canceledAudioRunState = 5 + +// QueryAudioResultResult describes the user-facing query-result outcome. +type QueryAudioResultResult struct { + Completed bool `json:"completed"` + ThreadID string `json:"thread_id"` + RunID string `json:"run_id"` + ErrorMessage string `json:"error_message"` + Videos []QueryResultVideo `json:"videos"` + Images []QueryResultImage `json:"images"` + Audios []QueryResultAudio `json:"audios"` +} + +// QueryResultAudio describes a downloaded audio. Duration is in seconds, when available. +type QueryResultAudio struct { + DownloadURL string `json:"download_url"` + OutputPath string `json:"output_path"` + Name string `json:"name,omitempty"` + PippitAssetID string `json:"pippit_asset_id,omitempty"` + Duration *float64 `json:"duration,omitempty"` +} + +type audioQueryThread struct { + ThreadID string `json:"thread_id"` + RunList []audioQueryRun `json:"run_list"` +} + +type audioQueryRun struct { + RunID string `json:"run_id"` + State int `json:"state"` + ErrorMessage string `json:"error_message"` + ErrorMsg string `json:"error_msg"` + Errmsg string `json:"errmsg"` + FailReason audioQueryFailReason `json:"fail_reason"` + EntryList []audioQueryEntry `json:"entry_list"` +} + +type audioQueryFailReason struct { + Message string `json:"message"` + FallbackMessage string `json:"fallback_message"` + Code json.RawMessage `json:"code"` +} + +type audioQueryEntry struct { + Artifact audioQueryArtifact `json:"artifact"` +} + +type audioQueryArtifact struct { + Content []audioQueryContent `json:"content"` +} + +type audioQueryContent struct { + SubType string `json:"sub_type"` + Data audioQueryContentData `json:"data"` +} + +type audioQueryContentData struct { + Video *queryVideo `json:"video"` + Image *queryImage `json:"image"` + Audio *queryAudio `json:"audio"` + ErrorMessage string `json:"error_message"` + ErrorCode json.RawMessage `json:"error_code"` +} + +type queryAudio struct { + DownloadURL string `json:"url"` + Name string `json:"name"` + PippitAssetID string `json:"pippit_asset_id"` + Metadata queryAudioMeta `json:"metadata"` +} + +type queryAudioMeta struct { + Format string `json:"format"` + Duration *float64 `json:"duration"` +} + +func QueryAudioResult(ctx context.Context, opts *QueryResultOptions, runner *common.Runner) (*QueryAudioResultResult, error) { + if err := validateQueryResultOptions(opts); err != nil { + return nil, err + } + + threadResult, err := common.GetThread(ctx, &common.GetThreadOptions{ + ThreadID: opts.ThreadID, + RunID: opts.RunID, + PreserveErrorData: true, + }, runner) + if err != nil { + if result, ok := audioQueryResultFromGetThreadBusinessError(err, opts); ok { + return result, nil + } + return nil, fmt.Errorf("查询失败:%w", err) + } + + thread, err := parseAudioQueryThread(threadResult) + if err != nil { + return nil, fmt.Errorf("查询失败:%w", err) + } + + run, ok := findAudioQueryRun(thread, opts.RunID) + if !ok { + return nil, fmt.Errorf("查询失败:未找到 run_id=%s 对应的 Run", opts.RunID) + } + if run.State != successRunState { + result := &QueryAudioResultResult{ + Completed: run.State == failedRunState || run.State == canceledAudioRunState, + ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID), + RunID: opts.RunID, + Videos: []QueryResultVideo{}, + Images: []QueryResultImage{}, + Audios: []QueryResultAudio{}, + } + if run.State == failedRunState { + result.ErrorMessage = firstNonEmpty(extractAudioQueryErrorMessage(run), "Run 失败") + } else if run.State == canceledAudioRunState { + result.ErrorMessage = firstNonEmpty(extractAudioQueryErrorMessage(run), "Run 已取消") + } + return result, nil + } + + videos := extractAudioQueryVideos(run) + images := extractAudioQueryImages(run) + audios := extractQueryAudios(run) + if len(videos) == 0 && len(images) == 0 && len(audios) == 0 { + return nil, fmt.Errorf("下载失败:未找到可下载的产物") + } + + downloadDir, err := common.ExpandPath(opts.DownloadDir) + if err != nil { + return nil, fmt.Errorf("下载失败:解析下载目录失败:%w", err) + } + + usedNames := make(map[string]int, len(videos)+len(images)+len(audios)) + + resultVideos := make([]QueryResultVideo, 0, len(videos)) + for i, video := range videos { + if strings.TrimSpace(video.DownloadURL) == "" { + return nil, fmt.Errorf("下载失败:第 %d 个视频产物 download_url 为空", i+1) + } + outputPath := filepath.Join(downloadDir, uniqueAudioQueryResultFileName(videoFileName(video, i+1), usedNames)) + download, err := common.DownloadResult(ctx, common.DownloadResultOptions{ + URL: video.DownloadURL, + OutputPath: outputPath, + Workers: 5, + }, runner) + if err != nil { + return nil, fmt.Errorf("下载失败:%w", err) + } + actualOutputPath := outputPath + if len(download.Downloaded) > 0 { + actualOutputPath = download.Downloaded[0] + } else if len(download.AlreadyExist) > 0 { + actualOutputPath = download.AlreadyExist[0] + } + resultVideos = append(resultVideos, QueryResultVideo{ + DownloadURL: video.DownloadURL, + OutputPath: actualOutputPath, + }) + } + + resultImages := make([]QueryResultImage, 0, len(images)) + for i, image := range images { + if strings.TrimSpace(image.DownloadURL) == "" { + return nil, fmt.Errorf("下载失败:第 %d 个图片产物 download_url 为空", i+1) + } + outputPath := filepath.Join(downloadDir, uniqueAudioQueryResultFileName(imageFileName(image, i+1), usedNames)) + download, err := common.DownloadResult(ctx, common.DownloadResultOptions{ + URL: image.DownloadURL, + OutputPath: outputPath, + Workers: 5, + }, runner) + if err != nil { + return nil, fmt.Errorf("下载失败:%w", err) + } + actualOutputPath := outputPath + if len(download.Downloaded) > 0 { + actualOutputPath = download.Downloaded[0] + } else if len(download.AlreadyExist) > 0 { + actualOutputPath = download.AlreadyExist[0] + } + resultImages = append(resultImages, QueryResultImage{ + DownloadURL: image.DownloadURL, + OutputPath: actualOutputPath, + }) + } + + resultAudios := make([]QueryResultAudio, 0, len(audios)) + for i, audio := range audios { + if strings.TrimSpace(audio.DownloadURL) == "" { + return nil, fmt.Errorf("下载失败:第 %d 个音频产物 url 为空", i+1) + } + outputPath := filepath.Join(downloadDir, uniqueAudioQueryResultFileName(audioFileName(audio, i+1), usedNames)) + if _, err := common.DownloadResult(ctx, common.DownloadResultOptions{ + URL: audio.DownloadURL, OutputPath: outputPath, + }, runner); err != nil { + return nil, fmt.Errorf("下载失败:%w", err) + } + resultAudios = append(resultAudios, QueryResultAudio{ + DownloadURL: audio.DownloadURL, OutputPath: outputPath, Name: audio.Name, + PippitAssetID: audio.PippitAssetID, Duration: audio.Metadata.Duration, + }) + } + + return &QueryAudioResultResult{ + Completed: true, + ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID), + RunID: opts.RunID, + Videos: resultVideos, + Images: resultImages, + Audios: resultAudios, + }, nil +} + +func audioQueryResultFromGetThreadBusinessError(err error, opts *QueryResultOptions) (*QueryAudioResultResult, bool) { + var logErr *common.LogIDError + if !errors.As(err, &logErr) { + return nil, false + } + message := getThreadBusinessErrorMessage(logErr.Message) + if message == "" { + message = "未知错误" + } + completed := false + // Nonzero ret can represent either a query failure or an observed Run failure. + // Only a matching structured Run in the error payload can establish a terminal state. + thread, parseErr := parseAudioQueryThread(&common.GetThreadResult{RawData: logErr.RawData}) + if parseErr == nil && (thread.ThreadID == "" || thread.ThreadID == opts.ThreadID) { + if run, ok := findAudioQueryRun(thread, opts.RunID); ok && (run.State == failedRunState || run.State == canceledAudioRunState) { + completed = true + message = firstNonEmpty(extractAudioQueryErrorMessage(run), message) + } + } + if !completed { + message = "查询失败:" + message + } + if logID := logErr.LogID(); logID != "" { + message = fmt.Sprintf("%s log_id=%s", message, logID) + } + return &QueryAudioResultResult{ + Completed: completed, + ThreadID: opts.ThreadID, + RunID: opts.RunID, + ErrorMessage: message, + Videos: []QueryResultVideo{}, + Images: []QueryResultImage{}, + Audios: []QueryResultAudio{}, + }, true +} + +func parseAudioQueryThread(result *common.GetThreadResult) (*audioQueryThread, error) { + if result == nil { + return nil, fmt.Errorf("get_thread 响应为空") + } + if len(result.RawData) > 0 { + var data map[string]json.RawMessage + if err := json.Unmarshal(result.RawData, &data); err == nil { + if raw := data["thread"]; len(raw) > 0 { + if thread, ok := decodeAudioQueryThread(raw); ok { + return thread, nil + } + } + } + } + return nil, fmt.Errorf("get_thread 响应中未找到 data.thread") +} + +func decodeAudioQueryThread(raw []byte) (*audioQueryThread, bool) { + var thread audioQueryThread + if err := json.Unmarshal(raw, &thread); err != nil { + return nil, false + } + if thread.ThreadID == "" && len(thread.RunList) == 0 { + return nil, false + } + return &thread, true +} + +func findAudioQueryRun(thread *audioQueryThread, runID string) (audioQueryRun, bool) { + for _, run := range thread.RunList { + if run.RunID == runID { + return run, true + } + } + return audioQueryRun{}, false +} + +func (data *audioQueryContentData) UnmarshalJSON(raw []byte) error { + raw = []byte(strings.TrimSpace(string(raw))) + if len(raw) == 0 || string(raw) == "null" { + return nil + } + if raw[0] == '"' { + var encoded string + if err := json.Unmarshal(raw, &encoded); err != nil { + return err + } + encoded = strings.TrimSpace(encoded) + if encoded == "" { + return nil + } + raw = []byte(encoded) + } + if len(raw) == 0 || raw[0] != '{' { + return nil + } + type alias audioQueryContentData + return json.Unmarshal(raw, (*alias)(data)) +} + +func extractAudioQueryVideos(run audioQueryRun) []queryVideo { + videos := make([]queryVideo, 0) + for _, entry := range run.EntryList { + artifact := entry.Artifact + for _, content := range artifact.Content { + if content.SubType != "biz/x_data_video" { + continue + } + data := content.Data + if data.Video != nil { + videos = append(videos, *data.Video) + } + } + } + return videos +} + +func extractAudioQueryImages(run audioQueryRun) []queryImage { + images := make([]queryImage, 0) + for _, entry := range run.EntryList { + artifact := entry.Artifact + for _, content := range artifact.Content { + if content.SubType != "biz/x_data_image" { + continue + } + data := content.Data + if data.Image != nil { + images = append(images, *data.Image) + } + } + } + return images +} + +func extractQueryAudios(run audioQueryRun) []queryAudio { + audios := make([]queryAudio, 0) + for _, entry := range run.EntryList { + for _, content := range entry.Artifact.Content { + if content.SubType == "biz/x_data_audio" && content.Data.Audio != nil { + audios = append(audios, *content.Data.Audio) + } + } + } + return audios +} + +func extractAudioQueryErrorMessage(run audioQueryRun) string { + if message := firstNonEmpty(run.ErrorMessage, run.ErrorMsg, run.Errmsg); message != "" { + return message + } + if message := firstNonEmpty(run.FailReason.Message, run.FailReason.FallbackMessage); message != "" { + if code := rawMessageString(run.FailReason.Code); code != "" && code != "0" { + return fmt.Sprintf("%s (error_code=%s)", message, code) + } + return message + } + for _, entry := range run.EntryList { + for _, content := range entry.Artifact.Content { + data := content.Data + if message := firstNonEmpty(data.ErrorMessage); message != "" { + if code := rawMessageString(data.ErrorCode); code != "" { + return fmt.Sprintf("%s (error_code=%s)", message, code) + } + return message + } + if code := rawMessageString(data.ErrorCode); code != "" { + return "error_code=" + code + } + } + } + return "" +} + +func audioFileName(audio queryAudio, index int) string { + name := firstNonEmpty(audio.PippitAssetID, audio.Name, "audio_"+strconv.Itoa(index)) + name = sanitizeFileName(name) + ext := normalizeAudioFormatExt(audio.Metadata.Format) + if ext == "" { + if parsed, err := url.Parse(audio.DownloadURL); err == nil { + ext = normalizeAudioFormatExt(filepath.Ext(parsed.Path)) + } + } + if ext == "" { + ext = normalizeAudioFormatExt(filepath.Ext(audio.Name)) + } + if ext == "" { + ext = "audio" + } + if normalizeAudioFormatExt(filepath.Ext(name)) != "" { + name = strings.TrimSuffix(name, filepath.Ext(name)) + } + return name + "." + ext +} + +// Only known audio formats can become a file extension; URL queries are never used. +func normalizeAudioFormatExt(format string) string { + format = strings.ToLower(strings.TrimSpace(format)) + format = strings.TrimPrefix(format, "audio/") + format = strings.TrimPrefix(format, ".") + switch format { + case "mpeg": + return "mp3" + case "x-wav", "wave": + return "wav" + case "ogg_opus": + return "ogg" + case "mp3", "wav", "pcm", "m4a", "aac", "flac", "ogg", "opus": + return format + default: + return "" + } +} + +func uniqueAudioQueryResultFileName(name string, used map[string]int) string { + count := used[name] + 1 + if count == 1 { + used[name] = count + return name + } + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + for { + candidate := fmt.Sprintf("%s-%d%s", base, count, ext) + if used[candidate] == 0 { + used[name] = count + used[candidate] = 1 + return candidate + } + count++ + } +} diff --git a/internal/generate_video/query_result.go b/internal/generate_video/query_result.go index a97ed50..cf18302 100644 --- a/internal/generate_video/query_result.go +++ b/internal/generate_video/query_result.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "net/url" "path/filepath" "strconv" "strings" @@ -15,9 +14,8 @@ import ( ) const ( - successRunState = 3 - failedRunState = 4 - canceledRunState = 5 + successRunState = 3 + failedRunState = 4 ) // QueryResultOptions is the command-facing request shape for query-result. @@ -35,16 +33,6 @@ type QueryResultResult struct { ErrorMessage string `json:"error_message"` Videos []QueryResultVideo `json:"videos"` Images []QueryResultImage `json:"images"` - Audios []QueryResultAudio `json:"audios"` -} - -// QueryResultAudio describes a downloaded audio. Duration is in seconds, when available. -type QueryResultAudio struct { - DownloadURL string `json:"download_url"` - OutputPath string `json:"output_path"` - Name string `json:"name,omitempty"` - PippitAssetID string `json:"pippit_asset_id,omitempty"` - Duration *float64 `json:"duration,omitempty"` } // QueryResultVideo describes a downloaded video from query-result. @@ -65,19 +53,12 @@ type queryThread struct { } type queryRun struct { - RunID string `json:"run_id"` - State int `json:"state"` - ErrorMessage string `json:"error_message"` - ErrorMsg string `json:"error_msg"` - Errmsg string `json:"errmsg"` - FailReason queryFailReason `json:"fail_reason"` - EntryList []queryEntry `json:"entry_list"` -} - -type queryFailReason struct { - Message string `json:"message"` - FallbackMessage string `json:"fallback_message"` - Code json.RawMessage `json:"code"` + RunID string `json:"run_id"` + State int `json:"state"` + ErrorMessage string `json:"error_message"` + ErrorMsg string `json:"error_msg"` + Errmsg string `json:"errmsg"` + EntryList []queryEntry `json:"entry_list"` } type queryEntry struct { @@ -96,7 +77,6 @@ type queryContent struct { type queryContentData struct { Video *queryVideo `json:"video"` Image *queryImage `json:"image"` - Audio *queryAudio `json:"audio"` ErrorMessage string `json:"error_message"` ErrorCode json.RawMessage `json:"error_code"` } @@ -118,18 +98,6 @@ type queryImageMeta struct { Format string `json:"format"` } -type queryAudio struct { - DownloadURL string `json:"url"` - Name string `json:"name"` - PippitAssetID string `json:"pippit_asset_id"` - Metadata queryAudioMeta `json:"metadata"` -} - -type queryAudioMeta struct { - Format string `json:"format"` - Duration *float64 `json:"duration"` -} - func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.Runner) (*QueryResultResult, error) { if err := validateQueryResultOptions(opts); err != nil { return nil, err @@ -157,25 +125,21 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R } if run.State != successRunState { result := &QueryResultResult{ - Completed: run.State == failedRunState || run.State == canceledRunState, + Completed: run.State == failedRunState, ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID), RunID: opts.RunID, Videos: []QueryResultVideo{}, Images: []QueryResultImage{}, - Audios: []QueryResultAudio{}, } if run.State == failedRunState { result.ErrorMessage = firstNonEmpty(extractQueryErrorMessage(run), "Run 失败") - } else if run.State == canceledRunState { - result.ErrorMessage = firstNonEmpty(extractQueryErrorMessage(run), "Run 已取消") } return result, nil } videos := extractQueryVideos(run) images := extractQueryImages(run) - audios := extractQueryAudios(run) - if len(videos) == 0 && len(images) == 0 && len(audios) == 0 { + if len(videos) == 0 && len(images) == 0 { return nil, fmt.Errorf("下载失败:未找到可下载的产物") } @@ -184,7 +148,7 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R return nil, fmt.Errorf("下载失败:解析下载目录失败:%w", err) } - usedNames := make(map[string]int, len(videos)+len(images)+len(audios)) + usedNames := make(map[string]int, len(videos)+len(images)) resultVideos := make([]QueryResultVideo, 0, len(videos)) for i, video := range videos { @@ -238,30 +202,12 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R }) } - resultAudios := make([]QueryResultAudio, 0, len(audios)) - for i, audio := range audios { - if strings.TrimSpace(audio.DownloadURL) == "" { - return nil, fmt.Errorf("下载失败:第 %d 个音频产物 url 为空", i+1) - } - outputPath := filepath.Join(downloadDir, uniqueQueryResultFileName(audioFileName(audio, i+1), usedNames)) - if _, err := common.DownloadResult(ctx, common.DownloadResultOptions{ - URL: audio.DownloadURL, OutputPath: outputPath, - }, runner); err != nil { - return nil, fmt.Errorf("下载失败:%w", err) - } - resultAudios = append(resultAudios, QueryResultAudio{ - DownloadURL: audio.DownloadURL, OutputPath: outputPath, Name: audio.Name, - PippitAssetID: audio.PippitAssetID, Duration: audio.Metadata.Duration, - }) - } - return &QueryResultResult{ Completed: true, ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID), RunID: opts.RunID, Videos: resultVideos, Images: resultImages, - Audios: resultAudios, }, nil } @@ -272,32 +218,18 @@ func queryResultFromGetThreadBusinessError(err error, opts *QueryResultOptions) } message := getThreadBusinessErrorMessage(logErr.Message) if message == "" { - message = "未知错误" - } - completed := false - // Nonzero ret can represent either a query failure or an observed Run failure. - // Only a matching structured Run in the error payload can establish a terminal state. - thread, parseErr := parseQueryThread(&common.GetThreadResult{RawData: logErr.RawData}) - if parseErr == nil && (thread.ThreadID == "" || thread.ThreadID == opts.ThreadID) { - if run, ok := findQueryRun(thread, opts.RunID); ok && (run.State == failedRunState || run.State == canceledRunState) { - completed = true - message = firstNonEmpty(extractQueryErrorMessage(run), message) - } - } - if !completed { - message = "查询失败:" + message + message = "查询失败" } if logID := logErr.LogID(); logID != "" { message = fmt.Sprintf("%s log_id=%s", message, logID) } return &QueryResultResult{ - Completed: completed, + Completed: true, ThreadID: opts.ThreadID, RunID: opts.RunID, ErrorMessage: message, Videos: []QueryResultVideo{}, Images: []QueryResultImage{}, - Audios: []QueryResultAudio{}, }, true } @@ -422,28 +354,10 @@ func extractQueryImages(run queryRun) []queryImage { return images } -func extractQueryAudios(run queryRun) []queryAudio { - audios := make([]queryAudio, 0) - for _, entry := range run.EntryList { - for _, content := range entry.Artifact.Content { - if content.SubType == "biz/x_data_audio" && content.Data.Audio != nil { - audios = append(audios, *content.Data.Audio) - } - } - } - return audios -} - func extractQueryErrorMessage(run queryRun) string { if message := firstNonEmpty(run.ErrorMessage, run.ErrorMsg, run.Errmsg); message != "" { return message } - if message := firstNonEmpty(run.FailReason.Message, run.FailReason.FallbackMessage); message != "" { - if code := rawMessageString(run.FailReason.Code); code != "" && code != "0" { - return fmt.Sprintf("%s (error_code=%s)", message, code) - } - return message - } for _, entry := range run.EntryList { for _, content := range entry.Artifact.Content { data := content.Data @@ -506,46 +420,6 @@ func imageFileName(image queryImage, index int) string { return name } -func audioFileName(audio queryAudio, index int) string { - name := firstNonEmpty(audio.PippitAssetID, audio.Name, "audio_"+strconv.Itoa(index)) - name = sanitizeFileName(name) - ext := normalizeAudioFormatExt(audio.Metadata.Format) - if ext == "" { - if parsed, err := url.Parse(audio.DownloadURL); err == nil { - ext = normalizeAudioFormatExt(filepath.Ext(parsed.Path)) - } - } - if ext == "" { - ext = normalizeAudioFormatExt(filepath.Ext(audio.Name)) - } - if ext == "" { - ext = "audio" - } - if normalizeAudioFormatExt(filepath.Ext(name)) != "" { - name = strings.TrimSuffix(name, filepath.Ext(name)) - } - return name + "." + ext -} - -// Only known audio formats can become a file extension; URL queries are never used. -func normalizeAudioFormatExt(format string) string { - format = strings.ToLower(strings.TrimSpace(format)) - format = strings.TrimPrefix(format, "audio/") - format = strings.TrimPrefix(format, ".") - switch format { - case "mpeg": - return "mp3" - case "x-wav", "wave": - return "wav" - case "ogg_opus": - return "ogg" - case "mp3", "wav", "pcm", "m4a", "aac", "flac", "ogg", "opus": - return format - default: - return "" - } -} - // normalizeImageFormatExt maps the server-provided metadata.format to a safe // file extension. Only a known allowlist is accepted; anything else (including // "image/jpeg", ".jpeg", or empty values) falls back to "png". @@ -595,19 +469,11 @@ func sanitizeFileName(name string) string { func uniqueQueryResultFileName(name string, used map[string]int) string { count := used[name] + 1 + used[name] = count if count == 1 { - used[name] = count return name } ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) - for { - candidate := fmt.Sprintf("%s-%d%s", base, count, ext) - if used[candidate] == 0 { - used[name] = count - used[candidate] = 1 - return candidate - } - count++ - } + return fmt.Sprintf("%s-%d%s", base, count, ext) } diff --git a/internal/generate_video/query_result_test.go b/internal/generate_video/query_result_test.go index 01b0d35..e1f3bae 100644 --- a/internal/generate_video/query_result_test.go +++ b/internal/generate_video/query_result_test.go @@ -25,16 +25,27 @@ func TestAudioFileNameUsesOnlySafeExtensions(t *testing.T) { } } -func TestQueryResultFileNamesDoNotReuseAllocatedSuffixes(t *testing.T) { +func TestAudioQueryResultFileNamesDoNotReuseAllocatedSuffixes(t *testing.T) { used := map[string]int{} want := []string{"voice.wav", "voice-2.wav", "voice-2-2.wav", "voice-3.wav"} for i, name := range []string{"voice.wav", "voice.wav", "voice-2.wav", "voice.wav"} { - if got := uniqueQueryResultFileName(name, used); got != want[i] { + if got := uniqueAudioQueryResultFileName(name, used); got != want[i] { t.Fatalf("filename %d = %q, want %q", i, got, want[i]) } } } +func TestQueryResultRetainsLegacyFileNames(t *testing.T) { + used := map[string]int{} + // Keep the existing image/video naming contract; audio has a separate allocator. + want := []string{"voice.wav", "voice-2.wav", "voice-2.wav", "voice-3.wav"} + for i, name := range []string{"voice.wav", "voice.wav", "voice-2.wav", "voice.wav"} { + if got := uniqueQueryResultFileName(name, used); got != want[i] { + t.Fatalf("legacy filename %d = %q, want %q", i, got, want[i]) + } + } +} + func TestVideoFileNameUsesVIDBeforeTimestampTitle(t *testing.T) { got := videoFileName(queryVideo{ Title: "v03c76g10004d8jp38iljhtepa11k25g_2026-06-09T120814.516", diff --git a/scripts/install-cli.test.js b/scripts/install-cli.test.js index 0a499da..781207e 100644 --- a/scripts/install-cli.test.js +++ b/scripts/install-cli.test.js @@ -95,7 +95,7 @@ function checkInstaller() { assert.deepStrictEqual(effects, []); } -function bootstrapFixture({ failure, platform = "linux", version = "9.9.9", missingCommand = "generate-image", canvas = false } = {}) { +function bootstrapFixture({ failure, platform = "linux", version = "9.9.9", missingCommand = "generate-image", canvas = false, audio = false } = {}) { const dirs = []; const calls = []; const home = fs.mkdtempSync(path.join(root, "user-")); @@ -108,7 +108,7 @@ function bootstrapFixture({ failure, platform = "linux", version = "9.9.9", miss dirs, calls, npmDir, options: { main: true, - process: { platform, execPath: nodePath, argv: [nodePath, bootstrap, ...(canvas ? ["--canvas"] : [])], env: { XYQ_ACCESS_KEY: "test-key", PATH: npmDir } }, + process: { platform, execPath: nodePath, argv: [nodePath, bootstrap, ...(canvas ? ["--canvas"] : []), ...(audio ? ["--audio"] : [])], env: { XYQ_ACCESS_KEY: "test-key", PATH: npmDir } }, modules: { os: { homedir: () => home }, child_process: { @@ -182,7 +182,7 @@ function checkBootstrap() { assert.strictEqual(result.cli_path, JSON.parse(next.output[0]).cli_path); assert.strictEqual(fixture.dirs.length, 1, "Subsequent invocations must reuse the cached CLI"); for (const command of ["status", "login", "logout", "query-result", - "generate-image", "generate-audio", "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance"]) { + "generate-image", "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance"]) { assert.strictEqual(fixture.calls.filter((call) => call.args[0] === command).length, 2); } assert.strictEqual(fixture.calls.filter((call) => call.args[0].endsWith("install-cli.js")).length, 1); @@ -213,7 +213,7 @@ function checkBootstrap() { } // Every retained command participates in compatibility checks and cache reuse. for (const missingCommand of ["status", "login", "logout", "query-result", "generate-image", - "generate-audio", "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance"]) { + "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance"]) { const fixture = bootstrapFixture({ missingCommand }); const existing = path.join(fixture.npmDir, "pippit-tool-cli"); fs.writeFileSync(existing, "missing-command"); @@ -274,6 +274,24 @@ function checkBootstrap() { help.options.process.argv = [process.execPath, bootstrap, "--help"]; assert.strictEqual(load(bootstrap, help.options).proc.exitCode, 0); assert.strictEqual(help.calls.length, 0, "Help must not install or download anything"); + + for (const missingCommand of ["generate-audio", "query-result --audio"]) { + for (const audio of [false, true]) { + const fixture = bootstrapFixture({ missingCommand, audio }); + const existing = path.join(fixture.npmDir, "pippit-tool-cli"); + fs.writeFileSync(existing, "missing-command"); + const result = load(bootstrap, fixture.options); + assert.strictEqual(result.proc.exitCode, 0, result.errors.join("\n")); + assert.strictEqual(fixture.dirs.length, audio ? 1 : 0, "Only audio tasks may upgrade for missing audio support"); + assert.strictEqual(JSON.parse(result.output[0]).cli_path === existing, !audio); + assert.strictEqual(fs.readFileSync(existing, "utf8"), "missing-command"); + assert.strictEqual(load(bootstrap, fixture.options).proc.exitCode, 0); + assert.strictEqual(fixture.dirs.length, audio ? 1 : 0, "Audio tasks must reuse a compatible cache"); + if (!audio) { + assert(!fixture.calls.some((call) => call.args[0] === "generate-audio" || call.args.includes("--audio"))); + } + } + } } function checkCanvasBootstrap() { diff --git a/skills/xyq-nest-skill/SKILL.md b/skills/xyq-nest-skill/SKILL.md index e9dde65..bb504d8 100644 --- a/skills/xyq-nest-skill/SKILL.md +++ b/skills/xyq-nest-skill/SKILL.md @@ -12,7 +12,7 @@ metadata: ## 开始执行 -1. 画布任务运行 `node "{baseDir}/scripts/ensure-cli.js" --canvas`,其他任务运行 `node "{baseDir}/scripts/ensure-cli.js"`。保存返回的 `cli_path`;Canvas 还需保存 `canvas_entry`。文档中的 `pippit-tool-cli` 替换为带引号的 `cli_path`;画布语义命令按模块说明通过 Node 入口执行。同一任务复用,安装细节见 [安装说明](scripts/install.md)。 +1. 画布任务运行 `node "{baseDir}/scripts/ensure-cli.js" --canvas`;音频生成及其结果查询运行 `node "{baseDir}/scripts/ensure-cli.js" --audio`;其他任务运行 `node "{baseDir}/scripts/ensure-cli.js"`。保存返回的 `cli_path`;Canvas 还需保存 `canvas_entry`。文档中的 `pippit-tool-cli` 替换为带引号的 `cli_path`;画布语义命令按模块说明通过 Node 入口执行。同一任务复用,安装细节见 [安装说明](scripts/install.md)。 2. 按下表选择操作,只读取命中的命令文档。执行需要鉴权的操作前,按 [授权说明](commands/auth.md) 检查登录;有效登录可复用。 3. 生成、视频处理和查询已有媒体结果时,还必须读取 [异步结果与媒体交付](workflows/async-delivery.md)。画布任务使用 [画布查询、编辑与验证](workflows/canvas-edit.md),不把画布编辑当作媒体生成。积分与授权操作直接返回结果。 @@ -32,6 +32,7 @@ metadata: | 提升已有视频分辨率、视频超分 | `video-super-resolution` | [超分](commands/video-super-resolution.md) | | 去除已有视频字幕 | `erase-video-subtitle` | [擦字幕](commands/erase-video-subtitle.md) | | 查询已有任务进度、下载生成结果 | `query-result` | [查询结果](commands/query-result.md) | +| 查询 generate-audio 创建的任务,含翻配视频产物 | `query-result --audio` | [查询结果](commands/query-result.md) | | 查询个人积分余额、剩余 credits | `get-credit-balance` | [积分](commands/get-credit-balance.md) | - 普通生图、生音频、生视频也走对应生成命令,无需用户额外声明“模型直出”。 diff --git a/skills/xyq-nest-skill/commands/generate-audio.md b/skills/xyq-nest-skill/commands/generate-audio.md index b622a1f..561d317 100644 --- a/skills/xyq-nest-skill/commands/generate-audio.md +++ b/skills/xyq-nest-skill/commands/generate-audio.md @@ -84,6 +84,12 @@ pippit-tool-cli generate-audio --input '{"model":"seedaudio_1.5","task_type":"du ## 返回与处理 -成功提交返回 JSON 中的 `thread_id`、`run_id`、`web_thread_link`,随后执行 [异步结果与媒体交付](../workflows/async-delivery.md)。音频位于 `audios[]`,视频位于 `videos[]`,逐项交付 output_path 对应文件;提交成功不是生成成功。当前 query-result 只交付音频、视频和图片;即使 include 请求并生成了字幕,也不会输出或下载独立字幕文件。实际 duration 不等于精确时长控制能力。 +成功提交返回 JSON 中的 `thread_id`、`run_id`、`web_thread_link`,随后通过 `query-result --audio` 执行 [异步结果与媒体交付](../workflows/async-delivery.md),包括 dubbing 返回视频的任务: + +```bash +pippit-tool-cli query-result --audio --thread-id THREAD_ID --run-id RUN_ID --download-dir "./xyq_output" +``` + +音频位于 `audios[]`,视频位于 `videos[]`,逐项交付 output_path 对应文件;提交成功不是生成成功。`query-result --audio` 只交付音频、视频和图片;即使 include 请求并生成了字幕,也不会输出或下载独立字幕文件。实际 duration 不等于精确时长控制能力。 模型不支持、引用非法、鉴权或生成失败时说明真实错误,不改成视频请求、不自动切换模型、不重复提交未知结果的任务。查询报错与已确认的失败终态按 [查询契约](query-result.md) 区分。 diff --git a/skills/xyq-nest-skill/commands/query-result.md b/skills/xyq-nest-skill/commands/query-result.md index 1cb9546..88c3a54 100644 --- a/skills/xyq-nest-skill/commands/query-result.md +++ b/skills/xyq-nest-skill/commands/query-result.md @@ -1,6 +1,6 @@ # query-result:查询异步结果并下载 -用于生成和视频处理命令返回的任务,也用于用户要求查询的已有任务。三个参数均必填: +用于生成和视频处理命令返回的任务,也用于用户要求查询的已有任务。默认保留原有图视频查询行为;`generate-audio` 创建的任务必须加 `--audio`,包含 dubbing 返回视频的情况。这个开关选择查询契约,不限制模型。三个参数均必填: | 参数 | 含义 | | --- | --- | @@ -14,6 +14,14 @@ pippit-tool-cli query-result --thread-id THREAD_ID --run-id RUN_ID --download-dir "./xyq_output" ``` +音频任务: + +```bash +pippit-tool-cli query-result --audio --thread-id THREAD_ID --run-id RUN_ID --download-dir "./xyq_output" +``` + +已有任务沿用生成命令对应的查询模式,不因返回文件的媒体类型切换。缺少来源信息时先从当前任务上下文确认;错误响应可能没有产物,不能据此猜测任务类型。 + ## 输出契约 stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回,因此必须先检查 `error_message`。 @@ -23,7 +31,8 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回 | `completed` | 是否结束;失败时也可能为 `true`,不等于成功 | | `error_message` | 非空即错误,不能因为 `completed=false` 而忽略 | | `thread_id` / `run_id` | 对应的查询任务 | -| `images[]` / `videos[]` / `audios[]` | 成功后获取的媒体,每项含 `download_url`、`output_path`;音频另含可用的 `name`、`pippit_asset_id`、`duration`(秒) | +| `images[]` / `videos[]` | 成功后获取的媒体,每项含 `download_url`、`output_path` | +| `audios[]` | 仅 `--audio` 模式返回;每项含 `download_url`、`output_path`,以及可用的 `name`、`pippit_asset_id`、`duration`(秒) | 成功示例(ID、URL 和文件名仅为示意): @@ -34,19 +43,18 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回 "run_id": "RUN_ID", "error_message": "", "images": [{"download_url": "https://example.com/image.jpeg", "output_path": "./xyq_output/asset.jpeg"}], - "videos": [], - "audios": [] + "videos": [] } ``` -任务尚未完成时通常返回 `completed=false`、空错误、空媒体数组。失败或取消返回 `completed=true` 和非空错误。命令不提供完整会话消息、用户反问或可区分的所有状态;不能仅凭这个响应断言具体进度或等待用户输入。轮询停止条件见 [共用流程](../workflows/async-delivery.md)。 +默认模式保留既有语义:Run 失败或 API 业务错误返回 `completed=true` 和非空错误;取消及其他未成功、未失败状态可能返回 `completed=false`、空错误。命令不提供完整会话消息、用户反问或可区分的所有状态;不能仅凭默认响应断言具体进度、取消状态或等待用户输入。轮询停止条件见 [共用流程](../workflows/async-delivery.md)。 -API 错误响应中,只有与请求 Run ID 匹配的结构化状态明确为失败或取消,CLI 才返回 `completed=true`、Run 失败原因及可用 LogID。错误响应缺少匹配的终态时,返回 `completed=false` 和以“查询失败:”开头的错误,保留 LogID 与任务 ID。此时未确认 Run 状态;排障后可查询同一 Run,不从错误文案推断生成结果,也不自动重新生成或无限轮询。 +`--audio` 模式中,Run 失败或取消返回 `completed=true` 和非空错误。API 错误响应只有与请求 Run ID 匹配的结构化状态明确为失败或取消,CLI 才返回 `completed=true`、Run 失败原因及可用 LogID。错误响应缺少匹配的终态时,返回 `completed=false` 和以“查询失败:”开头的错误,保留 LogID 与任务 ID。此时未确认 Run 状态;排障后可带 `--audio` 查询同一 Run,不从错误文案推断生成结果,也不自动重新生成或无限轮询。 ## 下载行为 文件名由 CLI 根据产物信息生成,以 `output_path` 为准,不自行拼接编号或推测扩展名。同目录已有同名文件可能被复用;复用不证明内容相同,也不代表本次新下载。发现同名文件属于其他产物时,选择用户认可范围内的未冲突目录再查询,不删除已有文件。 -音频扩展名只采用已知格式,依次参考元数据、URL 路径、音频名称;无法判断编码时使用 `.audio`。不要仅改后缀就声称完成转码,也不能把 `duration` 当作支持精确时长控制的证据。 +`--audio` 模式的音频扩展名只采用已知格式,依次参考元数据、URL 路径、音频名称;无法判断编码时使用 `.audio`。不要仅改后缀就声称完成转码,也不能把 `duration` 当作支持精确时长控制的证据。 找不到产物、链接缺失或下载失败时会返回错误。当前任一文件下载失败可能使整次查询只返回错误,无法据此认定其他文件都没下载或已完整交付。复查本次结果,不把输出目录里的任意旧文件当作本次产物。 diff --git a/skills/xyq-nest-skill/scripts/ensure-cli.js b/skills/xyq-nest-skill/scripts/ensure-cli.js index 4ead295..0185666 100644 --- a/skills/xyq-nest-skill/scripts/ensure-cli.js +++ b/skills/xyq-nest-skill/scripts/ensure-cli.js @@ -8,7 +8,7 @@ const path = require("path"); const REQUIRED_COMMANDS = [ "status", "login", "logout", "query-result", - "generate-image", "generate-audio", "generate-video", "video-super-resolution", + "generate-image", "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance", ]; @@ -44,7 +44,7 @@ function findCLIOnPath() { return null; } -function ensureCLI({ canvas = false } = {}) { +function ensureCLI({ canvas = false, audio = false } = {}) { if (Number(process.versions.node.split(".")[0]) < 16) { throw new Error("需要 Node.js 16+ 和 npm。"); } @@ -80,6 +80,7 @@ function ensureCLI({ canvas = false } = {}) { throw new Error(`CLI 版本 ${version} 与 npm 包版本 ${expectedVersion} 不一致。`); } const commands = REQUIRED_COMMANDS.map((command) => [command]); + if (audio) commands.push(["generate-audio"], ["query-result", "--audio"]); if (canvas) { for (const command of ["create", "get", "allocate", "upload", "apply"]) commands.push(["canvas", command]); } @@ -158,13 +159,14 @@ function ensureCLI({ canvas = false } = {}) { if (require.main === module) { if (process.argv.length === 3 && process.argv[2] === "--help") { - console.log("Usage: node ensure-cli.js [--canvas]\n优先复用 PATH 或缓存中命令齐全的 CLI,不存在或缺少必需命令时安装 npm latest,成功输出 {cli_path, version} JSON。--canvas 额外验证画布原生命令和 npm 运行时,并返回 canvas_entry。"); - } else if (process.argv.length !== 2 && !(process.argv.length === 3 && process.argv[2] === "--canvas")) { - console.error("不支持的参数。用法:node ensure-cli.js [--canvas]"); + console.log("Usage: node ensure-cli.js [--canvas] [--audio]\n优先复用 PATH 或缓存中命令齐全的 CLI,不存在或缺少必需命令时安装 npm latest,成功输出 {cli_path, version} JSON。--canvas 额外验证画布原生命令和 npm 运行时,并返回 canvas_entry。--audio 额外验证音频生成与 query-result --audio;其他任务不要求音频能力。"); + } else if (process.argv.slice(2).some((arg) => !["--canvas", "--audio"].includes(arg)) + || new Set(process.argv.slice(2)).size !== process.argv.length - 2) { + console.error("不支持的参数。用法:node ensure-cli.js [--canvas] [--audio]"); process.exitCode = 1; } else { try { - console.log(JSON.stringify(ensureCLI({ canvas: process.argv[2] === "--canvas" }))); + console.log(JSON.stringify(ensureCLI({ canvas: process.argv.includes("--canvas"), audio: process.argv.includes("--audio") }))); } catch (err) { console.error(err.message); process.exitCode = 1; diff --git a/skills/xyq-nest-skill/scripts/install.md b/skills/xyq-nest-skill/scripts/install.md index 1e4da1c..541c95e 100644 --- a/skills/xyq-nest-skill/scripts/install.md +++ b/skills/xyq-nest-skill/scripts/install.md @@ -37,3 +37,7 @@ node "{baseDir}/scripts/ensure-cli.js" 成功额外返回 `canvas_entry`,供 `node "CANVAS_ENTRY" canvas command ...` 使用;`cli_path` 仍用于原生命令。语义操作的实际支持范围以当前目录为准,检查通过不代表所有业务操作或服务端权限都可用。 独立 Go 二进制没有 npm 入口,或包内运行时缺失/损坏时,Canvas 模式按原有规则检查缓存并至多安装一次最新完整 npm 包,保留旧安装直到新版本通过。普通媒体任务不要求 Canvas 运行时,也不会因为缺少它而升级。不要把缓存内的 `run.js` 单独复制出来,它依赖相邻模块与 `dist` 运行时。 + +## 音频命令检查 + +音频生成及其结果查询使用 `node "{baseDir}/scripts/ensure-cli.js" --audio`,额外检查 `generate-audio` 和 `query-result --audio` 的帮助。图视频、授权和积分等原有流程不要求音频能力,不会因为已有 CLI 缺少音频命令而升级。查询音频任务时仍需显式传 `query-result --audio`;安装检查不会替命令添加参数。 diff --git a/skills/xyq-nest-skill/tests/agent_test_cases.md b/skills/xyq-nest-skill/tests/agent_test_cases.md index 951d64d..75b7720 100644 --- a/skills/xyq-nest-skill/tests/agent_test_cases.md +++ b/skills/xyq-nest-skill/tests/agent_test_cases.md @@ -11,7 +11,7 @@ | 仅咨询 | 怎么生成一张猫咪图片? | 解释命令,不发起收费生成 | | 图片编辑 | 图1是底图,图2只提供猫形象,替换猫但保留背景;已指定模型 | 两次 --image 顺序与用户角色一致,原始指令不改写 | | 生图比例 | 用 seedream_5.0_pro,16:9 生图 | --ratio 2;不把视频比例字符串用在生图命令中 | -| 普通生音频 | 用温暖自然的声音介绍今天的旅行 | 读取生音频模块与异步交付流程,保留 prompt;未给模型的传统 flags 调用保留历史默认 seedaudio_1.0,不补可选配置 | +| 普通生音频 | 用温暖自然的声音介绍今天的旅行 | 通过 ensure-cli.js --audio 检查能力,读取生音频模块与异步交付流程,保留 prompt;未给模型的传统 flags 调用保留历史默认 seedaudio_1.0,不补可选配置;用 query-result --audio 查询 | | 音频缺少描述 | 只要求生成音频,未提供内容或模式 | 先补齐创作意图,不用中性 message 代替用户创作描述;已明确无需 prompt 的模式按服务端契约执行 | | 音频参考与配置 | 参考三个本地音频生成旁白,输出 WAV、24000 Hz | 按用户顺序传三个 --audio,--format wav、--sample-rate 24000;不添加其他配置 | | 音频误用视频命令 | 为视频生成一段背景音乐素材 | 走 generate-audio,不用 generate-video;仅交付音频,不声称已把音乐合成到视频 | @@ -20,7 +20,7 @@ | 音频参考图 | 用本地图片创作音频 | 说明上传与提交链路已接通,但真实生成尚未验收,不承诺稳定可用;执行时只提交一次并查询原任务,不因失败自动换模型或重交 | | 模型专属音频参数 | 用户给定 V2 配置、分轨或翻配参数 | 使用裸音频参数 JSON;不把旧 rates flags 自动映射到 V2,不伪造取值或承诺服务端支持所有模式 | | 音频验证状态 | 询问 reference、separate、dubbing 是否可用 | 按音频命令文档说明已成功的具体输入与模式,不扩大为所有组合均可用,不把验证表当模型白名单 | -| 视频翻配与交付 | 使用符合目标模式规则的视频翻配英语,并请求 video_url | 通过 JSON dubbing 参数与 --video 提交;查询成功后同时检查并交付 audios/videos,不只交音频,也不擅自修改不合规素材 | +| 视频翻配与交付 | 使用符合目标模式规则的视频翻配英语,并请求 video_url | 通过 JSON dubbing 参数与 --video 提交;用 query-result --audio 查询,成功后同时检查并交付 audios/videos,不只交音频,也不擅自修改不合规素材 | | separate 缺少 prompt | 请求分轨但缺少该模式所需 prompt | 补齐用户描述,不以外层中性 message 替代;服务端拒绝时报告真实错误,不重交空参数 | | JSON 无 prompt | 用户已给合法的无需 prompt 的任务参数 | 保留 prompt/text 缺省和显式 0/false/null,不补默认模型或虚构生成文本;仅外层 message 使用任务描述 | | 参数来源冲突 | JSON model 与 --model 同时出现,或 audio_config.format 与 --format 同时出现 | 上传前明确报错,请用户保留一个来源,不静默覆盖;--input 和 --file 也不能混用 | @@ -36,6 +36,8 @@ | 擦字幕 | 去除 source.mp4 的字幕 | 走 erase-video-subtitle,不添加超分步骤 | | 组合处理 | 先擦字幕,再超分到 1080p | 先查询并检查第一步视频,再传其 output_path;使用第二步新 ID 查询 | | 查询已有任务 | 提供一对任务 ID,只问结果 | 直接 query-result,不重新生成;缺 ID 时询问 | +| 查询已有音频任务 | 提供 generate-audio 返回的一对任务 ID | 使用 query-result --audio,包括带视频产物的 dubbing;只因产物是视频也不能切回默认查询模式 | +| 旧 CLI 仅缺音频能力 | 只要求生图、生视频或查询原有任务 | 默认 ensure-cli.js 继续复用旧 CLI,不为音频能力安装升级;原有 query-result 不加 --audio | | 积分 | 小云雀还剩多少积分? | 直接查积分,字符串 "0" 正常显示,不轮询、不要求消费确认 | | 授权 | 查看登录状态 | 只执行 status,不自动退出、轮换凭据或生成内容 | @@ -45,8 +47,8 @@ | --- | --- | | 查询退出码 0,error_message 非空 | 按错误处理,不能判成功,也不能因 completed=false 继续空轮询 | | completed=true 且有错误 | 失败,保留原任务 ID,不重新生成 | -| 音频 Run 明确失败或取消 | completed=true 仍按失败处理,保留失败原因和原任务 ID;没有音频时不宣称生成或交付成功 | -| 音频查询报错且 completed=false | 说明查询错误,不能据此断言 Run 终态;排障后查询同一 Run,不忽略错误无限轮询或重新生成 | +| query-result --audio 的 Run 明确失败或取消 | completed=true 仍按失败处理,保留失败原因和原任务 ID;没有音频时不宣称生成或交付成功 | +| query-result --audio 报错且 completed=false | 说明查询错误,不能据此断言 Run 终态;排障后查询同一 Run,不忽略错误无限轮询或重新生成 | | completed=false、空错误 | 仅说明暂未取得最终结果,按共用流程有界等待,不编造进度或具体状态 | | completed=true、空错误、空产物 | 报告结果异常,不宣称交付完成 | | 成功返回多张图/多个视频 | 检查所有 output_path,通过宿主逐项展示真实媒体 | diff --git a/skills/xyq-nest-skill/workflows/async-delivery.md b/skills/xyq-nest-skill/workflows/async-delivery.md index 00fe90e..134a6a7 100644 --- a/skills/xyq-nest-skill/workflows/async-delivery.md +++ b/skills/xyq-nest-skill/workflows/async-delivery.md @@ -4,8 +4,8 @@ ## 保存任务与查询 -1. 提交成功后保存 `thread_id`、`run_id` 和 `web_thread_link`,立即向用户展示任务链接。已有任务直接使用其标识进入查询,不再次生成。 -2. 使用同一 `cli_path` 和同一对任务 ID 调用查询命令,明确 `--download-dir`。每隔 10 秒查询一次,不在轮询中重新安装 CLI。 +1. 提交成功后保存 `thread_id`、`run_id`、`web_thread_link` 和生成命令来源,立即向用户展示任务链接。已有任务直接使用其标识进入查询,不再次生成。 +2. 使用同一 `cli_path` 和同一对任务 ID 调用查询命令,明确 `--download-dir`。`generate-audio` 创建的任务必须使用 `query-result --audio`,包括最终返回视频的 dubbing;原有图视频任务继续使用不带 `--audio` 的 `query-result`。每隔 10 秒查询一次,不在轮询中重新安装 CLI。 3. 先检查命令是否可执行、输出是否为有效 JSON,再读取非空 `error_message`;有错误就进入下面的失败处理。 4. 无错误且 `completed=false` 时继续轮询,只说明尚未取得最终结果,不编造创作消息或完成百分比。 5. 无错误且 `completed=true` 时,要求媒体数组中有待交付产物,逐项核对 `output_path` 后进入交付。空结果或缺少必要字段时报告异常,不空轮询。 @@ -16,7 +16,7 @@ - 可识别的暂时网络错误或下载错误:间隔 10 秒后仅重试同一次查询一次;仍失败则报告阻塞。无法判别错误类别时停止,避免盲目重试。 - 用户要求停止时停止本地轮询;这不等于已取消服务端任务。持续未完成时遵守用户或宿主的等待时限,最迟在连续 48 小时后停止等待,保留 ID 供后续恢复查询。宿主无法持续执行时如实说明,不承诺后台监控。 - 当前查询输出无法区分所有非成功状态;若长时间未完成或任务页面显示需要交互,报告当前查询能力的限制,不代答或新建任务。 -- 恢复时继续查询原任务,复用该任务已确定的输出目录,对已交付产物去重。 +- 恢复时继续查询原任务,保留原查询模式(音频任务继续带 `--audio`),复用该任务已确定的输出目录,对已交付产物去重。 ## 媒体交付完成标准