diff --git a/AGENTS.md b/AGENTS.md index 5b51da1..6b98a17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,3 +41,25 @@ Commands: Exit codes: `0` success, `2` user error, `5` business failure, `6` interrupted, `7` partial success (preview ready, export failed). + +## Video kinds + +`vk create --mode ` picks which figlens pipeline runs: + +- **default (no flag)** — generative video from the document. +- **`--mode replica`** — PPT/PDF page-by-page reproduction. Suited + for slide decks where the visual structure is the message. +- **`--mode script`** — uses the uploaded document text *verbatim* + as the narration. The doc must pass a quality preflight (length, + characters, content). Preflight failures exit **2** with a clear + message; agents should treat them as user-input problems, not + retries. + +Modes combine freely with `--aspect horizontal|vertical` (or `16:9` / +`9:16`) and `--bgm`. All three flags are independent of `--from`, +`--prompt`, `--voice`, and `--export`. + +Exit-code summary for new modes: +- `2` — `--mode `, `--aspect `, or script preflight rejected the document. +- `5` — pipeline business failure (e.g., insufficient credits) — same as today. +- `7` — preview ok, MP4 export failed — same as today. diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e77d6..a1ca282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.4.2 — 2026-05-14 + +### New + +- `vk create --mode replica` runs the figlens PPT/PDF page-by-page + replica pipeline. `vk create --mode script` runs the verbatim-script + ("讲稿锁定") pipeline that uses the uploaded document as the + narration. Both modes are now visible to humans and AI agents + through the same single-flag surface; default invocation (no + `--mode`) is unchanged. +- `vk create --aspect horizontal|vertical` selects 16:9 or 9:16 + output. Accepts `16:9` / `9:16` as aliases. +- `vk create --bgm` enables background music (off by default). +- SSE progress events for the replica pipeline's new nodes + (`doc_replica_plan`, `doc_replica_shoot`) now surface in `text` and + `ndjson` output — previously filtered out by the CLI's stage map. + +### Changed + +- Script-mode preflight failures (`POST /v1/tasks/init` returns code + `100004`) now exit **2** (validation, user fixes input) with the + backend's localized message, instead of exit 5 with a generic + "business error" label. +- `figlens.InitTask` now takes `InitTaskParams{KnowledgeID, DocID, + VideoKind}`. Default-zero params produce the same wire body as + before (`{"v": 3}`), so callers that don't use script mode are + unaffected. + ## 0.4.1 — 2026-04-24 ### Fixed diff --git a/README.md b/README.md index 2d58b54..1e12b39 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,14 @@ output=sess_xxx.mp4 Or one-shot: `vk create --from ... --export --yes`. +### Choose a video mode + +```bash +vk create --from deck.pdf --mode replica # PPT/PDF page-by-page +vk create --from talk.docx --mode script # narrate the doc verbatim +vk create --from --aspect vertical --bgm +``` + ### Voice Templates ```bash diff --git a/README.zh.md b/README.zh.md index e91b186..aedbe64 100644 --- a/README.zh.md +++ b/README.zh.md @@ -203,6 +203,14 @@ output=sess_xxx.mp4 或者一键搞定:`vk create --from ... --export --yes`。 +### 选择视频模式 + +```bash +vk create --from deck.pdf --mode replica # PPT/PDF 逐页还原 +vk create --from talk.docx --mode script # 讲稿模式(用文档原文做旁白) +vk create --from --aspect vertical --bgm +``` + ### 音色模板 ```bash diff --git a/client/figlens/figlens_test.go b/client/figlens/figlens_test.go index f70e097..7e330eb 100644 --- a/client/figlens/figlens_test.go +++ b/client/figlens/figlens_test.go @@ -3,8 +3,11 @@ package figlens_test import ( "context" "encoding/json" + "fmt" + "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/vibeknow/cli/client/figlens" @@ -20,10 +23,12 @@ func figlensResp(w http.ResponseWriter, data any) { } func TestInitTask(t *testing.T) { + var gotBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/tasks/init" || r.Method != "POST" { t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) } + _ = json.NewDecoder(r.Body).Decode(&gotBody) figlensResp(w, map[string]any{ "task_id": 123, "session_id": "s_abc", "work_id": 456, "v": 3, }) @@ -31,15 +36,46 @@ func TestInitTask(t *testing.T) { defer srv.Close() c := figlens.New(srv.URL, staticToken("tok")) - task, err := c.InitTask(context.Background()) + task, err := c.InitTask(context.Background(), figlens.InitTaskParams{ + KnowledgeID: "kb_1", DocID: "doc_1", VideoKind: "script_lock", + }) if err != nil { t.Fatalf("InitTask: %v", err) } if task.TaskID != 123 { t.Fatalf("task_id = %d", task.TaskID) } - if task.SessionID != "s_abc" { - t.Fatalf("session_id = %q", task.SessionID) + if gotBody["v"] != float64(3) { + t.Fatalf("v = %v, want 3", gotBody["v"]) + } + if gotBody["knowledge_id"] != "kb_1" { + t.Fatalf("knowledge_id = %v", gotBody["knowledge_id"]) + } + if gotBody["video_kind"] != "script_lock" { + t.Fatalf("video_kind = %v", gotBody["video_kind"]) + } +} + +func TestInitTask_OmitsEmptyFields(t *testing.T) { + var raw []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ = io.ReadAll(r.Body) + figlensResp(w, map[string]any{ + "task_id": 1, "session_id": "s_x", "work_id": 2, "v": 3, + }) + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + _, err := c.InitTask(context.Background(), figlens.InitTaskParams{}) + if err != nil { + t.Fatalf("InitTask: %v", err) + } + body := string(raw) + for _, f := range []string{"knowledge_id", "doc_id", "video_kind"} { + if strings.Contains(body, f) { + t.Fatalf("%s unexpectedly present in empty-params body: %s", f, body) + } } } @@ -134,3 +170,50 @@ func TestSignedURL(t *testing.T) { t.Fatalf("url = %q", u) } } + +func TestFastQueryOptimize_SendsVideoKind(t *testing.T) { + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, `data: {"code":200,"data":{"type":"aim_result","answer_done":{"text":"ok"}}} + +data: [DONE] + +`) + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + _, err := c.FastQueryOptimize(context.Background(), figlens.OptimizeParams{ + KnowledgeID: "kb_1", DocID: "doc_1", VideoKind: "script_lock", + }, nil) + if err != nil { + t.Fatalf("FastQueryOptimize: %v", err) + } + if gotBody["video_kind"] != "script_lock" { + t.Fatalf("video_kind on wire = %q, want %q", gotBody["video_kind"], "script_lock") + } +} + +func TestFastQueryOptimize_OmitsVideoKindWhenEmpty(t *testing.T) { + var raw []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, `data: {"code":200,"data":{"type":"aim_result","answer_done":{"text":"ok"}}} + +data: [DONE] + +`) + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + _, _ = c.FastQueryOptimize(context.Background(), figlens.OptimizeParams{ + KnowledgeID: "kb_1", DocID: "doc_1", + }, nil) + if strings.Contains(string(raw), "video_kind") { + t.Fatalf("video_kind unexpectedly present in wire body: %s", raw) + } +} diff --git a/client/figlens/optimize.go b/client/figlens/optimize.go index 988b82c..7f80feb 100644 --- a/client/figlens/optimize.go +++ b/client/figlens/optimize.go @@ -14,6 +14,7 @@ type OptimizeParams struct { KnowledgeID string `json:"knowledge_id"` DocID string `json:"doc_id"` Query string `json:"query,omitempty"` + VideoKind string `json:"video_kind,omitempty"` } type optimizePayload struct { diff --git a/client/figlens/stream.go b/client/figlens/stream.go index 5a86c42..1d1e7c9 100644 --- a/client/figlens/stream.go +++ b/client/figlens/stream.go @@ -7,6 +7,7 @@ import ( "io" "strings" + "github.com/vibeknow/cli/internal/httpclient" "github.com/vibeknow/cli/internal/sse" "github.com/vibeknow/cli/internal/stage" ) @@ -19,10 +20,13 @@ type StreamParams struct { DocID string `json:"doc_id,omitempty"` VoiceID string `json:"voice_id,omitempty"` BGMEnabled bool `json:"bgm_enabled,omitempty"` + Aspect string `json:"aspect,omitempty"` + VideoKind string `json:"video_kind,omitempty"` } type StreamEvent struct { Type string + Code string // set on task.failed when payload carries an envelope code Stage string Node string Message string @@ -47,18 +51,13 @@ type processLog struct { Message string `json:"message"` } -// mapSSECode maps a backend envelope code from an SSE payload to a CLI error code string. +// mapSSECode maps an SSE envelope code to a CLI error code label, delegating +// to httpclient.MapBusinessCode so the two transports never diverge. func mapSSECode(code int) string { - switch code { - case 100001: - return "insufficient_credits" - case 100002: - return "freeze_not_found" - case 100003: - return "concurrent_work_limit" - default: - return "business_error" + if label, ok := httpclient.MapBusinessCode(code); ok { + return label } + return "business_error" } func (c *Client) StreamChat(ctx context.Context, params StreamParams, onEvent func(StreamEvent)) error { @@ -100,8 +99,11 @@ func (c *Client) StreamChat(ctx context.Context, params StreamParams, onEvent fu msg = d.Message } } - code := mapSSECode(payload.Code) - onEvent(StreamEvent{Type: "task.failed", Message: fmt.Sprintf("[%s] %s", code, msg)}) + onEvent(StreamEvent{ + Type: "task.failed", + Code: mapSSECode(payload.Code), + Message: msg, + }) return nil } diff --git a/client/figlens/stream_test.go b/client/figlens/stream_test.go index 39d9b7a..9777f03 100644 --- a/client/figlens/stream_test.go +++ b/client/figlens/stream_test.go @@ -2,6 +2,7 @@ package figlens_test import ( "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -79,3 +80,64 @@ func TestStreamChat_ErrorEvent(t *testing.T) { t.Fatalf("expected task.failed event, got %v", events) } } + +func TestStreamChat_SendsVideoKindAndAspect(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, `data: {"code":200,"data":{"type":"aim_result","session_id":"s_abc"}} + +data: [DONE] + +`) + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + err := c.StreamChat(context.Background(), figlens.StreamParams{ + TaskID: 1, SessionID: "s_abc", Query: "q", + VideoKind: "replica", Aspect: "vertical", BGMEnabled: true, + }, func(figlens.StreamEvent) {}) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + if gotBody["video_kind"] != "replica" { + t.Fatalf("video_kind = %v, want \"replica\"", gotBody["video_kind"]) + } + if gotBody["aspect"] != "vertical" { + t.Fatalf("aspect = %v, want \"vertical\"", gotBody["aspect"]) + } + if gotBody["bgm_enabled"] != true { + t.Fatalf("bgm_enabled = %v, want true", gotBody["bgm_enabled"]) + } +} + +func TestStreamChat_ScriptInvalidCode(t *testing.T) { + sseBody := `data: {"code":100004,"data":{"message":"讲稿超过 8000 字"}} + +` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, sseBody) + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + var events []figlens.StreamEvent + err := c.StreamChat(context.Background(), figlens.StreamParams{TaskID: 1, SessionID: "s"}, func(ev figlens.StreamEvent) { + events = append(events, ev) + }) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + if len(events) == 0 || events[0].Type != "task.failed" { + t.Fatalf("expected task.failed, got %v", events) + } + if events[0].Code != "script_invalid" { + t.Fatalf("expected Code=script_invalid, got %q", events[0].Code) + } + if events[0].Message != "讲稿超过 8000 字" { + t.Fatalf("expected backend message verbatim, got %q", events[0].Message) + } +} diff --git a/client/figlens/task.go b/client/figlens/task.go index ec620dd..053f824 100644 --- a/client/figlens/task.go +++ b/client/figlens/task.go @@ -5,6 +5,13 @@ import ( "fmt" ) +// Backend video_kind wire values. The CLI flag names (`replica`, `script`) +// map to these via cmd.resolveVideoKind. +const ( + VideoKindReplica = "replica" + VideoKindScriptLock = "script_lock" +) + type Task struct { TaskID int64 `json:"task_id"` SessionID string `json:"session_id"` @@ -12,9 +19,21 @@ type Task struct { V int `json:"v,omitempty"` } -func (c *Client) InitTask(ctx context.Context) (*Task, error) { +type InitTaskParams struct { + KnowledgeID string `json:"knowledge_id,omitempty"` + DocID string `json:"doc_id,omitempty"` + VideoKind string `json:"video_kind,omitempty"` +} + +type initTaskWire struct { + V int `json:"v"` + InitTaskParams +} + +func (c *Client) InitTask(ctx context.Context, p InitTaskParams) (*Task, error) { var t Task - if err := c.http.Do(ctx, "POST", "/v1/tasks/init", map[string]int{"v": 3}, &t); err != nil { + body := initTaskWire{V: 3, InitTaskParams: p} + if err := c.http.Do(ctx, "POST", "/v1/tasks/init", body, &t); err != nil { return nil, fmt.Errorf("init task: %w", err) } return &t, nil diff --git a/cmd/create.go b/cmd/create.go index 7917175..f974bcf 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "regexp" @@ -14,6 +15,7 @@ import ( "github.com/vibeknow/cli/client/figlens" "github.com/vibeknow/cli/client/vectoria" "github.com/vibeknow/cli/internal/cliauth" + "github.com/vibeknow/cli/internal/clerr" "github.com/vibeknow/cli/internal/cmdutil" "github.com/vibeknow/cli/internal/errs" "github.com/vibeknow/cli/internal/i18n" @@ -29,6 +31,9 @@ var ( flagCreateAsync bool flagCreateExport bool flagCreateYes bool + flagCreateMode string + flagCreateAspect string + flagCreateBGM bool ) var docIDRe = regexp.MustCompile(`^doc_[a-zA-Z0-9]{8,}$`) @@ -47,6 +52,15 @@ var createCmd = &cobra.Command{ return fmt.Errorf("--from is required") } + videoKind, err := resolveVideoKind(flagCreateMode) + if err != nil { + return err + } + aspect, err := resolveAspect(flagCreateAspect) + if err != nil { + return err + } + ctx := context.Background() // Step 1: resolve --from to kb_id + doc_id. @@ -75,6 +89,10 @@ var createCmd = &cobra.Command{ } } + if videoKind == figlens.VideoKindScriptLock && kbID == "" { + return clerr.Validation(i18n.T("create.err.script_needs_doc")) + } + // Step 2: optimize prompt (skip if user provided --prompt). _, url, tp, err := cmdutil.Default().Service("figlens") if err != nil { @@ -95,6 +113,7 @@ var createCmd = &cobra.Command{ optimized, err := fc.FastQueryOptimize(ctx, figlens.OptimizeParams{ KnowledgeID: kbID, DocID: docID, + VideoKind: videoKind, }, onDelta) if streaming { fmt.Fprintln(os.Stderr) @@ -114,11 +133,25 @@ var createCmd = &cobra.Command{ // Step 3: init figlens task. fmt.Fprintln(os.Stderr, i18n.T("create.init_task")) - task, err := fc.InitTask(ctx) + initParams := figlens.InitTaskParams{VideoKind: videoKind} + if videoKind == figlens.VideoKindScriptLock { + initParams.KnowledgeID = kbID + initParams.DocID = docID + } + task, err := fc.InitTask(ctx, initParams) if err != nil { if errs.HasCode(err, "insufficient_credits") { return fmt.Errorf("%s", i18n.T("credits.insufficient")) } + if errs.HasCode(err, "script_invalid") { + // Backend's localized message already lives on the error. + // Exit 2 via clerr.Validation — this is a user-input problem. + var o *errs.Object + if errors.As(err, &o) { + return clerr.Validation(o.Message) + } + return clerr.Validation(err.Error()) + } return err } @@ -135,7 +168,7 @@ var createCmd = &cobra.Command{ format, _ := cmd.Flags().GetString("output") isNDJSONCreate := format == "ndjson" - var taskFailed bool + var failExitCode int // 0 = not failed; 5 = business; 2 = script_invalid (user-fixable input) var successSessionID string err = fc.StreamChat(ctx, figlens.StreamParams{ @@ -145,6 +178,9 @@ var createCmd = &cobra.Command{ KnowledgeID: kbID, DocID: docID, VoiceID: flagCreateVoiceID, + BGMEnabled: flagCreateBGM, + Aspect: aspect, + VideoKind: videoKind, }, func(ev figlens.StreamEvent) { switch ev.Type { case "node.started", "node.succeeded", "node.failed": @@ -175,15 +211,23 @@ var createCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, i18n.T("create.task_succeeded")) } case "task.failed": - taskFailed = true + failExitCode = 5 + if ev.Code == "script_invalid" { + failExitCode = 2 + } if isNDJSONCreate { _ = output.NewNDJSON(cmd.OutOrStdout()).Event(map[string]any{ - "type": "task.failed", "message": ev.Message, + "type": "task.failed", + "code": ev.Code, + "message": ev.Message, }) } else { - if strings.Contains(ev.Message, "insufficient_credits") { + switch ev.Code { + case "insufficient_credits": fmt.Fprintln(os.Stderr, i18n.T("credits.insufficient")) - } else { + case "script_invalid": + fmt.Fprintln(os.Stderr, ev.Message) + default: fmt.Fprintln(os.Stderr, i18n.T("create.task_failed", ev.Message)) } } @@ -199,8 +243,8 @@ var createCmd = &cobra.Command{ os.Exit(6) } - if taskFailed { - os.Exit(5) + if failExitCode != 0 { + os.Exit(failExitCode) } if successSessionID == "" { @@ -297,6 +341,9 @@ func init() { createCmd.Flags().BoolVar(&flagCreateAsync, "async", false, "print task_id/session_id and exit without waiting") createCmd.Flags().BoolVar(&flagCreateExport, "export", false, "after preview, also render MP4 (extra credits + time)") createCmd.Flags().BoolVarP(&flagCreateYes, "yes", "y", false, "skip export confirmation prompt") + createCmd.Flags().StringVar(&flagCreateMode, "mode", "", i18n.T("create.flag.mode")) + createCmd.Flags().StringVar(&flagCreateAspect, "aspect", "", i18n.T("create.flag.aspect")) + createCmd.Flags().BoolVar(&flagCreateBGM, "bgm", false, i18n.T("create.flag.bgm")) } // uploadFile uploads a local file to vectoria and returns kb_id + doc_id. @@ -392,3 +439,33 @@ func pollDocReady(ctx context.Context, vc *vectoria.Client, kbID, docID string) } } +// resolveVideoKind maps the --mode flag to the backend video_kind wire value. +// Empty passes through (caller omits the field); unrecognized → Validation error. +func resolveVideoKind(flag string) (string, error) { + switch strings.ToLower(strings.TrimSpace(flag)) { + case "": + return "", nil + case "replica": + return figlens.VideoKindReplica, nil + case "script": + return figlens.VideoKindScriptLock, nil + default: + return "", clerr.Validation(i18n.T("create.err.mode_invalid", flag)) + } +} + +// resolveAspect normalizes --aspect (canonical words + 16:9 / 9:16 aliases) +// to the backend wire value. +func resolveAspect(flag string) (string, error) { + switch strings.ToLower(strings.TrimSpace(flag)) { + case "": + return "", nil + case "horizontal", "16:9": + return "horizontal", nil + case "vertical", "9:16": + return "vertical", nil + default: + return "", clerr.Validation(i18n.T("create.err.aspect_invalid", flag)) + } +} + diff --git a/cmd/create_test.go b/cmd/create_test.go new file mode 100644 index 0000000..73b6bd1 --- /dev/null +++ b/cmd/create_test.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestResolveVideoKind(t *testing.T) { + tests := []struct { + flag string + want string + wantErr bool + }{ + {"", "", false}, + {"replica", "replica", false}, + {"script", "script_lock", false}, + {"SCRIPT", "script_lock", false}, + {"script_lock", "", true}, // backend jargon, not a CLI flag value + {"bogus", "", true}, + } + for _, tt := range tests { + t.Run(tt.flag, func(t *testing.T) { + got, err := resolveVideoKind(tt.flag) + if tt.wantErr && err == nil { + t.Fatalf("expected error for %q", tt.flag) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("resolveVideoKind(%q) = %q, want %q", tt.flag, got, tt.want) + } + }) + } +} + +func TestResolveAspect(t *testing.T) { + tests := []struct { + flag string + want string + wantErr bool + }{ + {"", "", false}, + {"horizontal", "horizontal", false}, + {"vertical", "vertical", false}, + {"16:9", "horizontal", false}, + {"9:16", "vertical", false}, + {"HORIZONTAL", "horizontal", false}, + {"square", "", true}, + } + for _, tt := range tests { + t.Run(tt.flag, func(t *testing.T) { + got, err := resolveAspect(tt.flag) + if tt.wantErr && err == nil { + t.Fatalf("expected error for %q", tt.flag) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("resolveAspect(%q) = %q, want %q", tt.flag, got, tt.want) + } + }) + } +} + +func TestResolveVideoKind_ErrorMessageMentionsValues(t *testing.T) { + _, err := resolveVideoKind("xyz") + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "replica") || !strings.Contains(msg, "script") { + t.Fatalf("error must list allowed values, got: %q", msg) + } +} + diff --git a/internal/httpclient/errors.go b/internal/httpclient/errors.go index ec65f1d..37c8455 100644 --- a/internal/httpclient/errors.go +++ b/internal/httpclient/errors.go @@ -94,6 +94,29 @@ const ( CodeAccountPendingDeletion = "account_pending_deletion" ) +// MapBusinessCode maps a 100xxx-range backend envelope code to a stable +// CLI error code label. Returns ok=false for codes outside that range so +// callers can fall back to their own mapping (e.g. HTTP-class for HTTP, +// "business_error" for SSE). Shared by mapEnvelopeCode (HTTP path) and +// client/figlens.mapSSECode (SSE path) so a new business code only needs +// to be added once. +func MapBusinessCode(envCode int) (string, bool) { + switch envCode { + case 100001: + return "insufficient_credits", true + case 100002: + return "freeze_not_found", true + case 100003: + return "concurrent_work_limit", true + case 100004: + return "script_invalid", true + } + if envCode >= 100000 { + return "business_error", true + } + return "", false +} + // mapEnvelopeCode maps a backend envelope code + HTTP status to a CLI error code. // Backend aether codes: 40xxx = 4xx class, 50xxx = 5xx class, 100xxx+ = business errors. func mapEnvelopeCode(envCode, httpStatus int) string { @@ -117,18 +140,11 @@ func mapEnvelopeCode(envCode, httpStatus int) string { return CodeSessionReplaced case envCode == 110013: return CodeAccountPendingDeletion - // Business errors (100xxx). - case envCode == 100001: - return "insufficient_credits" - case envCode == 100002: - return "freeze_not_found" - case envCode == 100003: - return "concurrent_work_limit" - case envCode >= 100000: - return "business_error" - default: - return mapHTTPCode(httpStatus) } + if label, ok := MapBusinessCode(envCode); ok { + return label + } + return mapHTTPCode(httpStatus) } func mapHTTPCode(status int) string { diff --git a/internal/httpclient/errors_test.go b/internal/httpclient/errors_test.go index fc22c2b..471e818 100644 --- a/internal/httpclient/errors_test.go +++ b/internal/httpclient/errors_test.go @@ -34,6 +34,7 @@ func TestMapEnvelopeCode(t *testing.T) { {"internal_error", 50001, http.StatusInternalServerError, "internal_error"}, // Known business codes retain their specific labels. {"insufficient_credits", 100001, http.StatusPaymentRequired, "insufficient_credits"}, + {"script_invalid", 100004, http.StatusBadRequest, "script_invalid"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/i18n/strings.go b/internal/i18n/strings.go index 354542f..a1bb9b6 100644 --- a/internal/i18n/strings.go +++ b/internal/i18n/strings.go @@ -72,6 +72,13 @@ func init() { "create.doc_timeout": "timed out waiting for document processing (10m)", "create.async.hint": "hint: run `vibeknow video wait %d --session-id %s` to track progress", + "create.flag.mode": "video mode: replica (PPT/PDF page-by-page) or script (use the doc as the verbatim narration script)", + "create.flag.aspect": "aspect ratio: horizontal (16:9, default) or vertical (9:16)", + "create.flag.bgm": "enable background music (default off)", + "create.err.mode_invalid": "--mode must be one of: replica, script (got %q)", + "create.err.aspect_invalid": "--aspect must be one of: horizontal, vertical, 16:9, 9:16 (got %q)", + "create.err.script_needs_doc": "--mode script requires an uploaded document (--from must be a file or URL, not a doc_id)", + // auth status (text mode) "auth.status.signed_in_as": "✓ Signed in as %s", "auth.status.signed_in": "✓ Signed in", @@ -202,6 +209,13 @@ func init() { "create.doc_timeout": "等待文档解析超时(10 分钟)", "create.async.hint": "提示: 运行 `vibeknow video wait %d --session-id %s` 查看进度", + "create.flag.mode": "视频模式:replica(PPT/PDF 逐页还原)或 script(用文档原文作为讲稿)", + "create.flag.aspect": "画幅:horizontal(16:9,默认)或 vertical(9:16)", + "create.flag.bgm": "启用背景音乐(默认关闭)", + "create.err.mode_invalid": "--mode 必须是 replica 或 script(当前为 %q)", + "create.err.aspect_invalid": "--aspect 必须是 horizontal、vertical、16:9 或 9:16(当前为 %q)", + "create.err.script_needs_doc": "--mode script 需要上传文档(--from 必须是文件或 URL,不能是 doc_id)", + // auth status (text mode) "auth.status.signed_in_as": "✓ 已登录为 %s", "auth.status.signed_in": "✓ 已登录", diff --git a/internal/stage/stage.go b/internal/stage/stage.go index 16ce3c6..fb01831 100644 --- a/internal/stage/stage.go +++ b/internal/stage/stage.go @@ -17,6 +17,8 @@ var nodeToStage = map[string]string{ "video_package": "publish", "video_finish": "publish", "suggest": "suggest", + "doc_replica_plan": "outline", + "doc_replica_shoot": "render", } var nodeDisplayName = map[string]string{ @@ -34,6 +36,8 @@ var nodeDisplayName = map[string]string{ "video_package": "video_package", "video_finish": "video_finish", "suggest": "suggest", + "doc_replica_plan": "doc_replica_plan", + "doc_replica_shoot": "doc_replica_shoot", } var orderedStages = []string{"parse", "outline", "tts", "render", "publish", "suggest"} diff --git a/internal/stage/stage_test.go b/internal/stage/stage_test.go index 74e30d6..5fc0464 100644 --- a/internal/stage/stage_test.go +++ b/internal/stage/stage_test.go @@ -25,6 +25,8 @@ func TestNodeToStage(t *testing.T) { {"video_package", "publish"}, {"video_finish", "publish"}, {"suggest", "suggest"}, + {"doc_replica_plan", "outline"}, + {"doc_replica_shoot", "render"}, } for _, tt := range tests { t.Run(tt.node, func(t *testing.T) { diff --git a/package.json b/package.json index e6c1962..d1430e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vibeknow-cli", - "version": "0.4.1", + "version": "0.4.2", "description": "VibeKnow CLI — turn docs / URLs into videos", "license": "MIT", "bin": { diff --git a/skills/vibeknow-core/SKILL.md b/skills/vibeknow-core/SKILL.md index 2104ad8..45d207c 100644 --- a/skills/vibeknow-core/SKILL.md +++ b/skills/vibeknow-core/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-core -version: 0.4.1 +version: 0.4.2 description: "vibeknow CLI setup, authentication, profile management, and diagnostics. Use when: first-time setup, auth errors, switching environments, diagnosing connection issues." metadata: requires: diff --git a/skills/vibeknow-create/SKILL.md b/skills/vibeknow-create/SKILL.md index 5864dc7..9b77e13 100644 --- a/skills/vibeknow-create/SKILL.md +++ b/skills/vibeknow-create/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-create -version: 0.4.1 +version: 0.4.2 description: "Generate videos from documents/URLs/files, track video task progress, download results, list voice templates. Use when: user wants to create a video, check task status, download video, or browse voices." metadata: requires: diff --git a/skills/vibeknow-doc/SKILL.md b/skills/vibeknow-doc/SKILL.md index 40ca172..de3b358 100644 --- a/skills/vibeknow-doc/SKILL.md +++ b/skills/vibeknow-doc/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-doc -version: 0.4.1 +version: 0.4.2 description: "Upload documents to vectoria and check processing status. Use when: user wants to upload a document, check if a document is ready, or get a doc_id for use with vibeknow create." metadata: requires: diff --git a/tests/integration/create_mode_test.go b/tests/integration/create_mode_test.go new file mode 100644 index 0000000..545ef63 --- /dev/null +++ b/tests/integration/create_mode_test.go @@ -0,0 +1,141 @@ +package integration + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "sync" + "testing" +) + +func TestCreate_ModeReplica_WiresVideoKind(t *testing.T) { + if testing.Short() { + t.Skip("integration test") + } + + var mu sync.Mutex + bodies := map[string]map[string]any{} + + figlens := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/tasks/init": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + bodies["init"] = body + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "data": map[string]any{"task_id": 42, "session_id": "s_replica", "work_id": 43, "v": 3}, + }) + + case "/v1/agent2forVideo/fastQueryOptimize": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + bodies["optimize"] = body + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + for _, e := range []string{ + `data: {"code":200,"data":{"type":"aim_result","answer_done":{"text":"replica prompt"}}}`, + `data: [DONE]`, + } { + fmt.Fprintln(w, e) + fmt.Fprintln(w) + if flusher != nil { + flusher.Flush() + } + } + + case "/v1/agent3forVideo/stream": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + bodies["stream"] = body + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + events := []string{ + `data: {"code":200,"data":{"type":"process","log":{"step_id":"doc_replica_plan","status":"start","message":"go"}}}`, + `data: {"code":200,"data":{"type":"process","log":{"step_id":"doc_replica_plan","status":"success","message":"ok"}}}`, + `data: {"code":200,"data":{"type":"aim_result","session_id":"s_replica"}}`, + `data: [DONE]`, + } + for _, e := range events { + fmt.Fprintln(w, e) + fmt.Fprintln(w) + if flusher != nil { + flusher.Flush() + } + } + + case "/v1/works/detailBySession": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "data": map[string]any{ + "id": 43, "session_id": "s_replica", + "title": "Replica Test", + "html_path": "works/replica/index.html", + "share_token": "tok_replica", + "exporting": 0, + }, + }) + + default: + w.WriteHeader(404) + } + })) + defer figlens.Close() + + bin := build(t) + configHome := buildVideoProfile(t, figlens.URL) + + cmd := exec.Command(bin, "create", "--mode", "replica", "--from", "doc_abc12345") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(os.Environ(), + "VIBEKNOW_TOKEN=fake-token", + "VIBEKNOW_CONFIG_HOME="+configHome, + ) + + err := cmd.Run() + code := 0 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else if err != nil { + t.Fatalf("run: %v\nstderr: %s", err, stderr.String()) + } + + if code != 0 { + t.Fatalf("create exit %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + + mu.Lock() + defer mu.Unlock() + + if bodies["init"]["video_kind"] != "replica" { + t.Fatalf("init body video_kind = %v, want \"replica\"", bodies["init"]["video_kind"]) + } + if _, ok := bodies["init"]["knowledge_id"]; ok { + t.Fatalf("init body should not carry knowledge_id for non-script mode: %v", bodies["init"]) + } + if _, ok := bodies["init"]["doc_id"]; ok { + t.Fatalf("init body should not carry doc_id for non-script mode: %v", bodies["init"]) + } + if bodies["stream"]["video_kind"] != "replica" { + t.Fatalf("stream body video_kind = %v, want \"replica\"", bodies["stream"]["video_kind"]) + } + out := stdout.String() + stderr.String() + if !strings.Contains(out, "doc_replica_plan") { + t.Fatalf("expected doc_replica_plan in output (proves stage map), got:\nstdout: %s\nstderr: %s", stdout.String(), stderr.String()) + } +}