From 278aaca6f0c335411872c0c81ad31b5afd853d92 Mon Sep 17 00:00:00 2001 From: Scout Field Date: Sat, 10 Oct 2026 19:30:54 -0700 Subject: [PATCH 1/3] fix: align Platform and Growth Studio clients with the official API docs A review of every operation against docs.platform.pixverse.ai and the Growth Studio OpenAPI integration guide found the fusion img_id bug was systemic, plus several other request-shape and recovery mismatches. Platform requests - Serialize every field the API reference types as integer (img_id, img_ids, template_id, source_video_id, video_media_id, audio_media_id, keyframe_id, restyle_id, agent_id, nested reference IDs) as lossless JSON numbers via a per-operation map. ~17 billable operations sent quoted IDs that the API rejects with ErrCode 400013. - Send template_ids as one comma-separated query value and type upload file parts by extension instead of application/octet-stream. Platform validation - sound-effect: drop the nonexistent prompt field; use the documented sound_effect_content and original_sound_switch. - music-mv: accept vibe_mv_v3/vibe_mv_v3_custom, make aspect_ratio and quality optional, fix mv_style/music_style lists, link the public doc. - template prompt may be empty; transition accepts only first_frame_img/last_frame_img; viral-recreation references require uploaded IDs and strip local paths; documented bounds for multi-transition durations, keyframe_id, restyle seed, extend models, avatar TTS length, real-estate pro-only fields, v6 omni video-only fusion. Platform polling and webhooks - Keep polling through transient read errors (transport, 429, 5xx, ErrCode 500069/500044) until the timeout; default interval 5s and timeout 30m; fresh Ai-trace-id per read retry. Billable submissions are still sent exactly once. - Answer verified duplicate webhooks with 200 ok, dedupe on (id, status), accept null values, sort keys by code point, and make the timestamp tolerance configurable (default 6h to cover retries). Growth Studio - Use the documented /openapi/v1/folders endpoints and send video.folder_id instead of a top-level folder_id. - Retry GET requests on 429/503 honoring Retry-After; never retry create, edit, upload, or folder create. - Parse responses losslessly, keep request_id in error.json, keep user folder names unchanged, and validate documented create, edit, list, and upload limits. Co-Authored-By: Claude Opus 5.5 --- .../pixverse-growth-studio-api/SKILL.md | 11 +- .../references/models-pricing-and-limits.md | 2 +- .../references/operation-catalog.md | 4 +- .../references/payload-examples.json | 8 +- .../references/troubleshooting.md | 4 +- .../references/workflows-and-recovery.md | 8 +- .env.example | 3 - README.md | 2 - docs/api/command-reference.md | 25 +- docs/api/safety-and-recovery.md | 18 +- src/core/errors.js | 16 + src/core/http.js | 35 +- src/core/polling.js | 48 +- src/credentials.js | 2 - src/growth-studio/cli.js | 8 +- src/growth-studio/client.js | 147 +++--- src/growth-studio/config.js | 6 +- src/growth-studio/folders.js | 19 +- src/growth-studio/jobs.js | 6 +- src/growth-studio/validation.js | 184 +++++++- src/platform/client.js | 17 +- src/platform/envelope.js | 14 +- src/platform/jobs.js | 37 +- src/platform/operations.js | 2 +- src/platform/request.js | 114 ++++- src/platform/validators/music-mv.js | 24 +- src/platform/validators/specialized.js | 60 ++- src/platform/validators/video.js | 54 ++- src/platform/webhooks.js | 202 +++++++-- test/catalog-completeness.test.js | 8 +- test/client.test.js | 429 +++++++++++++++--- .../growth-studio-client.contract.test.js | 100 +++- .../contract/platform-client.contract.test.js | 38 +- test/e2e/growth-studio-namespaced.e2e.test.js | 26 +- test/e2e/platform-recovery.e2e.test.js | 53 ++- test/e2e/platform-webhook.e2e.test.js | 26 +- .../platform/agent.real-estate/request.json | 2 +- .../agent.viral-recreation/request.json | 10 +- .../platform/image.template/request.json | 2 +- .../official-operation-inventory.json | 2 +- .../platform/video.avatar/request.json | 4 +- .../platform/video.extend/request.json | 2 +- .../platform/video.image/request.json | 2 +- .../platform/video.lip-sync/request.json | 4 +- .../platform/video.modify/request.json | 2 +- .../video.motion-control/request.json | 4 +- .../video.multi-transition/request.json | 4 +- .../platform/video.restyle/request.json | 4 +- .../platform/video.sound-effect/request.json | 6 +- .../platform/video.swap-mask/request.json | 4 +- .../fixtures/platform/video.swap/request.json | 6 +- .../platform/video.template/request.json | 4 +- .../platform/video.transition/request.json | 4 +- .../platform/voice.create/request.json | 2 +- test/folders.test.js | 69 ++- test/helpers/cli-process.js | 3 +- test/helpers/recording-fetch.js | 20 +- test/integration/cli-routing.test.js | 4 +- test/integration/growth-studio-cli.test.js | 9 +- test/jobs.test.js | 69 ++- test/platform-skill.test.js | 10 +- test/unit/core-errors.test.js | 17 + test/unit/core-http.test.js | 67 +++ test/unit/core-polling.test.js | 92 ++++ test/unit/growth-studio-compatibility.test.js | 55 ++- .../growth-studio-payload-boundary.test.js | 36 +- test/unit/platform-client-retry-trace.test.js | 56 +++ test/unit/platform-envelope.test.js | 9 + test/unit/platform-jobs.test.js | 104 ++++- test/unit/platform-operations.test.js | 8 +- test/unit/platform-validation.test.js | 102 ++++- test/unit/platform-webhooks.test.js | 181 +++++++- 72 files changed, 2325 insertions(+), 414 deletions(-) create mode 100644 test/unit/platform-client-retry-trace.test.js diff --git a/.agents/skills/pixverse-growth-studio-api/SKILL.md b/.agents/skills/pixverse-growth-studio-api/SKILL.md index a6992bb..ac4e802 100644 --- a/.agents/skills/pixverse-growth-studio-api/SKILL.md +++ b/.agents/skills/pixverse-growth-studio-api/SKILL.md @@ -26,12 +26,7 @@ npm run cli -- growth-studio --help Growth Studio uses `PIXVERSE_GROWTH_API_KEY` and bearer authentication. It must not read `PIXVERSE_PLATFORM_API_KEY` or use Platform credits. Keep keys in `.env` or the server environment, never in a payload, command argument, artifact, or browser. -Optional folder configuration for the existing URL-based workflow: - -```bash -PIXVERSE_GROWTH_FOLDER_API_PREFIX=/marketing_hub -PIXVERSE_GROWTH_FOLDER_API_KEY=... -``` +Folders use the documented `GET`/`POST /openapi/v1/folders` endpoints with the same `PIXVERSE_GROWTH_API_KEY`; no separate folder credential exists. A folder is targeted with the string `video.folder_id` inside the create payload, never a top-level `folder_id`. A non-loopback custom origin must be HTTPS, explicitly enabled with `PIXVERSE_GROWTH_ALLOW_CUSTOM_BASE_URL=true`, and controlled by the operator. @@ -84,8 +79,8 @@ For a local image, upload it first and place only the returned `https://media.pi Folder targeting applies only to the existing general workflow: - `--folder-id ` uses a verified folder. -- `--folder-name ` reuses a case-insensitive match or creates the folder. -- `--auto-folder` infers a folder from supported general-payload fields. +- `--folder-name ` reuses an exact (trimmed) name match or creates the folder with that name unchanged. +- `--auto-folder` infers a normalized folder name from supported general-payload fields and reuses a case-insensitive match. Prefer `growth-studio run-job` over direct general-video creation when a durable artifact trail is needed. General-video creation has no dry-run mode. diff --git a/.agents/skills/pixverse-platform-api/references/models-pricing-and-limits.md b/.agents/skills/pixverse-platform-api/references/models-pricing-and-limits.md index f850462..dec8131 100644 --- a/.agents/skills/pixverse-platform-api/references/models-pricing-and-limits.md +++ b/.agents/skills/pixverse-platform-api/references/models-pricing-and-limits.md @@ -49,4 +49,4 @@ Use `resource.templates`, `resource.tts-speakers`, and `resource.restyle-effects - [Rate limits](https://docs.platform.pixverse.ai/rate-limit-796040m0) - [Image upload](https://docs.platform.pixverse.ai/upload-image-13016631e0) - [Video and audio upload](https://docs.platform.pixverse.ai/upload-videoaudio-19094401e0) -- [One-Click Music MV interface](https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc) +- [One-click VibeMV](https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0) diff --git a/.agents/skills/pixverse-platform-api/references/operation-catalog.md b/.agents/skills/pixverse-platform-api/references/operation-catalog.md index f18b0ec..13cadb5 100644 --- a/.agents/skills/pixverse-platform-api/references/operation-catalog.md +++ b/.agents/skills/pixverse-platform-api/references/operation-catalog.md @@ -26,7 +26,7 @@ Every documented Platform API endpoint currently covered by `pixverse-api platfo | operation:video.restyle | `platform video restyle` | POST | `/openapi/v2/video/restyle/generate` | billable | yes | `Resp.video_id` | Source video plus live `restyle_id` | https://docs.platform.pixverse.ai/restyle-video-generation-21992681e0 | | operation:video.swap-mask | `platform video swap-mask` | POST | `/openapi/v2/video/mask/selection` | billable | no | none | Source video plus safe-integer-string `keyframe_id` | https://docs.platform.pixverse.ai/swap-mask-generation-24001877e0 | | operation:video.swap | `platform video swap` | POST | `/openapi/v2/video/swap/generate` | billable | yes | `Resp.video_id` | Source video, keyframe/mask from mask selection, replacement `img_id`, and quality | https://docs.platform.pixverse.ai/swap-video-generation-24001839e0 | -| operation:video.sound-effect | `platform video sound-effect` | POST | `/openapi/v2/video/sound_effect/generate` | billable | yes | `Resp.video_id` | Source video plus sound description | https://docs.platform.pixverse.ai/sound-effect-generation-19884196e0 | +| operation:video.sound-effect | `platform video sound-effect` | POST | `/openapi/v2/video/sound_effect/generate` | billable | yes | `Resp.video_id` | Source video plus optional `sound_effect_content` description | https://docs.platform.pixverse.ai/sound-effect-generation-19884196e0 | | operation:video.extend | `platform video extend` | POST | `/openapi/v2/video/extend/generate` | billable | yes | `Resp.video_id` | Source video plus continuation prompt and compatible V6 fields | https://docs.platform.pixverse.ai/extend-generation-19094393e0 | | operation:video.motion-control | `platform video motion-control` | POST | `/openapi/v2/video/mimic/generate` | billable | yes | `Resp.video_id` | Motion source video plus subject `img_id` and quality | https://docs.platform.pixverse.ai/motion-control-mimic-generation-28748523e0 | | operation:video.modify | `platform video modify` | POST | `/openapi/v2/video/modify/generate` | billable | yes | `Resp.video_id` | Source video plus edit prompt; optional masks/references | https://docs.platform.pixverse.ai/modify-generation-33365578e0 | @@ -34,5 +34,5 @@ Every documented Platform API endpoint currently covered by `pixverse-api platfo | operation:video.avatar | `platform video avatar` | POST | `/openapi/v2/video/avatar/generate` | billable | yes | `Resp.video_id` | Portrait `img_id` plus quality and exactly one uploaded-audio or TTS source | https://docs.platform.pixverse.ai/avatar-generation-40528034e0 | | operation:agent.viral-recreation | `platform agent viral-recreation` | POST | `/openapi/v2/video/agent/generate` | billable | yes | `Resp.video_id` | Fixed agent ID, prompt, image references, and exactly one video reference | https://docs.platform.pixverse.ai/viral-recreation-agent-41205382e0 | | operation:agent.real-estate | `platform agent real-estate` | POST | `/openapi/v2/video/agent/generate` | billable | yes | `Resp.video_id` | Fixed agent ID/model plus listing URL or supported image set | https://docs.platform.pixverse.ai/one-click-real-estate-video-42843647e0 | -| operation:agent.music-mv | `platform agent music-mv` | POST | `/openapi/v2/video/music_mv_agent/generate` | billable | yes | `Resp.video_id` | Verified audio plus optional character/style image references | https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc | +| operation:agent.music-mv | `platform agent music-mv` | POST | `/openapi/v2/video/music_mv_agent/generate` | billable | yes | `Resp.video_id` | Verified audio plus optional character/style image references | https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0 | | operation:video.status | `platform video status` | GET | `/openapi/v2/video/result/{video_id}` | read-only | no | `Resp.id` | Known `video_id` string; never resubmit generation to poll | https://docs.platform.pixverse.ai/get-video-generation-status-13016632e0 | diff --git a/.agents/skills/pixverse-platform-api/references/payload-examples.json b/.agents/skills/pixverse-platform-api/references/payload-examples.json index fa97326..1d2ab53 100644 --- a/.agents/skills/pixverse-platform-api/references/payload-examples.json +++ b/.agents/skills/pixverse-platform-api/references/payload-examples.json @@ -200,7 +200,7 @@ "description": "Generate synchronized sound for an existing video.", "input": { "source_video_id": "629000000000000005", - "prompt": "Gentle rain and distant thunder." + "sound_effect_content": "Gentle rain and distant thunder." } }, "video.extend": { @@ -258,7 +258,7 @@ "img_id": "629000000000000001", "quality": "720p", "lip_sync_tts_speaker_id": "speaker-7", - "lip_sync_tts_content": "Here is the latest update." + "lip_sync_tts_content": "Here is the latest product update for this week." } } ] @@ -271,12 +271,12 @@ "quality": "720p", "img_references": [ { - "img_url": "https://cdn.example.test/reference.png" + "img_id": "629000000000000002" } ], "video_references": [ { - "video_url": "https://cdn.example.test/reference.mp4" + "video_media_id": "629000000000000005" } ] } diff --git a/.agents/skills/pixverse-platform-api/references/troubleshooting.md b/.agents/skills/pixverse-platform-api/references/troubleshooting.md index f2f53bb..5fb8de2 100644 --- a/.agents/skills/pixverse-platform-api/references/troubleshooting.md +++ b/.agents/skills/pixverse-platform-api/references/troubleshooting.md @@ -9,7 +9,9 @@ HTTP success is not Platform success. Require a valid response envelope and `Err | Provider parameter rejection | Nonzero parameter-related `ErrCode` | Compare the specialized example, local validator, and current endpoint page; change only the rejected input. | | Moderation | Status 7 or moderation `ErrCode` | Treat as terminal. Report the provider reason; revise content only if the user asks. Do not hide or bypass moderation. | | Rate/concurrency pressure | Rate/concurrency `ErrCode`, HTTP 429, or retry guidance | For read-only calls, wait as directed and retry safely. For a confirmed-not-accepted submission, follow provider guidance; if acceptance is ambiguous, reconcile instead of resubmitting. | -| Polling issue | Transient failure while a result ID is known | Preserve the ID and job directory; resume polling without recreating the generation. | +| Polling issue | Transient failure while a result ID is known | Status polling already waits through transport errors, HTTP 429/5xx, and `500069`/`500044` until its timeout (see `poll_error` records in `polling.jsonl`). After a timeout, preserve the ID and job directory and resume polling without recreating the generation. | +| High load (`500069`) | Provider reports the system is under high load | Retryable for read-only calls; status polling continues automatically. A submission rejected with it was not accepted; obtain fresh approval before submitting again, and never auto-resubmit. | +| Webhook duplicates | PixVerse redelivers until it receives `ok` | The handler acknowledges verified duplicates (same nonce, or same `id` and `status`) with `ok` and does not call the delivery callback twice. A `409` means the first attempt is still in progress. | | Ambiguous billable submission | Timeout/connection loss after dispatch and no saved result ID | Do not retry or resubmit. Keep `request.json` and trace ID, inspect usage/provider records, and report `reconciliation_required`. | | Terminal generation failure | Status 6, 7, or 8 | Preserve final status, trace, provider envelope, and failure details; do not automatically create a replacement job. | | Missing result ID | Successful-looking create envelope lacks the cataloged ID path | Preserve create response and trace; stop as a response-contract error and reconcile. | diff --git a/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md b/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md index a966d7f..b0ac418 100644 --- a/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md +++ b/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md @@ -38,14 +38,14 @@ Platform billable commands wait for a terminal result by default. To return imme `image_references` is accepted as a documented input alias and normalized to `img_references`. Do not send both fields. Lip sync requires recognizable vocals; audio verification does not authorize an automatic paid submission. -Keep Music MV identifiers as decimal strings in payload files and artifacts. The request layer losslessly serializes the documented `uint64` fields (`audio_media_id` and reference `img_id` values) as JSON number tokens because the live verification and generation endpoints reject quoted identifiers. +Keep identifiers as decimal strings in payload files and artifacts. The request layer losslessly serializes every field an operation's API reference types as integer (for example `img_id`, `img_ids`, `template_id`, `source_video_id`, `video_media_id`, `audio_media_id`, `keyframe_id`, `restyle_id`, `agent_id`, and reference `img_id` values) as JSON number tokens, because the endpoints reject quoted identifiers with ErrCode 400013. Fields the reference types as string (`mask_id`, `mask_ids`, `lip_sync_tts_speaker_id`, `webhook_id`) stay quoted. ### Durable artifact meanings - `request.json` is written before submission and records the provider, operation, redacted headers, original submission trace, and normalized input. - `create-response.json` preserves the redacted create response. - `video-id.json` or `image-id.json` preserves the known result ID as a string. -- `polling.jsonl` appends each redacted status snapshot and status-request trace. +- `polling.jsonl` appends each redacted status snapshot and status-request trace, plus a `poll_error` record for each transient status-read failure. - `final.json` preserves the original submission `trace_id`; `status_trace_id` identifies the latest status request. - `error.json` preserves safe failure classification and trace context without exposing secrets. @@ -67,10 +67,14 @@ Keep Music MV identifiers as decimal strings in payload files and artifacts. The Poll a known ID at the documented cadence and honor provider retry guidance. Preserve the original submission trace separately from status-request traces. +The default cadence is 5 seconds with a 30 minute wait per session; a timeout is resumable. Transient status-read failures (transport errors, HTTP 429/5xx, `ErrCode` `500069` high load or `500044` concurrency limit) do not fail the job: polling backs off, honors `Retry-After`, and continues until the overall timeout. Other read errors fail immediately. Every read-only retry uses a fresh `Ai-trace-id`. The billable submission is never retried automatically. + ## Webhooks Use the [official webhook page](https://docs.platform.pixverse.ai/how-to-use-webhook-1905378m0) to confirm current supported operations and delivery behavior. Verify timestamp, nonce, Base64 HMAC-SHA256 signature, time window, and nonce replay state before parsing JSON or invoking delivery logic. Return plain `ok` only after verified successful handling; use HTTPS and idempotent delivery processing. +PixVerse resends until it receives `ok` (8 retries over about 5 hours). The built-in handler therefore answers a verified duplicate (same nonce, or same payload `id` and `status` with a fresh nonce) with `200 ok` without invoking the delivery callback again, and answers `409` only while the first attempt is still in progress. `null` payload values are accepted (signed as `null`). The timestamp window defaults to 6 hours (`timestampToleranceSeconds`) so PixVerse's own late retries are not rejected if they reuse the original timestamp; nonce and `(id, status)` deduplication still block replays. + ## Raw diagnostics Use `platform raw` only for a relative `/openapi/v2/` path when diagnosing a documented endpoint or inspecting a newly published endpoint not yet in the catalog. Start with `--dry-run`. Never pass API keys, cookies, host, content length, authorization, or trace headers; the CLI owns those headers and assigns a fresh trace. diff --git a/.env.example b/.env.example index 0e5c53c..39b8a8c 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,3 @@ PIXVERSE_GROWTH_API_KEY=mh_live_REPLACE_WITH_PRODUCTION_API_KEY PIXVERSE_GROWTH_BASE_URL=https://growth-api.pixverse.ai # Required only for a non-official, non-loopback HTTPS origin: # PIXVERSE_GROWTH_ALLOW_CUSTOM_BASE_URL=true -# Optional, only if Growth Studio folder list/create uses a different API prefix/token: -# PIXVERSE_GROWTH_FOLDER_API_PREFIX=/marketing_hub -# PIXVERSE_GROWTH_FOLDER_API_KEY=REPLACE_WITH_FOLDER_MANAGEMENT_TOKEN diff --git a/README.md b/README.md index daee694..0f416ac 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,6 @@ PIXVERSE_PLATFORM_BASE_URL=https://app-api.pixverse.ai PIXVERSE_GROWTH_API_KEY=mh_live_... PIXVERSE_GROWTH_BASE_URL=https://growth-api.pixverse.ai -PIXVERSE_GROWTH_FOLDER_API_PREFIX=/marketing_hub -PIXVERSE_GROWTH_FOLDER_API_KEY=... ``` Keys stay server-side. Platform and Growth Studio credentials are separate and never fall back to each other. diff --git a/docs/api/command-reference.md b/docs/api/command-reference.md index 46f34b5..6e9ce11 100644 --- a/docs/api/command-reference.md +++ b/docs/api/command-reference.md @@ -129,7 +129,11 @@ pixverse-api growth-studio folders ensure "Campaign Alpha" pixverse-api growth-studio upload image /absolute/path/product.webp ``` -`folders ensure` accepts either the positional name shown above or `--folder-name `. +`folders ensure` accepts either the positional name shown above or `--folder-name `. The name is trimmed and otherwise sent unchanged; an existing folder is reused only on an exact (trimmed) name match. + +Folders use the documented `GET /openapi/v1/folders` and `POST /openapi/v1/folders` endpoints with the same `PIXVERSE_GROWTH_API_KEY` bearer credential as every other Growth Studio call. A resolved folder is sent as the string `video.folder_id` in the create payload. + +`upload image` accepts `.jpg`, `.jpeg`, `.png`, or `.webp` files of at most 20 MiB and rejects other files before any network request. ### PDP videos @@ -187,8 +191,12 @@ pixverse-api growth-studio video edit Folder options for creation are mutually exclusive targeting choices: - `--folder-id ` uses a verified existing folder. -- `--folder-name ` reuses a case-insensitive match or creates the folder. -- `--auto-folder` infers a name from supported payload metadata or the merchant URL. +- `--folder-name ` reuses an exact (trimmed) name match or creates the folder with that name unchanged. +- `--auto-folder` infers a normalized name from supported payload metadata or the merchant URL and reuses a case-insensitive match. + +A payload that already contains `video.folder_id` keeps that folder; no folder is listed or created. `video list --limit` must be an integer from 1 through 100 and `--status` one of `queued`, `processing`, `succeeded`, `failed`, or `canceled`. `video edit` requires a `clip_index` from 1 through 40 and a non-empty instruction of at most 500 characters. + +Read-only requests (`avatars list`, `folders list`, `video get`/`status`/`list`, and polling) retry on HTTP 429 and retryable 5xx responses, honoring `Retry-After` (seconds or HTTP date) and otherwise using capped exponential backoff. Video create, video edit, image upload, and folder create are never retried automatically. The existing general `create-from-url`, `create-from-json`, and `video edit` commands have no dry-run mode. They can create billable asynchronous work and do not provide the full durable job workflow. Prefer `run-job` for new general video generation. PDP is a separate workflow and does provide `pdp create --dry-run`. @@ -211,21 +219,12 @@ pixverse-api growth-studio resume /absolute/path/jobs/ \ [--fallback-delay-seconds 5] ``` -Timing values must be finite: `--timeout-minutes` must be greater than zero, while both delay values may be zero or greater. By default, `run-job` polls to a terminal state. `--no-poll` returns after recording the create response and string `video_id`. +Timing values must be finite: `--timeout-minutes` must be greater than zero. Polling never waits less than five seconds before the first status read or less than one second between later reads; smaller delay values are raised to those minimums. By default, `run-job` polls to a terminal state. `--no-poll` returns after recording the create response and string `video_id`. `resume` reads `video-id.json` and continues polling only. It never calls the create endpoint. With no saved video ID it reports `reconciliation_required`, preserving the evidence needed to investigate an ambiguous submission. Growth Studio jobs use `jobs/` by default and can contain `request.json`, `folder.json`, `create-response.json`, `video-id.json`, `polling.jsonl`, `final.json`, and `error.json`. PDP jobs omit folder resolution and can additionally contain `ledger-source-id.json`. A resumed nonterminal `final.json` is preserved as `final-prior-N.json`. JSON artifacts are private, atomic, and recursively redacted. -### Growth Studio folder configuration - -If folder management uses a different API prefix or token, configure either or both values without changing the video-generation credential: - -```bash -PIXVERSE_GROWTH_FOLDER_API_PREFIX=/marketing_hub -PIXVERSE_GROWTH_FOLDER_API_KEY=... -``` - ### Legacy aliases Legacy Growth Studio aliases remain available during `0.x` and emit one deprecation warning: diff --git a/docs/api/safety-and-recovery.md b/docs/api/safety-and-recovery.md index e7ff790..3ae3dd7 100644 --- a/docs/api/safety-and-recovery.md +++ b/docs/api/safety-and-recovery.md @@ -33,6 +33,12 @@ pixverse-api growth-studio resume /absolute/path/jobs/ pixverse-api growth-studio pdp resume /absolute/path/jobs/ ``` +### Polling defaults and transient read errors + +Platform status polling defaults to a 5 second interval (the status page asks for no less than 5 seconds) and a 30 minute wait per session (`--interval-ms` / `--timeout-ms` override both). Generation can run longer than that; a timeout is not a failure and the job resumes from its directory. + +Status reads are read-only, so a transient read failure does not abandon the job. Transport errors, HTTP 429 and 5xx, and the retryable `ErrCode`s `500069` (high load) and `500044` (concurrency limit) keep the job polling with exponential backoff (starting at the interval, capped at 60 seconds), honoring `Retry-After` when present, until the overall timeout. Each such failure is appended to `polling.jsonl` as a `poll_error` record; a timeout reached during failures records the last error in the timeout details. Any other error (for example an invalid ID) still fails immediately. Each read-only HTTP retry sends a fresh `Ai-trace-id`, because PixVerse can return a cached earlier result for a reused trace. None of this applies to the billable submission: it is sent exactly once, never retried automatically, and keeps its saved trace for recovery. + `resume` is poll-only. It reads the saved result ID and continues status checks; it never calls a create endpoint. If the submission response was ambiguous and no result ID was saved, it returns `reconciliation_required` instead of risking a duplicate charged generation. Inspect the saved request, error, recent remote jobs, and any provider request ID before deciding whether to submit again. ## Growth Studio PDP @@ -53,10 +59,18 @@ See [Growth Studio PDP Videos](growth-studio-pdp.md) for its seller-neutral fash ## Webhooks -Platform webhook verification first parses the raw body as a JSON object whose values must be scalar. It converts those key/value pairs to a URL-encoded query string with `URLSearchParams`, and verifies either insertion order or key-sorted order for compatibility. The signed string is: +Platform webhook verification first parses the raw body as a JSON object whose values must be scalar or `null`. It converts those key/value pairs to a URL-encoded query string with `URLSearchParams`, and verifies either insertion order or key-sorted order (code-point/byte order, matching the official Go and Java examples) for compatibility. The signed string is: ```text ${timestamp}\n${nonce}\n${queryString} ``` -For example, `{"video_id":"123","status":1}` becomes `video_id=123&status=1`. Nested objects, arrays, and `null` are not supported by this signature format. The signature is Base64 HMAC-SHA256. The handler validates authentication and reserves the nonce before delivery, rejects replays, and returns exact body `ok` only after the delivery callback succeeds and the nonce reservation is committed. +For example, `{"video_id":"123","status":1}` becomes `video_id=123&status=1`. A `null` value (such as `"url": null` on a failed delivery) is accepted and signed as `null`, which is what `String(null)` in the official Node example produces; the Go (``) and Python (`None`) spellings are also accepted. Nested objects and arrays are not supported by this signature format and are rejected. The signature is Base64 HMAC-SHA256. + +The handler validates authentication, reserves the nonce, and returns exact body `ok` only after the delivery callback succeeds and the reservation is committed. PixVerse resends any delivery that does not receive `ok` (8 retries over about 5 hours), so duplicates are acknowledged rather than rejected: + +- A verified replay of an already-processed nonce returns `200 ok` without calling the delivery callback again. +- Deliveries are also deduplicated by payload `(id, status)`, as the official docs recommend, so a redelivery with a fresh nonce is acknowledged with `ok` once the first one succeeded. A later status for the same `id` is a new event. +- A duplicate that arrives while the first attempt is still being processed gets `409`, so PixVerse retries it later; if the first attempt fails, its reservation is released and the retry is delivered. + +`timestampToleranceSeconds` (an option of `createPlatformWebhookHandler` and `verifyPlatformWebhook`, 1 second to 7 days) bounds how old a `Webhook-Timestamp` may be. The default is 6 hours: the docs do not say whether retries are re-signed, and a short window could reject PixVerse's own late retries if they keep the original timestamp. Replays inside the window are still blocked by the nonce and `(id, status)` deduplication, which are held for the whole window. Timestamps more than 300 seconds in the future are always rejected. A custom store's `reserve(key, expiresAt, nowSeconds)` must return a falsy value only for a key that is already committed, and throw (for example an error with `statusCode: 409`) for one that is still reserved. For a multi-instance receiver, use a shared durable store. diff --git a/src/core/errors.js b/src/core/errors.js index 28b3964..b859084 100644 --- a/src/core/errors.js +++ b/src/core/errors.js @@ -40,3 +40,19 @@ export function serializeError(error) { return redact(serialized); } + +// Classifies a failed *read-only* request (for example a status poll) as +// transient: worth waiting and reading again. Never use this to decide whether +// to resend a billable submission; those are never retried automatically. +export function isTransientReadError(error) { + if (!error || typeof error !== "object") return false; + if (isAbortError(error) || isAbortError(error.cause)) return false; + if (error.category === "transport") return true; + const status = Number(error.status); + if (status === 429 || (Number.isInteger(status) && status >= 500 && status <= 599)) return true; + return error.category === "provider" && error.retryable === true; +} + +function isAbortError(error) { + return error?.name === "AbortError" || error?.name === "TimeoutError"; +} diff --git a/src/core/http.js b/src/core/http.js index 0ce2edc..2d2f0e4 100644 --- a/src/core/http.js +++ b/src/core/http.js @@ -21,17 +21,32 @@ export async function requestHttp(options) { now = Date.now, provider, operation, - traceId, + traceId: initialTraceId, + traceHeader = "Ai-trace-id", + nextTraceId, signal, } = options; const normalizedMethod = method.toUpperCase(); const maxAttempts = normalizeMaxAttempts(retry?.maxAttempts); const canRetry = RETRYABLE_METHODS.has(normalizedMethod) && maxAttempts > 1; + if (nextTraceId !== undefined && typeof nextTraceId !== "function") { + throw new TypeError("nextTraceId must be a function."); + } + let traceId = initialTraceId; + let attemptHeaders = headers; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + if (attempt > 1 && nextTraceId) { + // PixVerse treats a repeated Ai-trace-id as the same request and can + // return the cached previous result, so each read-only retry gets a + // fresh trace. Billable submissions never reach this path: they are + // single-attempt and deliberately keep their persisted recovery trace. + traceId = nextTraceId(); + attemptHeaders = withHeader(headers, traceHeader, traceId); + } let response; try { - response = await fetchImpl(url, { method: normalizedMethod, headers, body, signal }); + response = await fetchImpl(url, { method: normalizedMethod, headers: attemptHeaders, body, signal }); } catch (cause) { const aborted = signal?.aborted === true || cause?.name === "AbortError"; const error = new PixverseCliError("PixVerse API transport request failed.", { @@ -67,6 +82,7 @@ export async function requestHttp(options) { headers: response.headers, status: response.status, retryAfter, + traceId, }; } @@ -143,6 +159,21 @@ function httpError(response, body, context) { }); } +function withHeader(headers, name, value) { + if (headers instanceof Headers) { + const copy = new Headers(headers); + copy.set(name, value); + return copy; + } + const copy = {}; + const lowerName = name.toLowerCase(); + for (const [key, entry] of Object.entries(headers ?? {})) { + if (key.toLowerCase() !== lowerName) copy[key] = entry; + } + copy[name] = value; + return copy; +} + function normalizeMaxAttempts(value) { if (value === undefined) return 1; if (!Number.isSafeInteger(value) || value < 1) { diff --git a/src/core/polling.js b/src/core/polling.js index 0776f9d..eb81657 100644 --- a/src/core/polling.js +++ b/src/core/polling.js @@ -1,4 +1,4 @@ -import { PixverseCliError } from "./errors.js"; +import { PixverseCliError, serializeError } from "./errors.js"; export async function pollUntilTerminal(options) { const { @@ -12,14 +12,42 @@ export async function pollUntilTerminal(options) { provider, operation, traceId, + // Opt-in: when provided and it returns true for a poll() failure, the loop + // keeps polling (with backoff) until timeoutMs instead of failing. Errors + // it rejects are rethrown immediately. + isRetryableError, + onPollError = async () => {}, + maxBackoffMs = Math.max(intervalMs, 60_000), } = options; - validateOptions({ poll, isTerminal, intervalMs, timeoutMs }); + validateOptions({ poll, isTerminal, intervalMs, timeoutMs, isRetryableError }); const startedAt = now(); let lastSnapshot; + let lastError; + let consecutiveErrors = 0; while (true) { - lastSnapshot = await poll(); + try { + lastSnapshot = await poll(); + consecutiveErrors = 0; + lastError = undefined; + } catch (error) { + if (!isRetryableError || !(await isRetryableError(error))) throw error; + consecutiveErrors += 1; + lastError = error; + await onPollError(error, { consecutiveErrors }); + const backoffMs = Math.min(maxBackoffMs, intervalMs * (2 ** (consecutiveErrors - 1))); + const retryAfterMs = Number.isFinite(error?.retryAfter) ? error.retryAfter * 1_000 : 0; + const delayMs = Math.max(backoffMs, retryAfterMs); + const remaining = timeoutMs - (now() - startedAt); + // Never poll again earlier than the provider asked; if its Retry-After + // outlasts the deadline, stop now with a resumable timeout. + if (remaining <= 0 || retryAfterMs > remaining) { + throw timeoutError({ provider, operation, traceId, timeoutMs, lastSnapshot, lastError }); + } + await sleep(Math.min(delayMs, remaining)); + continue; + } await onSnapshot(lastSnapshot); if (await isTerminal(lastSnapshot)) return lastSnapshot; @@ -32,7 +60,7 @@ export async function pollUntilTerminal(options) { } } -function validateOptions({ poll, isTerminal, intervalMs, timeoutMs }) { +function validateOptions({ poll, isTerminal, intervalMs, timeoutMs, isRetryableError }) { if (typeof poll !== "function") throw new TypeError("poll must be a function."); if (typeof isTerminal !== "function") throw new TypeError("isTerminal must be a function."); if (!Number.isFinite(intervalMs) || intervalMs <= 0) { @@ -41,9 +69,12 @@ function validateOptions({ poll, isTerminal, intervalMs, timeoutMs }) { if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { throw new TypeError("timeoutMs must be a non-negative number."); } + if (isRetryableError !== undefined && typeof isRetryableError !== "function") { + throw new TypeError("isRetryableError must be a function."); + } } -function timeoutError({ provider, operation, traceId, timeoutMs, lastSnapshot }) { +function timeoutError({ provider, operation, traceId, timeoutMs, lastSnapshot, lastError }) { return new PixverseCliError(`PixVerse polling timed out after ${timeoutMs}ms.`, { category: "timeout", provider, @@ -53,10 +84,17 @@ function timeoutError({ provider, operation, traceId, timeoutMs, lastSnapshot }) details: { timeout_ms: timeoutMs, last_snapshot: lastSnapshot, + ...(lastError === undefined ? {} : { last_error: summarizeError(lastError) }), }, }); } +function summarizeError(error) { + const summary = serializeError(error); + delete summary.details; + return summary; +} + function defaultSleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } diff --git a/src/credentials.js b/src/credentials.js index e5c1176..96144b0 100644 --- a/src/credentials.js +++ b/src/credentials.js @@ -8,8 +8,6 @@ export const USER_CREDENTIAL_KEYS = Object.freeze([ "PIXVERSE_PLATFORM_ALLOW_CUSTOM_BASE_URL", "PIXVERSE_GROWTH_API_KEY", "PIXVERSE_GROWTH_BASE_URL", - "PIXVERSE_GROWTH_FOLDER_API_KEY", - "PIXVERSE_GROWTH_FOLDER_API_PREFIX", "PIXVERSE_GROWTH_ALLOW_CUSTOM_BASE_URL", ]); diff --git a/src/growth-studio/cli.js b/src/growth-studio/cli.js index 6f14c82..31a342b 100644 --- a/src/growth-studio/cli.js +++ b/src/growth-studio/cli.js @@ -291,7 +291,9 @@ function parseListOptions(args) { const options = {}; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; - if (arg === "--limit") options.limit = readOptionValue(args, ++index, arg); + if (arg === "--limit") { + options.limit = readSafeIntegerOption(args, ++index, arg, { minimum: 1, maximum: 100 }); + } else if (arg === "--status") options.status = readOptionValue(args, ++index, arg); else if (arg === "--cursor") options.cursor = readOptionValue(args, ++index, arg); else throw new Error(`Unknown video list option: ${arg}`); @@ -546,8 +548,8 @@ function readFiniteOption(args, index, optionName, constraints = {}) { } function withFolderId(payload, folderId) { - if (!folderId) return payload; - return { ...payload, folder_id: folderId }; + if (!folderId || payload?.video?.folder_id === folderId) return payload; + return { ...payload, video: { ...payload.video, folder_id: folderId } }; } function resolveContextPath(context, value) { diff --git a/src/growth-studio/client.js b/src/growth-studio/client.js index ac2d37c..c554cd7 100644 --- a/src/growth-studio/client.js +++ b/src/growth-studio/client.js @@ -1,10 +1,12 @@ import fs from "node:fs"; import path from "node:path"; +import JSONBigFactory from "json-bigint"; import { assertVideoId, validateCreatePayload, validateEditPayload, validateFolderPayload, + validateListVideosOptions, } from "./validation.js"; import { PDP_CREATE_ROUTE, normalizePdpPayload } from "./pdp.js"; import { normalizeWalletLedgerOptions } from "./wallet.js"; @@ -13,6 +15,7 @@ export { validateCreatePayload, validateEditPayload, validateFolderPayload, + validateListVideosOptions, } from "./validation.js"; export { PDP_CREATE_PATH, @@ -24,8 +27,24 @@ export { export { normalizeWalletLedgerOptions } from "./wallet.js"; const API_PREFIX = "/openapi/v1"; -const DEFAULT_FOLDER_API_PREFIX = "/marketing_hub"; const TERMINAL_STATUSES = new Set(["succeeded", "failed", "canceled"]); +const DEFAULT_RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); +const JSON_BIG = JSONBigFactory({ + storeAsString: true, + protoAction: "error", + constructorAction: "error", +}); +const DEFAULT_READ_RETRY = Object.freeze({ maxAttempts: 4, baseDelayMs: 1_000, maxDelayMs: 30_000 }); + +export const MIN_INITIAL_POLL_DELAY_SECONDS = 5; +export const MIN_FALLBACK_POLL_DELAY_SECONDS = 1; +export const MAX_UPLOAD_IMAGE_BYTES = 20 * 1024 * 1024; +const UPLOAD_IMAGE_TYPES = Object.freeze({ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +}); export class GrowthStudioApiError extends Error { constructor(message, options = {}) { @@ -72,7 +91,7 @@ export async function parseResponse(response) { let body = {}; if (text) { try { - body = JSON.parse(text); + body = toPlainJson(JSON_BIG.parse(text)); } catch { if (response.ok) { throw new GrowthStudioApiError("PixVerse API returned invalid JSON in a successful response.", { @@ -87,12 +106,14 @@ export async function parseResponse(response) { } if (!response.ok) { - const error = body.error || {}; + const error = isRecord(body?.error) ? body.error : {}; throw new GrowthStudioApiError(error.message || `PixVerse API request failed with HTTP ${response.status}.`, { status: response.status, code: error.code, - retryable: error.retryable, - requestId: body.request_id || requestId, + retryable: typeof error.retryable === "boolean" + ? error.retryable + : DEFAULT_RETRYABLE_STATUSES.has(response.status), + requestId: body?.request_id || requestId, retryAfter, details: error.details, }); @@ -101,7 +122,7 @@ export async function parseResponse(response) { return { body, headers: response.headers, - requestId: body.request_id || requestId, + requestId: body?.request_id || requestId, retryAfter, }; } @@ -109,19 +130,19 @@ export async function parseResponse(response) { export class GrowthStudioClient { constructor({ apiKey, - folderApiKey, - folderApiPrefix = DEFAULT_FOLDER_API_PREFIX, baseUrl, fetchImpl = globalThis.fetch, sleep = defaultSleep, + now = Date.now, + readRetry = DEFAULT_READ_RETRY, }) { if (!fetchImpl) throw new Error("A fetch implementation is required."); this.apiKey = apiKey; - this.folderApiKey = folderApiKey || apiKey; - this.folderApiPrefix = normalizeApiPrefix(folderApiPrefix); this.baseUrl = baseUrl; this.fetch = fetchImpl; this.sleep = sleep; + this.now = now; + this.readRetry = { ...DEFAULT_READ_RETRY, ...readRetry }; } async listAvatars(options = {}) { @@ -129,7 +150,14 @@ export class GrowthStudioClient { } async uploadImage(filePath, options = {}) { - const file = await fs.openAsBlob(filePath); + const type = UPLOAD_IMAGE_TYPES[path.extname(String(filePath)).toLowerCase()]; + if (!type) throw new Error("upload image accepts only .jpg, .jpeg, .png, or .webp files."); + const stats = await fs.promises.stat(filePath); + if (!stats.isFile()) throw new Error("upload image requires a regular file."); + if (stats.size > MAX_UPLOAD_IMAGE_BYTES) { + throw new Error("upload image files must be 20 MiB or smaller."); + } + const file = await fs.openAsBlob(filePath, { type }); const form = new FormData(); form.set("image", file, path.basename(filePath)); return this.request("POST", "/image/upload", { @@ -175,6 +203,7 @@ export class GrowthStudioClient { } async listVideos(options = {}) { + validateListVideosOptions(options); return this.request("GET", "/videos", { query: { limit: options.limit, @@ -186,14 +215,14 @@ export class GrowthStudioClient { } async listFolders(options = {}) { - return this.requestFolderApi("GET", "/folder/list", { + return this.request("GET", "/folders", { traceId: options.traceId, }); } async createFolder(payload, options = {}) { validateFolderPayload(payload); - return this.requestFolderApi("POST", "/folder/create", { + return this.request("POST", "/folders", { json: payload, traceId: options.traceId, }); @@ -210,26 +239,37 @@ export class GrowthStudioClient { async pollVideo(videoId, options = {}) { const timeoutMs = options.timeoutMs ?? 30 * 60 * 1000; - const startedAt = Date.now(); - let waitSeconds = options.initialDelaySeconds ?? 5; + const startedAt = this.now(); + const fallbackDelaySeconds = Math.max( + MIN_FALLBACK_POLL_DELAY_SECONDS, + options.fallbackDelaySeconds ?? 5, + ); + let waitSeconds = Math.max(MIN_INITIAL_POLL_DELAY_SECONDS, options.initialDelaySeconds ?? 5); while (true) { - if (waitSeconds > 0) await this.sleep(waitSeconds * 1000); - - const result = await this.getVideo(videoId, { traceId: options.traceId }); + await this.sleep(waitSeconds * 1000); + + let result; + try { + result = await this.getVideo(videoId, { traceId: options.traceId }); + } catch (error) { + if (!isRetryableReadError(error) || this.now() - startedAt > timeoutMs) throw error; + waitSeconds = Math.max(MIN_FALLBACK_POLL_DELAY_SECONDS, error.retryAfter ?? fallbackDelaySeconds); + continue; + } const body = result.body; - const editStatus = body.edit?.latest?.status || body.edit?.status; + const editStatus = body.edit?.status || body.edit?.latest?.status; await options.onSnapshot?.(body); if (TERMINAL_STATUSES.has(body.status) && !isEditRunning(editStatus)) { return body; } - if (Date.now() - startedAt > timeoutMs) { + if (this.now() - startedAt > timeoutMs) { throw new Error(`Polling timed out for video_id ${videoId}.`); } - waitSeconds = result.retryAfter ?? options.fallbackDelaySeconds ?? 5; + waitSeconds = Math.max(MIN_FALLBACK_POLL_DELAY_SECONDS, result.retryAfter ?? fallbackDelaySeconds); } } @@ -246,33 +286,22 @@ export class GrowthStudioClient { body = JSON.stringify(options.json); } - return parseResponse(await this.fetch(url, { method, headers, body })); - } - - async requestFolderApi(method, path, options = {}) { - const url = buildUrl(this.baseUrl, `${this.folderApiPrefix}${path}`, options.query); - const headers = new Headers(options.headers); - headers.set("Authorization", `Bearer ${this.folderApiKey}`); - headers.set("Accept", "application/json"); - if (options.traceId) headers.set("Ai-Trace-Id", options.traceId); - - let body = options.body; - if (options.json !== undefined) { - headers.set("Content-Type", "application/json"); - body = JSON.stringify(options.json); + // Only read-only GET requests are retried. Create, edit, upload, and folder + // creation have no idempotency keys, so a retry could duplicate billing or data. + const maxAttempts = method === "GET" ? Math.max(1, this.readRetry.maxAttempts) : 1; + for (let attempt = 1; ; attempt += 1) { + try { + return await parseResponse(await this.fetch(url, { method, headers, body })); + } catch (error) { + if (attempt >= maxAttempts || !isRetryableReadError(error)) throw error; + await this.sleep(readRetryDelayMs(error.retryAfter, attempt, this.readRetry)); + } } - - const response = await parseResponse(await this.fetch(url, { method, headers, body })); - return { - ...response, - body: unwrapFolderApiEnvelope(response.body), - }; } } export function createProductUrlPayload(sourceUrl, video = {}) { return { - ...(video.folderId ? { folder_id: video.folderId } : {}), product: { source_url: sourceUrl, }, @@ -285,29 +314,33 @@ export function createProductUrlPayload(sourceUrl, video = {}) { captions: video.captions ?? true, background_music: video.backgroundMusic ?? true, avatar: video.avatar || { mode: "auto" }, + ...(video.folderId ? { folder_id: video.folderId } : {}), }, ...(video.metadata ? { metadata: video.metadata } : {}), }; } -function unwrapFolderApiEnvelope(body) { - if (body && typeof body === "object" && "ErrCode" in body && body.ErrCode !== 0) { - const error = new Error(body.ErrMsg || `Growth Studio folder request failed with code ${body.ErrCode}.`); - error.code = body.ErrCode; - error.details = body.Resp; - throw error; - } +function isRetryableReadError(error) { + return error instanceof GrowthStudioApiError + && error.retryable === true + && (error.status === 429 || error.status >= 500); +} + +function readRetryDelayMs(retryAfter, attempt, policy) { + if (retryAfter !== undefined) return retryAfter * 1000; + return Math.min(policy.maxDelayMs, policy.baseDelayMs * (2 ** (attempt - 1))); +} - if (body && typeof body === "object" && "Resp" in body) return body.Resp || {}; - return body; +function toPlainJson(value) { + if (Array.isArray(value)) return value.map(toPlainJson); + if (value === null || typeof value !== "object") return value; + const output = {}; + for (const [key, item] of Object.entries(value)) output[key] = toPlainJson(item); + return output; } -function normalizeApiPrefix(prefix) { - if (!prefix || typeof prefix !== "string") return DEFAULT_FOLDER_API_PREFIX; - const trimmed = prefix.trim(); - if (!trimmed) return DEFAULT_FOLDER_API_PREFIX; - const withoutTrailingSlash = trimmed.replace(/\/+$/g, ""); - return withoutTrailingSlash.startsWith("/") ? withoutTrailingSlash : `/${withoutTrailingSlash}`; +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); } function isEditRunning(status) { diff --git a/src/growth-studio/config.js b/src/growth-studio/config.js index 32c7f1b..11d23ba 100644 --- a/src/growth-studio/config.js +++ b/src/growth-studio/config.js @@ -5,8 +5,6 @@ const OFFICIAL_GROWTH_ORIGIN = new URL(DEFAULT_BASE_URL).origin; const DOTENV_KEYS = new Set([ "PIXVERSE_GROWTH_API_KEY", "PIXVERSE_GROWTH_BASE_URL", - "PIXVERSE_GROWTH_FOLDER_API_KEY", - "PIXVERSE_GROWTH_FOLDER_API_PREFIX", "PIXVERSE_GROWTH_ALLOW_CUSTOM_BASE_URL", ]); @@ -31,8 +29,6 @@ export function loadDotEnv(path = ".env") { export function getGrowthStudioConfig(env = process.env) { const apiKey = env.PIXVERSE_GROWTH_API_KEY; - const folderApiKey = env.PIXVERSE_GROWTH_FOLDER_API_KEY || apiKey; - const folderApiPrefix = env.PIXVERSE_GROWTH_FOLDER_API_PREFIX; const baseUrl = normalizeBaseUrl( env.PIXVERSE_GROWTH_BASE_URL || DEFAULT_BASE_URL, env.PIXVERSE_GROWTH_ALLOW_CUSTOM_BASE_URL, @@ -46,7 +42,7 @@ export function getGrowthStudioConfig(env = process.env) { throw new Error("PIXVERSE_GROWTH_API_KEY must be a production key starting with mh_live_."); } - return { apiKey, folderApiKey, folderApiPrefix, baseUrl }; + return { apiKey, baseUrl }; } export const getConfig = getGrowthStudioConfig; diff --git a/src/growth-studio/folders.js b/src/growth-studio/folders.js index e6aab6f..97b1fda 100644 --- a/src/growth-studio/folders.js +++ b/src/growth-studio/folders.js @@ -1,5 +1,5 @@ export async function resolveFolderForPayload(client, payload, options = {}) { - const explicitFolderId = options.folderId || payload.folder_id; + const explicitFolderId = options.folderId || payload?.video?.folder_id; if (explicitFolderId) { assertFolderId(explicitFolderId); return { @@ -12,15 +12,22 @@ export async function resolveFolderForPayload(client, payload, options = {}) { if (!options.folderName && !options.autoFolder) return null; - const folderName = normalizeFolderName(options.folderName || inferFolderName(payload)); + // An explicit --folder-name is sent exactly as the user typed it (trimmed) and + // matched exactly; normalization only applies to names inferred by --auto-folder. + const explicitName = Boolean(options.folderName); + const folderName = explicitName + ? String(options.folderName).trim() + : normalizeFolderName(inferFolderName(payload)); if (!folderName) { - if (options.folderName) throw new Error("Folder name cannot be empty."); + if (explicitName) throw new Error("Folder name cannot be empty."); return null; } const listResult = await client.listFolders({ traceId: options.traceId && `${options.traceId}-folder-list` }); const folders = Array.isArray(listResult.body?.folders) ? listResult.body.folders : []; - const existing = folders.find((folder) => folderNamesMatch(folder.name, folderName)); + const existing = folders.find((folder) => (explicitName + ? typeof folder?.name === "string" && folder.name.trim() === folderName + : folderNamesMatch(folder?.name, folderName))); if (existing) { const folderId = readFolderId(existing); return { @@ -45,8 +52,8 @@ export async function resolveFolderForPayload(client, payload, options = {}) { } export function inferFolderName(payload) { - const metadata = asRecord(payload.metadata); - const product = asRecord(payload.product); + const metadata = asRecord(payload?.metadata); + const product = asRecord(payload?.product); return firstNonEmpty([ metadata.folder_name, diff --git a/src/growth-studio/jobs.js b/src/growth-studio/jobs.js index c7eeaae..5109ada 100644 --- a/src/growth-studio/jobs.js +++ b/src/growth-studio/jobs.js @@ -227,6 +227,8 @@ async function persistFailure(jobDir, error, traceId, options = {}) { ...(publicError.code === undefined ? {} : { code: publicError.code }), ...(publicError.retryable === undefined ? {} : { retryable: publicError.retryable }), ...(publicError.retry_after === undefined ? {} : { retry_after: publicError.retry_after }), + ...(publicError.request_id === undefined ? {} : { request_id: publicError.request_id }), + ...(publicError.details === undefined ? {} : { details: publicError.details }), trace_id: publicError.trace_id ?? traceId, })); } @@ -277,9 +279,9 @@ function requireStringIdentifier(value, message) { } function withFolderId(payload, folderId) { - if (!folderId) return payload; + if (!folderId || payload?.video?.folder_id === folderId) return payload; return { ...payload, - folder_id: folderId, + video: { ...payload.video, folder_id: folderId }, }; } diff --git a/src/growth-studio/validation.js b/src/growth-studio/validation.js index cb6de7e..21807f1 100644 --- a/src/growth-studio/validation.js +++ b/src/growth-studio/validation.js @@ -1,28 +1,47 @@ -export function validateCreatePayload(payload) { - if (!payload || typeof payload !== "object") throw new Error("Create payload must be an object."); - if (payload.folder_id !== undefined) assertFolderId(payload.folder_id); - if (!payload.product || typeof payload.product !== "object") throw new Error("Create payload requires product."); - if (!payload.video || typeof payload.video !== "object") throw new Error("Create payload requires video."); +export const VIDEO_DURATIONS = Object.freeze([15, 30, 45, 60]); +export const VIDEO_RESOLUTIONS = Object.freeze(["480p", "720p", "1080p"]); +export const AVATAR_MODES = Object.freeze(["auto", "custom", "disabled"]); +export const VIDEO_STATUSES = Object.freeze(["queued", "processing", "succeeded", "failed", "canceled"]); +export const VIDEO_LANGUAGES = Object.freeze([ + "en-AU", "pt-BR", "es-BR", "en-CA", "fr-CA", "zh-CN", "es-CO", "fr-FR", "de-DE", "hi-IN", + "en-IN", "id-ID", "it-IT", "ja-JP", "ms-MY", "es-MX", "nl-NL", "en-NL", "fil-PH", "en-PH", + "pl-PL", "ru-RU", "en-SG", "ko-KR", "es-ES", "de-CH", "fr-CH", "it-CH", "zh-TW", "th-TH", + "en-TH", "tr-TR", "ar-AE", "en-AE", "en-GB", "en-US", "es-US", "vi-VN", +]); +export const MAX_PRODUCT_IMAGES = 6; +export const MAX_EDIT_CLIP_INDEX = 40; +export const MAX_EDIT_INSTRUCTION_LENGTH = 500; +export const MAX_LIST_LIMIT = 100; - if (!payload.product.source_url && !Array.isArray(payload.product.images)) { - throw new Error("Create payload requires product.source_url or product.images from the upload endpoint."); - } +const LANGUAGE_SET = new Set(VIDEO_LANGUAGES); +const STATUS_SET = new Set(VIDEO_STATUSES); +const UPLOADED_IMAGE_PREFIX = "https://media.pixverse.ai/"; - for (const image of payload.product.images || []) { - if (!image.url || !image.url.startsWith("https://media.pixverse.ai/")) { - throw new Error("Product images must use URLs returned by the image upload endpoint."); - } +export function validateCreatePayload(payload) { + if (!isRecord(payload)) throw new Error("Create payload must be an object."); + if (payload.folder_id !== undefined) { + throw new Error("folder_id belongs at video.folder_id, not at the top level of the create payload."); } + validateProduct(payload.product); + validateVideo(payload.video); + if (payload.metadata !== undefined) validateMetadata(payload.metadata); } export function validateEditPayload(payload) { - if (!payload || typeof payload !== "object") throw new Error("Edit payload must be an object."); - if (!Number.isInteger(payload.clip_index) || payload.clip_index < 1) { - throw new Error("clip_index must be a one-based positive integer."); + if (!isRecord(payload)) throw new Error("Edit payload must be an object."); + if ( + !Number.isInteger(payload.clip_index) + || payload.clip_index < 1 + || payload.clip_index > MAX_EDIT_CLIP_INDEX + ) { + throw new Error(`clip_index must be a one-based integer from 1 through ${MAX_EDIT_CLIP_INDEX}.`); } - if (!payload.instruction || typeof payload.instruction !== "string") { + if (typeof payload.instruction !== "string" || !payload.instruction.trim()) { throw new Error("instruction is required."); } + if (payload.instruction.length > MAX_EDIT_INSTRUCTION_LENGTH) { + throw new Error(`instruction must be at most ${MAX_EDIT_INSTRUCTION_LENGTH} characters.`); + } } export function validateFolderPayload(payload) { @@ -32,6 +51,20 @@ export function validateFolderPayload(payload) { } } +export function validateListVideosOptions(options = {}) { + if (options.limit !== undefined) { + if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > MAX_LIST_LIMIT) { + throw new Error(`limit must be an integer from 1 through ${MAX_LIST_LIMIT}.`); + } + } + if (options.status !== undefined && !STATUS_SET.has(options.status)) { + throw new Error(`status must be one of: ${VIDEO_STATUSES.join(", ")}.`); + } + if (options.cursor !== undefined && typeof options.cursor !== "string") { + throw new Error("cursor must be a string."); + } +} + export function assertVideoId(videoId) { if (typeof videoId !== "string" || !/^\d+$/.test(videoId)) { throw new Error("video_id must be a numeric string."); @@ -43,3 +76,122 @@ export function assertFolderId(folderId) { throw new Error("folder_id must be a numeric string."); } } + +function validateProduct(product) { + if (!isRecord(product)) throw new Error("Create payload requires product."); + + const hasSourceUrl = product.source_url !== undefined; + if (hasSourceUrl) assertHttpUrl(product.source_url, "product.source_url", 2048); + + if (product.title !== undefined || !hasSourceUrl) { + assertNonEmptyString(product.title, "product.title", 255, hasSourceUrl ? "" : " when product.source_url is omitted"); + } + assertOptionalString(product.description, "product.description", 5120); + assertOptionalString(product.brand, "product.brand", 255); + + if (product.price !== undefined) { + if (!isRecord(product.price)) throw new Error("product.price must be an object."); + if (typeof product.price.amount !== "string" || !product.price.amount.trim()) { + throw new Error("product.price.amount must be a non-empty string such as \"89.99\"."); + } + if (typeof product.price.currency !== "string" || !/^[A-Za-z]{3}$/.test(product.price.currency)) { + throw new Error("product.price.currency must be a three-letter currency code."); + } + } + + if (product.images === undefined) { + if (!hasSourceUrl) { + throw new Error("Create payload requires product.source_url or product.images from the upload endpoint."); + } + return; + } + if (!Array.isArray(product.images)) throw new Error("product.images must be an array."); + if (!hasSourceUrl && product.images.length === 0) { + throw new Error("product.images requires at least one uploaded image when product.source_url is omitted."); + } + if (product.images.length > MAX_PRODUCT_IMAGES) { + throw new Error(`product.images accepts at most ${MAX_PRODUCT_IMAGES} images.`); + } + for (const image of product.images) { + if (!isRecord(image) || typeof image.url !== "string" || !image.url.startsWith(UPLOADED_IMAGE_PREFIX)) { + throw new Error("Product images must use URLs returned by the image upload endpoint."); + } + } +} + +function validateVideo(video) { + if (!isRecord(video)) throw new Error("Create payload requires video."); + + assertNonEmptyString(video.aspect_ratio, "video.aspect_ratio", Number.MAX_SAFE_INTEGER); + if (!VIDEO_DURATIONS.includes(video.duration_seconds)) { + throw new Error(`video.duration_seconds must be one of: ${VIDEO_DURATIONS.join(", ")}.`); + } + if (typeof video.resolution !== "string" || !VIDEO_RESOLUTIONS.includes(video.resolution.toLowerCase())) { + throw new Error(`video.resolution must be one of: ${VIDEO_RESOLUTIONS.join(", ")}.`); + } + if (!LANGUAGE_SET.has(video.language)) { + throw new Error("video.language must be one of the documented language codes, such as en-US."); + } + for (const field of ["voiceover", "captions", "background_music"]) { + if (typeof video[field] !== "boolean") throw new Error(`video.${field} must be a boolean.`); + } + + if (!isRecord(video.avatar)) throw new Error("video.avatar must be an object."); + if (!AVATAR_MODES.includes(video.avatar.mode)) { + throw new Error(`video.avatar.mode must be one of: ${AVATAR_MODES.join(", ")}.`); + } + if (video.avatar.mode === "custom") { + if (typeof video.avatar.url !== "string" || !video.avatar.url.trim()) { + throw new Error("video.avatar.url is required when video.avatar.mode is custom."); + } + } else if (video.avatar.url !== undefined) { + throw new Error(`video.avatar.url must be omitted when video.avatar.mode is ${video.avatar.mode}.`); + } + + assertOptionalString(video.creative_ad_clone, "video.creative_ad_clone", Number.MAX_SAFE_INTEGER); + assertOptionalString(video.subtitles_size_mode, "video.subtitles_size_mode", Number.MAX_SAFE_INTEGER); + if (video.folder_id !== undefined) assertFolderId(video.folder_id); +} + +function validateMetadata(metadata) { + if (!isRecord(metadata)) throw new Error("metadata must be an object of string values."); + for (const [key, value] of Object.entries(metadata)) { + if (typeof value !== "string") throw new Error(`metadata.${key} must be a string.`); + } +} + +function assertHttpUrl(value, label, maximumLength) { + if (typeof value !== "string" || !value || value.length > maximumLength) { + throw new Error(`${label} must be a non-empty string of at most ${maximumLength} characters.`); + } + let url; + try { + url = new URL(value); + } catch { + throw new Error(`${label} must be an HTTP or HTTPS URL.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${label} must be an HTTP or HTTPS URL.`); + } +} + +function assertNonEmptyString(value, label, maximumLength, condition = "") { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${label} is required${condition}.`); + } + if (value.length > maximumLength) { + throw new Error(`${label} must be at most ${maximumLength} characters.`); + } +} + +function assertOptionalString(value, label, maximumLength) { + if (value === undefined) return; + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + if (value.length > maximumLength) { + throw new Error(`${label} must be at most ${maximumLength} characters.`); + } +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/platform/client.js b/src/platform/client.js index cdbbe7d..37b2a4c 100644 --- a/src/platform/client.js +++ b/src/platform/client.js @@ -46,9 +46,8 @@ export class PlatformClient { headers.set("Accept", "application/json"); headers.set("API-KEY", this.apiKey); headers.set("Ai-trace-id", traceId); - const retry = operation.billing === "read-only" && RETRYABLE_METHODS.has(operation.method) - ? this.retry - : { maxAttempts: 1 }; + const retrySafe = operation.billing === "read-only" && RETRYABLE_METHODS.has(operation.method); + const retry = retrySafe ? this.retry : { maxAttempts: 1 }; let response; try { @@ -63,6 +62,9 @@ export class PlatformClient { provider: "platform", operation: operation.id, traceId, + // Read-only retries need a unique Ai-trace-id per attempt. A recovery + // trace is never rotated: it only exists for single-attempt submissions. + nextTraceId: retrySafe && options.recovery !== true ? this.traceIdFactory : undefined, signal: options.signal, }); } catch (error) { @@ -70,20 +72,23 @@ export class PlatformClient { parsePlatformEnvelope(normalizePlatformResponseIdentifiers(error.details), { operation: operation.id, httpStatus: error.status, - traceId, + traceId: error.traceId ?? traceId, + retryAfter: error.retryAfter, }); } throw error; } + const attemptTraceId = response.traceId ?? traceId; const parsed = parsePlatformEnvelope(normalizePlatformResponseIdentifiers(response.body), { operation: operation.id, httpStatus: response.status, - traceId, + traceId: attemptTraceId, + retryAfter: response.retryAfter, }); return { operation: operation.id, - traceId, + traceId: attemptTraceId, envelope: parsed.envelope, data: parsed.data, retryAfter: response.retryAfter, diff --git a/src/platform/envelope.js b/src/platform/envelope.js index fee8e75..9226bfd 100644 --- a/src/platform/envelope.js +++ b/src/platform/envelope.js @@ -1,5 +1,16 @@ import { PixverseCliError } from "../core/errors.js"; +// Documented transient ErrCodes. 500069 is "system under high load, try again +// later"; 500044 is the concurrent-generation limit, which clears as running +// jobs finish. Marking them retryable lets read-only callers (status polling) +// keep waiting. It never authorizes an automatic billable resubmission: POST +// submissions are single-attempt regardless of this flag. +const RETRYABLE_PLATFORM_ERROR_CODES = new Set([500044, 500069]); + +export function isRetryablePlatformErrorCode(code) { + return RETRYABLE_PLATFORM_ERROR_CODES.has(Number(code)); +} + export function parsePlatformEnvelope(body, context = {}) { if (!isEnvelope(body)) { throw new PixverseCliError("Invalid Platform API response envelope.", { @@ -21,7 +32,8 @@ export function parsePlatformEnvelope(body, context = {}) { status: context.httpStatus, code: body.ErrCode, traceId: context.traceId, - retryable: false, + retryable: isRetryablePlatformErrorCode(errorCode), + ...(Number.isFinite(context.retryAfter) ? { retryAfter: context.retryAfter } : {}), details: body, }); } diff --git a/src/platform/jobs.js b/src/platform/jobs.js index 7978dd7..667d1a3 100644 --- a/src/platform/jobs.js +++ b/src/platform/jobs.js @@ -7,7 +7,7 @@ import { readJsonArtifact, writeJsonArtifact, } from "../core/artifacts.js"; -import { PixverseCliError, serializeError } from "../core/errors.js"; +import { isTransientReadError, PixverseCliError, serializeError } from "../core/errors.js"; import { pollUntilTerminal } from "../core/polling.js"; import { redact } from "../core/redaction.js"; import { createTraceId } from "../core/trace.js"; @@ -15,8 +15,12 @@ import { getPlatformOperation } from "./operations.js"; import { normalizePlatformStatus } from "./status.js"; import { normalizeAndValidatePlatformInput } from "./validation.js"; -const DEFAULT_INTERVAL_MS = 3_000; -const DEFAULT_TIMEOUT_MS = 10 * 60 * 1_000; +// Official status guidance: poll no more often than every 5 seconds. +const DEFAULT_INTERVAL_MS = 5_000; +// Generation can legitimately run well past 10 minutes (the FAQ covers jobs +// exceeding 10 minutes and 2 hours). A timeout is resumable from the job +// directory, so this only bounds a single waiting session. +const DEFAULT_TIMEOUT_MS = 30 * 60 * 1_000; export async function submitPlatformJob(client, operationId, input, options = {}) { const operation = requireBillableOperation(operationId); @@ -152,6 +156,16 @@ async function pollKnownJob(client, { jobDir, operation, id, traceId, options }) onSnapshot: (snapshot) => appendJsonLineArtifact( path.join(jobDir, "polling.jsonl"), snapshot, { rootDir: jobDir }, ), + // A status read is read-only: transport failures, HTTP 429/5xx, and + // retryable ErrCodes (for example 500069 high load) must not abandon a + // job that is still generating. Keep polling with backoff until the + // overall timeout. The billable submission above is never retried. + isRetryableError: (error) => options.signal?.aborted !== true && isTransientReadError(error), + onPollError: (error, { consecutiveErrors }) => appendJsonLineArtifact( + path.join(jobDir, "polling.jsonl"), + transientPollRecord(error, consecutiveErrors), + { rootDir: jobDir }, + ), intervalMs: options.intervalMs ?? DEFAULT_INTERVAL_MS, timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, sleep: async (milliseconds) => { @@ -182,6 +196,23 @@ async function pollKnownJob(client, { jobDir, operation, id, traceId, options }) } } +function transientPollRecord(error, consecutiveErrors) { + const publicError = serializeError(error); + return redact({ + status: "poll_error", + terminal: false, + transient: true, + consecutive_errors: consecutiveErrors, + error: { + category: publicError.category ?? inferErrorCategory(error), + ...(publicError.status === undefined ? {} : { status: publicError.status }), + ...(publicError.code === undefined ? {} : { code: publicError.code }), + ...(publicError.retry_after === undefined ? {} : { retry_after: publicError.retry_after }), + ...(publicError.trace_id === undefined ? {} : { trace_id: publicError.trace_id }), + }, + }); +} + function timeoutRecoveryError(error, { jobDir, id }) { return new PixverseCliError(error.message, { category: error.category, diff --git a/src/platform/operations.js b/src/platform/operations.js index 0264b7a..61508c5 100644 --- a/src/platform/operations.js +++ b/src/platform/operations.js @@ -37,7 +37,7 @@ const DOCUMENTATION_URLS = Object.freeze({ "video.avatar": "https://docs.platform.pixverse.ai/avatar-generation-40528034e0", "agent.viral-recreation": "https://docs.platform.pixverse.ai/viral-recreation-agent-41205382e0", "agent.real-estate": "https://docs.platform.pixverse.ai/one-click-real-estate-video-42843647e0", - "agent.music-mv": "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc", + "agent.music-mv": "https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0", "video.status": "https://docs.platform.pixverse.ai/get-video-generation-status-13016632e0", }); diff --git a/src/platform/request.js b/src/platform/request.js index b53fef4..a3c4d7f 100644 --- a/src/platform/request.js +++ b/src/platform/request.js @@ -5,6 +5,23 @@ import JSONBigFactory from "json-bigint"; import { PixverseCliError } from "../core/errors.js"; const PATH_PARAMETER = /\{([^}]+)\}/g; +// The template list documents template_ids as one comma-separated string. +const COMMA_SEPARATED_QUERY = new Set(["template_ids"]); +// Upload endpoints accept only these MIME types; an untyped Blob is sent as +// application/octet-stream, which the image upload reference rejects. +const UPLOAD_MIME_TYPES = new Map(Object.entries({ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".m4a": "audio/x-m4a", + ".aac": "audio/aac", +})); const JSON_BIG = JSONBigFactory({ useNativeBigInt: true, protoAction: "error", @@ -38,35 +55,87 @@ export async function buildPlatformRequest(operation, normalizedInput) { }; } +// Fields each operation's API reference types as integer. The validators keep +// identifiers as decimal strings so 64-bit values stay exact; they are turned +// back into JSON integers here. Fields typed as string (mask_id, mask_ids, +// lip_sync_tts_speaker_id, webhook_id, fusion video_references, image.template +// img_ids) are deliberately absent. +const VIDEO_SOURCE_IDS = ["source_video_id", "video_media_id"]; +const INTEGER_ID_FIELDS = new Map(Object.entries({ + "audio.verify": ["audio_media_id"], + "voice.create": ["audio_media_id"], + "image.template": ["template_id"], + "video.text": ["template_id"], + "video.image": ["img_id", "template_id"], + "video.template": ["img_id", "img_ids[]", "template_id"], + "video.transition": ["first_frame_img", "last_frame_img"], + "video.multi-transition": ["multi_transition[].img_id"], + "video.lip-sync": [...VIDEO_SOURCE_IDS, "audio_media_id"], + "video.fusion": ["image_references[].img_id"], + "video.avatar": ["img_id", "audio_media_id"], + "video.restyle": [...VIDEO_SOURCE_IDS, "restyle_id"], + "video.swap-mask": [...VIDEO_SOURCE_IDS, "keyframe_id"], + "video.swap": [...VIDEO_SOURCE_IDS, "keyframe_id", "img_id"], + "video.sound-effect": VIDEO_SOURCE_IDS, + "video.extend": [...VIDEO_SOURCE_IDS, "template_id"], + "video.motion-control": [...VIDEO_SOURCE_IDS, "img_id"], + "video.modify": [...VIDEO_SOURCE_IDS, "img_ids[]", "keyframe_id"], + "video.upscale": VIDEO_SOURCE_IDS, + "agent.viral-recreation": [ + "agent_id", + "img_references[].img_id", + "video_references[].video_media_id", + "video_references[].source_video_id", + ], + "agent.real-estate": ["agent_id", "img_references[].img_id"], + "agent.music-mv": ["audio_media_id", "image_references[].img_id", "style_img_references[].img_id"], +})); + +export function integerIdFields(operationId) { + return INTEGER_ID_FIELDS.get(operationId) ?? []; +} + function serializeJsonBody(operation, payload) { - if (!new Set(["audio.verify", "agent.music-mv", "video.fusion", "video.upscale"]).has(operation.id)) { - return JSON.stringify(payload); - } + const fields = INTEGER_ID_FIELDS.get(operation.id); + if (!fields) return JSON.stringify(payload); const wirePayload = { ...payload }; if (operation.id === "agent.music-mv" && wirePayload.img_references !== undefined) { wirePayload.image_references = wirePayload.img_references; delete wirePayload.img_references; } - if (wirePayload.audio_media_id !== undefined) { - wirePayload.audio_media_id = decimalStringToBigInt(wirePayload.audio_media_id); - } - for (const field of ["img_references", "image_references", "style_img_references"]) { - if (!Array.isArray(wirePayload[field])) continue; - wirePayload[field] = wirePayload[field].map((reference) => ({ - ...reference, - img_id: decimalStringToBigInt(reference.img_id), - })); + for (const field of fields) convertIntegerPath(operation, wirePayload, field.split("."), field); + return JSON_BIG.stringify(wirePayload); +} + +function convertIntegerPath(operation, container, [segment, ...rest], label) { + const isArray = segment.endsWith("[]"); + const key = isArray ? segment.slice(0, -2) : segment; + if (container === null || typeof container !== "object" || container[key] === undefined) return; + if (isArray) { + if (!Array.isArray(container[key])) return; + container[key] = container[key].map((item) => { + if (rest.length === 0) return toWireInteger(operation, label, item); + if (item === null || typeof item !== "object" || Array.isArray(item)) return item; + const copy = { ...item }; + convertIntegerPath(operation, copy, rest, label); + return copy; + }); + return; } - for (const field of ["source_video_id", "video_media_id"]) { - if (wirePayload[field] !== undefined) { - wirePayload[field] = decimalStringToBigInt(wirePayload[field]); - } + if (rest.length === 0) { + container[key] = toWireInteger(operation, label, container[key]); + return; } - return JSON_BIG.stringify(wirePayload); + if (container[key] === null || typeof container[key] !== "object") return; + container[key] = Array.isArray(container[key]) ? [...container[key]] : { ...container[key] }; + convertIntegerPath(operation, container[key], rest, label); } -function decimalStringToBigInt(value) { - return typeof value === "string" && /^[1-9]\d*$/.test(value) ? BigInt(value) : value; +function toWireInteger(operation, field, value) { + if (value === null || value === "") return value; + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return value; + if (typeof value === "string" && /^(?:0|[1-9]\d*)$/.test(value)) return BigInt(value); + throw requestError(operation, `${field} must be a non-negative integer ID.`); } function interpolatePath(operation, pathParams) { @@ -83,7 +152,9 @@ function appendQuery(requestPath, query) { const search = new URLSearchParams(); for (const [name, value] of Object.entries(query)) { if (value === undefined || value === null || value === "") continue; - if (Array.isArray(value)) { + if (Array.isArray(value) && COMMA_SEPARATED_QUERY.has(name)) { + search.append(name, value.map(serializeField).join(",")); + } else if (Array.isArray(value)) { for (const item of value) search.append(name, serializeField(item)); } else { search.append(name, serializeField(value)); @@ -99,7 +170,8 @@ async function buildMultipartBody(normalizedInput) { if (value !== undefined && value !== null) form.append(name, serializeField(value)); } for (const [name, filePath] of Object.entries(normalizedInput.files)) { - const blob = await fs.openAsBlob(filePath); + const type = UPLOAD_MIME_TYPES.get(path.extname(filePath).toLowerCase()); + const blob = await fs.openAsBlob(filePath, type ? { type } : undefined); form.append(name, blob, path.basename(filePath)); } return form; diff --git a/src/platform/validators/music-mv.js b/src/platform/validators/music-mv.js index 560e4b8..ca2f700 100644 --- a/src/platform/validators/music-mv.js +++ b/src/platform/validators/music-mv.js @@ -1,14 +1,15 @@ import { requireFields, validationError } from "./common.js"; -const AGENT_TYPES = new Set(["vibe_mv", "vibe_mv_v3_custom"]); +// vibe_mv_v3 is the documented legacy workflow; vibe_mv_v3_custom is recommended. +const AGENT_TYPES = new Set(["vibe_mv_v3_custom", "vibe_mv_v3"]); const ASPECT_RATIOS = new Set(["16:9", "9:16", "1:1", "4:3", "3:4"]); const QUALITIES = new Set(["720p", "1080p"]); const MUSIC_STYLES = new Set([ - "pop", "rock", "hiphop", "r&b", "jazz", "reggae", "country", "folk", + "pop", "rock", "hip hop", "hiphop", "r&b", "jazz", "reggae", "country", "folk", "electronic", "classical", "soul", "funk", "metal", "ambient", "others", ]); const MV_STYLES = new Set([ - "custom", "cinematic", "lo-fi", "dreamscape", "woolen felt", "candy", "vintage", + "custom", "cinematic", "lo-fi", "dreamscape", "woolen felt", "candy", "golden age", "voxel", "retro game", "claymation", "woodland tale", "impressionism", "decadence", "futuristic", "chromatic clash", "holiday", ]); @@ -18,13 +19,14 @@ const UINT64_MAX = 18_446_744_073_709_551_615n; export function normalizeAndValidateMusicMv(operation, payload) { const normalized = normalizeImageReferenceAlias(operation, payload); - requireFields(operation, normalized, [ - "mv_agent_type", "audio_media_id", "aspect_ratio", "quality", - ]); + requireFields(operation, normalized, ["mv_agent_type", "audio_media_id"]); validateExactEnum(operation, "mv_agent_type", normalized.mv_agent_type, AGENT_TYPES); validatePositiveDecimalId(operation, "audio_media_id", normalized.audio_media_id); - validateExactEnum(operation, "aspect_ratio", normalized.aspect_ratio, ASPECT_RATIOS); - validateExactEnum(operation, "quality", normalized.quality, QUALITIES); + // Both are optional; the API defaults to 16:9 and 720p. + if (normalized.aspect_ratio !== undefined) { + validateExactEnum(operation, "aspect_ratio", normalized.aspect_ratio, ASPECT_RATIOS); + } + if (normalized.quality !== undefined) validateExactEnum(operation, "quality", normalized.quality, QUALITIES); validateOptionalStyles(operation, normalized); validateReferences(operation, normalized); validateOptionalScalars(operation, normalized); @@ -68,7 +70,11 @@ function validateReferences(operation, payload) { } for (const reference of references) validateReference(operation, reference); } - if (String(payload.mv_style).toLowerCase() === "custom") { + const customStyle = String(payload.mv_style).toLowerCase() === "custom"; + if (!customStyle && (payload.style_img_references?.length ?? 0) > 0) { + throw validationError(operation, "style_img_references is supported only when mv_style is Custom."); + } + if (customStyle) { const count = (payload.img_references?.length ?? 0) + (payload.style_img_references?.length ?? 0); if (count === 0) throw validationError(operation, "Custom mv_style requires an image or style reference."); } diff --git a/src/platform/validators/specialized.js b/src/platform/validators/specialized.js index c65692d..4930f6f 100644 --- a/src/platform/validators/specialized.js +++ b/src/platform/validators/specialized.js @@ -10,6 +10,8 @@ import { inspectLocalMedia as inspectMedia } from "../../core/media.js"; import { normalizeAndValidateMusicMv, validatePositiveDecimalId } from "./music-mv.js"; const VIDEO_SOURCES = ["source_video_id", "video_media_id"]; +const EXTEND_MODELS = ["v3.5", "v4", "v4.5", "v5", "v5.5", "v6"]; +const SEED_MAXIMUM = 2_147_483_647; export async function validateSpecializedOperation(operation, payload, query = payload, context = {}) { switch (operation.id) { @@ -50,11 +52,23 @@ export async function validateSpecializedOperation(operation, payload, query = p return; case "video.sound-effect": requireExactlyOneField(operation, payload, VIDEO_SOURCES); - requireFields(operation, payload, ["prompt"]); + // The API has no prompt field; the sound description is sound_effect_content. + if (payload.prompt !== undefined) { + throw validationError(operation, "sound-effect has no prompt field; describe the sound with sound_effect_content."); + } + if (payload.sound_effect_content !== undefined && typeof payload.sound_effect_content !== "string") { + throw validationError(operation, "sound_effect_content must be a string."); + } + if (payload.original_sound_switch !== undefined && typeof payload.original_sound_switch !== "boolean") { + throw validationError(operation, "original_sound_switch must be a boolean."); + } return; case "video.extend": requireExactlyOneField(operation, payload, VIDEO_SOURCES); requireFields(operation, payload, ["prompt", "seed", "quality", "duration", "model"]); + if (!EXTEND_MODELS.includes(payload.model)) { + throw validationError(operation, `extend model must be one of ${EXTEND_MODELS.join(", ")}.`); + } validateModelScalars(operation, payload); return; case "video.motion-control": @@ -102,8 +116,9 @@ function validateUsage(operation, payload) { function validateTemplateQuery(operation, query) { if (query.type !== undefined && ![1, 2, "1", "2"].includes(query.type)) throw validationError(operation, "type must be 1 or 2."); - if (query.page !== undefined) validatePositiveInteger(operation, query.page, "page"); - if (query.pageSize !== undefined && ![10, 20, 50, 100].includes(query.pageSize)) throw validationError(operation, "pageSize must be 10, 20, 50, or 100."); + // The reference types these query values as strings, e.g. page "1" and pageSize "50". + if (query.page !== undefined) validatePositiveInteger(operation, toIntegerOrSelf(query.page), "page"); + if (query.pageSize !== undefined && ![10, 20, 50, 100].includes(toIntegerOrSelf(query.pageSize))) throw validationError(operation, "pageSize must be 10, 20, 50, or 100."); } function validateTtsQuery(operation, query) { @@ -118,6 +133,16 @@ async function validateViralAgent(operation, payload, context) { validateModelScalars(operation, payload, { qualities: ["720p", "1080p"], aspectRatios: ["9:16", "16:9", "1:1", "4:3", "3:4", "21:9"] }); if (!Array.isArray(payload.img_references) || payload.img_references.length < 1 || payload.img_references.length > 5) throw validationError(operation, "img_references must contain between 1 and 5 images."); if (!Array.isArray(payload.video_references) || payload.video_references.length !== 1) throw validationError(operation, "video_references must contain exactly one video."); + // Each reference must carry an uploaded asset ID; the API has no URL or file fields here. + payload.img_references.forEach((reference, index) => { + if (!isReferenceObject(reference) || !isPresent(reference.img_id)) { + throw validationError(operation, `img_references[${index}] requires an img_id from upload image.`); + } + }); + payload.video_references.forEach((reference, index) => { + if (!isReferenceObject(reference)) throw validationError(operation, `video_references[${index}] must be an object.`); + requireExactlyOneField(operation, reference, VIDEO_SOURCES, `video_references[${index}] source_video_id or video_media_id`); + }); const inspector = context.inspectLocalMedia ?? inspectMedia; for (const reference of payload.img_references) { const file = localReferencePath(reference); @@ -132,13 +157,25 @@ async function validateViralAgent(operation, payload, context) { if (metadata.duration_seconds < 2 || metadata.duration_seconds > 30) throw validationError(operation, "Viral-agent video duration must be between 2 and 30 seconds."); if (metadata.width < 640 || metadata.width > 1920 || metadata.height < 640 || metadata.height > 1920) throw validationError(operation, "Viral-agent video width and height must each be between 640 and 1920 pixels."); } + // Local paths only feed the media checks above and must not reach the API. + return { + ...payload, + img_references: payload.img_references.map(withoutLocalPath), + video_references: payload.video_references.map(withoutLocalPath), + }; +} + +function withoutLocalPath({ file, local_path, ...reference }) { + return reference; } function validateRealEstateAgent(operation, payload) { requireFields(operation, payload, ["agent_id", "model"]); if (payload.agent_id !== "419629433597950") throw validationError(operation, "real estate agent_id must be 419629433597950."); if (!["pro", "fast"].includes(payload.model)) throw validationError(operation, "real estate model must be pro or fast."); - if (payload.mode !== undefined && payload.model !== "pro") throw validationError(operation, "mode is supported only by the pro real estate model."); + for (const field of ["mode", "duration", "video_style", "lip_sync_tts_speaker_id"]) { + if (payload[field] !== undefined && payload.model !== "pro") throw validationError(operation, `${field} is supported only by the pro real estate model.`); + } if (payload.mode !== undefined && !["property_tour", "ad_house_tour"].includes(payload.mode)) throw validationError(operation, "mode must be property_tour or ad_house_tour."); if (isPresent(payload.product_url)) { validateSafeRemoteUrl(operation, "product_url", payload.product_url); @@ -162,11 +199,14 @@ function validateRealEstateAgent(operation, payload) { if (payload.quality !== undefined) validateQuality(operation, payload.quality, qualities); } +function toIntegerOrSelf(value) { + return typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value; +} function validatePositiveInteger(operation, value, name) { if (!Number.isInteger(value) || value < 1) throw validationError(operation, `${name} must be a positive integer.`); } function validateOptionalSeed(operation, value) { - if (value !== undefined && (!Number.isInteger(value) || value < 0)) throw validationError(operation, "seed must be a non-negative integer."); + if (value !== undefined && (!Number.isInteger(value) || value < 0 || value > SEED_MAXIMUM)) throw validationError(operation, `seed must be an integer from 0 to ${SEED_MAXIMUM}.`); } function validateKeyframe(operation, value, required) { if (!isPresent(value)) { @@ -174,13 +214,12 @@ function validateKeyframe(operation, value, required) { return; } const normalized = typeof value === "number" && Number.isSafeInteger(value) ? String(value) : value; - const minimum = operation.id === "video.swap-mask" ? 0n : 1n; + // Documented range: 1 to the position of the last video frame. if (typeof normalized !== "string" || !/^\d+$/.test(normalized) - || BigInt(normalized) < minimum + || BigInt(normalized) < 1n || BigInt(normalized) > BigInt(Number.MAX_SAFE_INTEGER)) { - const range = minimum === 0n ? "non-negative" : "positive"; - throw validationError(operation, `keyframe_id must be a ${range} safe-integer string.`); + throw validationError(operation, "keyframe_id must be a positive safe-integer string."); } } function validateQuality(operation, value, allowed) { @@ -189,6 +228,9 @@ function validateQuality(operation, value, allowed) { function validateMaximumItems(operation, value, field, maximum) { if (value !== undefined && (!Array.isArray(value) || value.length > maximum)) throw validationError(operation, `${field} must contain no more than ${maximum} items.`); } +function isReferenceObject(reference) { + return reference !== null && typeof reference === "object" && !Array.isArray(reference); +} function localReferencePath(reference) { if (!reference || typeof reference !== "object" || Array.isArray(reference)) return undefined; return reference.file ?? reference.local_path; diff --git a/src/platform/validators/video.js b/src/platform/validators/video.js index 825c322..4696ac7 100644 --- a/src/platform/validators/video.js +++ b/src/platform/validators/video.js @@ -21,13 +21,20 @@ export function validateVideoOperation(operation, payload) { validateModelScalars(operation, payload); requireFields(operation, payload, ["duration", "img_id", "model", "prompt", "quality"]); break; - case "video.template": - validateModelScalars(operation, payload, { promptMaxLength: 2048 }); - requireFields(operation, payload, ["template_id", "duration", "model", "prompt", "quality"]); + case "video.template": { + // The template supplies the prompt: it may be empty and is ignored when template_id is set. + if (payload.prompt === undefined) payload.prompt = ""; + if (typeof payload.prompt !== "string" || payload.prompt.length > 2048) { + throw validationError(operation, "prompt must be a string no longer than 2048 characters."); + } + const { prompt, ...scalars } = payload; + validateModelScalars(operation, scalars); + requireFields(operation, payload, ["template_id", "duration", "model", "quality"]); if (!isPresent(payload.img_id) && (!Array.isArray(payload.img_ids) || payload.img_ids.length === 0)) { throw validationError(operation, "img_id or img_ids image source is required."); } break; + } case "video.transition": validateModelScalars(operation, payload); requireFields(operation, payload, ["prompt", "model", "duration", "quality"]); @@ -46,6 +53,10 @@ export function validateVideoOperation(operation, payload) { validateModelScalars(operation, payload); requireFields(operation, payload, ["img_id", "quality"]); validateAudioOrTts(operation, payload, true); + if (isPresent(payload.lip_sync_tts_content) + && (payload.lip_sync_tts_content.length < 30 || payload.lip_sync_tts_content.length > 200)) { + throw validationError(operation, "avatar lip_sync_tts_content must contain 30 to 200 characters."); + } break; default: throw validationError(operation, `No video validator exists for ${operation.id}.`); @@ -53,8 +64,8 @@ export function validateVideoOperation(operation, payload) { } function validateTransition(operation, payload) { - requireField(operation, payload, ["first_frame_img", "first_frame_img_id", "first_frame_image_id", "first_frame_url"], "first frame"); - requireField(operation, payload, ["last_frame_img", "last_frame_img_id", "last_frame_image_id", "last_frame_url"], "last frame"); + // The API only reads first_frame_img/last_frame_img; aliases would be sent but ignored. + requireFields(operation, payload, ["first_frame_img", "last_frame_img"]); } function validateMultiTransition(operation, payload) { @@ -72,13 +83,19 @@ function validateMultiTransition(operation, payload) { throw validationError(operation, `multi-transition item ${index + 1} must be an object.`); } requireField(operation, item, ["img_id"], `multi-transition item ${index + 1} img_id`); - if (index < items.length - 1 && !isPresent(item.duration)) { + const last = index === items.length - 1; + if (last) { + if (isPresent(item.duration) && item.duration !== 0) { + throw validationError(operation, "the last multi-transition item duration must be 0 or omitted."); + } + return; + } + if (!isPresent(item.duration)) { throw validationError(operation, "duration is required for every multi-transition item except the last item."); } - const minimumDuration = index === items.length - 1 ? 0 : 1; - if (isPresent(item.duration) && (!Number.isInteger(item.duration) || item.duration < minimumDuration)) { - const range = minimumDuration === 0 ? "a non-negative integer" : "a positive integer"; - throw validationError(operation, `multi-transition item duration must be ${range}.`); + const maximumDuration = items.length >= 3 ? 5 : 8; + if (!Number.isInteger(item.duration) || item.duration < 1 || item.duration > maximumDuration) { + throw validationError(operation, `multi-transition item duration must be an integer from 1 to ${maximumDuration} when there are ${items.length} items.`); } }); } @@ -110,15 +127,26 @@ function validateFusion(operation, payload) { ? { aspectRatios: ["16:9", "4:3", "1:1", "3:4", "9:16", "2:3", "3:2", "21:9", "auto"] } : {}; validateModelScalars(operation, scalarPayload, scalarOptions); - requireFields(operation, payload, ["image_references", "prompt", "model", "duration", "quality", "aspect_ratio"]); + // v6 omni requests may reference only videos (fusion v6 video reference guide). + requireFields(operation, payload, omniVideo + ? ["prompt", "model", "duration", "quality", "aspect_ratio"] + : ["image_references", "prompt", "model", "duration", "quality", "aspect_ratio"]); if (!["v4.5", "v5", "v5.5", "v5.6", "v6", "c1"].includes(payload.model)) { throw validationError(operation, "fusion model must be v4.5, v5, v5.5, v5.6, v6, or c1."); } validateFusionModelRules(operation, payload, omniVideo); - const references = payload.image_references; - if (!Array.isArray(references) || references.length === 0) { + if (omniVideo && payload.video_references.length > 2) { + throw validationError(operation, "fusion accepts at most two reference videos."); + } + const references = payload.image_references ?? (omniVideo ? [] : undefined); + if (!Array.isArray(references) || (references.length === 0 && !omniVideo)) { throw validationError(operation, "fusion requires one or more image references."); } + for (const reference of references) { + if (reference?.type !== undefined && !["subject", "background"].includes(reference.type)) { + throw validationError(operation, "fusion image reference type must be subject or background."); + } + } const maximum = payload.model === "v6" && payload.reference_mode === "omni" ? 10 : ["v5.5", "v5.6", "v6", "c1"].includes(payload.model) ? 7 : 3; if (references.length > maximum) throw validationError(operation, `fusion accepts at most ${maximum} image references for this model.`); diff --git a/src/platform/webhooks.js b/src/platform/webhooks.js index 7e424b4..9efd159 100644 --- a/src/platform/webhooks.js +++ b/src/platform/webhooks.js @@ -13,8 +13,20 @@ const MAX_BODY_BYTES = 1_048_576; const MAX_SECRET_BYTES = 4_096; const MAX_NONCE_BYTES = 256; const MAX_TRACE_ID_BYTES = 256; -const MAX_TIMESTAMP_SKEW_SECONDS = 300; -const MAX_NONCES = 10_000; +// PixVerse retries a failed delivery after 2s, 10s, 30s, 1m, 5m, 15m, 1h and +// 4h (about 5h22m in total), and the docs do not say whether a retry is +// re-signed with a fresh Webhook-Timestamp. A 300s window could therefore +// reject PixVerse's own late retries and lose the result. The default accepts +// the whole documented schedule plus margin. Widening the window is safe +// because replays are still stopped by the nonce store (each nonce is held +// for the full window) and by (payload id, status) event deduplication. +export const DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 6 * 60 * 60; +const MAX_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 7 * 24 * 60 * 60; +// Clock skew allowed for timestamps in the future; deliveries are never +// legitimately signed far ahead of the receiver's clock. +const MAX_FUTURE_SKEW_SECONDS = 300; +const MAX_NONCES = 100_000; +const EVENT_KEY_PREFIX = "pixverse-webhook-event:"; const DEFAULT_COMMIT_TIMEOUT_MS = 5_000; const MAX_COMMIT_TIMEOUT_MS = 60_000; const REQUIRED_HEADERS = new Map([ @@ -30,7 +42,12 @@ export function createMemoryNonceStore(options = {}) { for (const [storedNonce, entry] of entries) { if (entry.expiresAt < nowSeconds) entries.delete(storedNonce); } - if (entries.has(nonce)) return undefined; + const existing = entries.get(nonce); + // A committed key is an already-processed delivery: report a duplicate. + // An in-flight reservation must not be acknowledged, because the first + // attempt may still fail and release it; ask the sender to retry later. + if (existing?.state === "committed") return undefined; + if (existing) throw inProgressError(); if (entries.size >= maxEntries) throw webhookError( "Webhook replay protection is temporarily unavailable.", "WEBHOOK_NONCE_STORE_FULL", @@ -80,20 +97,21 @@ export async function verifyPlatformWebhook(options = {}) { if (!/^[0-9]{1,13}$/.test(timestamp)) { throw webhookError("Webhook authentication failed.", "WEBHOOK_TIMESTAMP_INVALID", 401); } + const toleranceSeconds = normalizeTimestampTolerance(options.timestampToleranceSeconds); const timestampSeconds = unixSeconds(Number(timestamp)); const nowSeconds = unixSeconds(resolveNow(options.now)); - if (Math.abs(nowSeconds - timestampSeconds) > MAX_TIMESTAMP_SKEW_SECONDS) { + const ageSeconds = nowSeconds - timestampSeconds; + if (ageSeconds > toleranceSeconds || -ageSeconds > Math.min(MAX_FUTURE_SKEW_SECONDS, toleranceSeconds)) { throw webhookError("Webhook authentication failed.", "WEBHOOK_TIMESTAMP_OUT_OF_RANGE", 401); } const payload = parsePlatformWebhook(rawBody); - const [insertionOrderQuery, sortedQuery] = canonicalWebhookQueries(payload); - const insertionExpected = signWebhookQuery(secret, timestamp, nonce, insertionOrderQuery); - const sortedExpected = signWebhookQuery(secret, timestamp, nonce, sortedQuery); const supplied = decodeSha256Signature(signature); - const matchesInsertionOrder = timingSafeEqual(insertionExpected, supplied); - const matchesSortedOrder = timingSafeEqual(sortedExpected, supplied); - if ((Number(matchesInsertionOrder) | Number(matchesSortedOrder)) === 0) { + let matched = 0; + for (const query of canonicalWebhookQueries(payload)) { + matched |= Number(timingSafeEqual(signWebhookQuery(secret, timestamp, nonce, query), supplied)); + } + if (matched === 0) { throw webhookError("Webhook authentication failed.", "WEBHOOK_SIGNATURE_INVALID", 401); } @@ -108,29 +126,81 @@ export async function verifyPlatformWebhook(options = {}) { 500, ); } - const reservation = typeof nonceStore.reserve === "function" - ? await nonceStore.reserve(nonce, timestampSeconds + MAX_TIMESTAMP_SKEW_SECONDS, nowSeconds) - : reserveSetLikeNonceStore(nonceStore, nonce); - if (!reservation) throw webhookError("Webhook delivery was already received.", "WEBHOOK_REPLAY", 409); - if (typeof reservation.commit !== "function" || typeof reservation.release !== "function") { - throw webhookError( - "Webhook replay protection returned an invalid reservation.", - "WEBHOOK_NONCE_STORE_INVALID", - 500, - ); + // The nonce must stay reserved for as long as its timestamp is acceptable. + const nonceReservation = await reserveKey(nonceStore, nonce, timestampSeconds + toleranceSeconds, nowSeconds); + if (!nonceReservation) { + throw duplicateError("Webhook delivery was already received.", "WEBHOOK_REPLAY"); } + // PixVerse recommends idempotency by payload `id`. A redelivery can carry a + // fresh nonce, so also deduplicate on (id, status); a later status for the + // same id is a distinct event and still delivered. + const eventKey = webhookEventKey(payload); + let eventReservation; + if (eventKey !== undefined) { + try { + eventReservation = await reserveKey(nonceStore, eventKey, nowSeconds + toleranceSeconds, nowSeconds); + } catch (error) { + await safeRelease(nonceReservation); + throw error; + } + if (!eventReservation) { + await safeRelease(nonceReservation); + throw duplicateError("Webhook event was already processed.", "WEBHOOK_DUPLICATE_EVENT"); + } + } + const reservations = eventReservation ? [nonceReservation, eventReservation] : [nonceReservation]; + return { timestamp, nonce, signature, traceId, payload, - commit: () => reservation.commit(), - release: () => reservation.release(), + commit: async () => { + const results = await Promise.all(reservations.map((reservation) => reservation.commit())); + return results.every((result) => result === true); + }, + release: async () => { + const results = await Promise.all(reservations.map((reservation) => reservation.release())); + return results.every((result) => result === true); + }, }; } +async function reserveKey(store, key, expiresAt, nowSeconds) { + const reservation = typeof store.reserve === "function" + ? await store.reserve(key, expiresAt, nowSeconds) + : reserveSetLikeNonceStore(store, key); + if (!reservation) return undefined; + if (typeof reservation.commit !== "function" || typeof reservation.release !== "function") { + throw webhookError( + "Webhook replay protection returned an invalid reservation.", + "WEBHOOK_NONCE_STORE_INVALID", + 500, + ); + } + return reservation; +} + +async function safeRelease(reservation) { + try { + await reservation.release(); + } catch { + // Never disclose store internals; the original outcome is reported. + } +} + +function webhookEventKey(payload) { + const { id } = payload; + if (!((typeof id === "string" && id !== "") || (typeof id === "number" && Number.isFinite(id)))) { + return undefined; + } + const status = payload.status; + const statusPart = status === undefined || status === null || typeof status === "object" ? "" : String(status); + return `${EVENT_KEY_PREFIX}${JSON.stringify([String(id), statusPart])}`; +} + export function parsePlatformWebhook(rawBody) { const normalized = normalizeRawBody(rawBody); let payload; @@ -154,6 +224,7 @@ export function createPlatformWebhookHandler(options = {}) { } const nonceStore = options.nonceStore ?? createMemoryNonceStore(); const commitTimeoutMs = normalizeCommitTimeout(options.commitTimeoutMs); + const timestampToleranceSeconds = normalizeTimestampTolerance(options.timestampToleranceSeconds); return async function platformWebhookHandler(request, response) { let verification; @@ -170,6 +241,7 @@ export function createPlatformWebhookHandler(options = {}) { secret: options.secret, now: options.now, nonceStore, + timestampToleranceSeconds, }); const context = { timestamp: verification.timestamp, @@ -184,6 +256,14 @@ export function createPlatformWebhookHandler(options = {}) { response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); response.end("ok"); } catch (error) { + if (error?.duplicate === true && !response.headersSent) { + // A verified redelivery of an already-processed event. PixVerse keeps + // resending until it receives exact `ok`, so acknowledge it without + // invoking onDelivery again. + response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); + response.end("ok"); + return; + } if (verification && !commitStarted) { try { await verification.release(); @@ -308,20 +388,42 @@ function decodeSha256Signature(value) { return decoded; } +// The official Node example serializes each value with String(value), so a +// null (for example "url": null on a failure delivery) signs as "null". The Go +// (`fmt %v` -> "") and Python (`str` -> "None") examples differ, so those +// spellings are also accepted for null values; every candidate is still a full +// HMAC comparison. Rejecting null would return non-ok and lose the delivery. +const NULL_SPELLINGS = ["null", "", "None"]; + function canonicalWebhookQueries(payload) { - const entries = Object.entries(payload).map(([key, value]) => { - if (value === null || typeof value === "object") { + let hasNull = false; + for (const value of Object.values(payload)) { + if (value === null) hasNull = true; + else if (typeof value === "object") { throw webhookError( "Webhook payload contains a value unsupported by the documented signature format.", "WEBHOOK_PAYLOAD_UNSUPPORTED", 400, ); } - return [key, String(value)]; - }); - const insertionOrder = new URLSearchParams(entries).toString(); - const sortedEntries = [...entries].sort(([left], [right]) => left.localeCompare(right)); - return [insertionOrder, new URLSearchParams(sortedEntries).toString()]; + } + const queries = []; + for (const nullSpelling of hasNull ? NULL_SPELLINGS : [NULL_SPELLINGS[0]]) { + const entries = Object.entries(payload).map(([key, value]) => [ + key, value === null ? nullSpelling : String(value), + ]); + // Code-point (byte) order, matching Go url.Values.Encode and Java TreeMap + // for the documented keys; localeCompare is locale- and case-folding-aware. + const sortedEntries = [...entries].sort(([left], [right]) => compareCodePoints(left, right)); + queries.push(new URLSearchParams(entries).toString(), new URLSearchParams(sortedEntries).toString()); + } + return queries; +} + +function compareCodePoints(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; } function signWebhookQuery(secret, timestamp, nonce, query) { @@ -330,19 +432,32 @@ function signWebhookQuery(secret, timestamp, nonce, query) { .digest(); } +const SET_LIKE_IN_FLIGHT = new WeakMap(); + function reserveSetLikeNonceStore(store, nonce) { - if (store.has(nonce)) return undefined; + let inFlight = SET_LIKE_IN_FLIGHT.get(store); + if (!inFlight) { + inFlight = new Set(); + SET_LIKE_IN_FLIGHT.set(store, inFlight); + } + if (store.has(nonce)) { + if (inFlight.has(nonce)) throw inProgressError(); + return undefined; + } store.add(nonce); + inFlight.add(nonce); let state = "reserved"; return Object.freeze({ commit() { if (state !== "reserved") return false; state = "committed"; + inFlight.delete(nonce); return true; }, release() { if (state !== "reserved") return false; state = "released"; + inFlight.delete(nonce); return store.delete(nonce); }, }); @@ -390,6 +505,18 @@ function positiveInteger(value, fallback) { return Number.isSafeInteger(value) && value > 0 ? value : fallback; } +function normalizeTimestampTolerance(value) { + if (value === undefined) return DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS; + if (!Number.isSafeInteger(value) || value < 1 || value > MAX_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS) { + throw webhookError( + "Invalid webhook timestamp tolerance.", + "WEBHOOK_CONFIGURATION_INVALID", + 500, + ); + } + return value; +} + function normalizeCommitTimeout(value) { if (value === undefined) return DEFAULT_COMMIT_TIMEOUT_MS; if (!Number.isSafeInteger(value) || value < 1 || value > MAX_COMMIT_TIMEOUT_MS) { @@ -410,6 +537,21 @@ function toPlainJsonValue(value) { return value; } +function duplicateError(message, code) { + // statusCode 200: the correct reply to a verified duplicate is exact `ok`. + const error = webhookError(message, code, 200); + error.duplicate = true; + return error; +} + +function inProgressError() { + return webhookError( + "Webhook delivery is already being processed.", + "WEBHOOK_DELIVERY_IN_PROGRESS", + 409, + ); +} + function webhookError(message, code, statusCode, cause) { const error = new PixverseCliError(message, { category: "webhook", diff --git a/test/catalog-completeness.test.js b/test/catalog-completeness.test.js index ba7d767..02d7c83 100644 --- a/test/catalog-completeness.test.js +++ b/test/catalog-completeness.test.js @@ -11,7 +11,11 @@ const ROOT = process.cwd(); const PLATFORM_SKILL = path.join(ROOT, ".agents/skills/pixverse-platform-api/SKILL.md"); const PLATFORM_OPERATION_REFERENCE = path.join(ROOT, ".agents/skills/pixverse-platform-api/references/operation-catalog.md"); const ROUTER_SKILL = path.join(ROOT, ".agents/skills/pixverse-api/SKILL.md"); -const MUSIC_MV_DOCUMENTATION_URL = "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc"; +// audio.verify has no public page yet; Music MV is documented as One-click VibeMV. +const MUSIC_MV_DOCUMENTATION_URLS = { + "audio.verify": "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc", + "agent.music-mv": "https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0", +}; const MUSIC_MV_OPERATION_IDS = new Set(["audio.verify", "agent.music-mv"]); test("Platform catalog, validators, fixtures, and help cover the independent inventory", async () => { @@ -33,7 +37,7 @@ test("Platform catalog, validators, fixtures, and help cover the independent inv const helpLine = help.split("\n").find((line) => line.includes(`pixverse-api platform ${operation.command.join(" ")}`)); assert.equal(helpLine.includes("[--no-wait]"), operation.billing === "billable"); if (MUSIC_MV_OPERATION_IDS.has(operation.id)) { - assert.equal(operation.documentationUrl, MUSIC_MV_DOCUMENTATION_URL); + assert.equal(operation.documentationUrl, MUSIC_MV_DOCUMENTATION_URLS[operation.id]); } else { assert.equal(new URL(operation.documentationUrl).hostname, "docs.platform.pixverse.ai"); } diff --git a/test/client.test.js b/test/client.test.js index fbef4ed..c93338c 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; import { GrowthStudioClient, @@ -6,6 +9,7 @@ import { validateCreatePayload, validateEditPayload, validateFolderPayload, + validateListVideosOptions, } from "../src/client.js"; import { GrowthStudioApiError, buildUrl, parseResponse } from "../src/http.js"; @@ -44,29 +48,54 @@ test("createProductUrlPayload can target a Growth Studio folder", () => { folderId: "630251570268735431", }); - assert.equal(payload.folder_id, "630251570268735431"); + assert.equal(payload.video.folder_id, "630251570268735431"); + assert.equal("folder_id" in payload, false); + assert.doesNotThrow(() => validateCreatePayload(payload)); }); -test("validateCreatePayload requires folder_id to stay a numeric string", () => { +function validVideo(overrides = {}) { + return { + aspect_ratio: "9:16", + duration_seconds: 30, + resolution: "1080p", + language: "en-US", + voiceover: true, + captions: true, + background_music: true, + avatar: { mode: "auto" }, + ...overrides, + }; +} + +const UPLOADED_IMAGE = "https://media.pixverse.ai/marketing_hub_website/openapi_video_inputs/uploads/22/11/product.webp"; + +test("validateCreatePayload keeps folder_id at video.folder_id as a numeric string", () => { assert.doesNotThrow(() => validateCreatePayload({ - folder_id: "630251570268735431", product: { source_url: "https://shop.example.com/products/running-shoes" }, - video: { avatar: { mode: "auto" } }, + video: validVideo({ folder_id: "630251570268735431" }), })); assert.throws(() => validateCreatePayload({ - folder_id: 630251570268735431, product: { source_url: "https://shop.example.com/products/running-shoes" }, - video: { avatar: { mode: "auto" } }, + video: validVideo({ folder_id: 630251570268735431 }), }), /folder_id must be a numeric string/); + assert.throws(() => validateCreatePayload({ + folder_id: "630251570268735431", + product: { source_url: "https://shop.example.com/products/running-shoes" }, + video: validVideo(), + }), /video\.folder_id/); }); test("validateCreatePayload accepts uploaded PixVerse image URLs", () => { assert.doesNotThrow(() => validateCreatePayload({ product: { title: "Shoes", - images: [{ url: "https://media.pixverse.ai/marketing_hub_website/openapi_video_inputs/uploads/22/11/product.webp" }], + description: "Lightweight running shoes.", + brand: "ACME", + price: { amount: "89.99", currency: "USD" }, + images: [{ url: UPLOADED_IMAGE }], }, - video: { avatar: { mode: "auto" } }, + video: validVideo({ resolution: "720P", avatar: { mode: "custom", url: "https://media.pixverse.ai/asset/avatar/urban.webp" } }), + metadata: { order_id: "order_123456" }, })); }); @@ -76,15 +105,89 @@ test("validateCreatePayload rejects arbitrary image URLs", () => { title: "Shoes", images: [{ url: "https://shop.example.com/product.webp" }], }, - video: { avatar: { mode: "auto" } }, + video: validVideo(), }), /Product images must use URLs returned by the image upload endpoint/); }); +test("validateCreatePayload enforces the documented product contract", () => { + const cases = [ + [{ source_url: "ftp://shop.example.com/item" }, /source_url must be an HTTP or HTTPS URL/], + [{ source_url: `https://shop.example.com/${"a".repeat(2048)}` }, /source_url must be .*2048/], + [{ images: [{ url: UPLOADED_IMAGE }] }, /product\.title is required/], + [{ title: "x".repeat(256), images: [{ url: UPLOADED_IMAGE }] }, /product\.title must be at most 255/], + [{ title: "Shoes" }, /product\.source_url or product\.images/], + [{ title: "Shoes", images: [] }, /at least one uploaded image/], + [{ title: "Shoes", images: Array.from({ length: 7 }, () => ({ url: UPLOADED_IMAGE })) }, /at most 6 images/], + [{ title: "Shoes", images: [{ url: UPLOADED_IMAGE }], description: "d".repeat(5121) }, /description must be at most 5120/], + [{ title: "Shoes", images: [{ url: UPLOADED_IMAGE }], brand: "b".repeat(256) }, /brand must be at most 255/], + [{ title: "Shoes", images: [{ url: UPLOADED_IMAGE }], price: { amount: 89.99, currency: "USD" } }, /price\.amount/], + [{ title: "Shoes", images: [{ url: UPLOADED_IMAGE }], price: { amount: "89.99" } }, /price\.currency/], + [{ title: "Shoes", images: [{ url: UPLOADED_IMAGE }], price: { amount: "89.99", currency: "US" } }, /price\.currency/], + ]; + for (const [product, message] of cases) { + assert.throws(() => validateCreatePayload({ product, video: validVideo() }), message); + } +}); + +test("validateCreatePayload enforces the documented video and metadata contract", () => { + const product = { source_url: "https://shop.example.com/products/running-shoes" }; + const cases = [ + [undefined, /requires video/], + [validVideo({ aspect_ratio: undefined }), /aspect_ratio is required/], + [validVideo({ duration_seconds: 20 }), /duration_seconds must be one of: 15, 30, 45, 60/], + [validVideo({ duration_seconds: "30" }), /duration_seconds/], + [validVideo({ resolution: "4k" }), /resolution must be one of/], + [validVideo({ language: "en" }), /language must be one of/], + [validVideo({ voiceover: "yes" }), /voiceover must be a boolean/], + [validVideo({ captions: undefined }), /captions must be a boolean/], + [validVideo({ background_music: 1 }), /background_music must be a boolean/], + [validVideo({ avatar: undefined }), /avatar must be an object/], + [validVideo({ avatar: { mode: "random" } }), /avatar\.mode must be one of/], + [validVideo({ avatar: { mode: "custom" } }), /avatar\.url is required/], + [validVideo({ avatar: { mode: "auto", url: "https://media.pixverse.ai/a.webp" } }), /avatar\.url must be omitted/], + [validVideo({ avatar: { mode: "disabled", url: "https://media.pixverse.ai/a.webp" } }), /avatar\.url must be omitted/], + [validVideo({ creative_ad_clone: 1 }), /creative_ad_clone must be a string/], + [validVideo({ subtitles_size_mode: true }), /subtitles_size_mode must be a string/], + ]; + for (const [video, message] of cases) { + assert.throws(() => validateCreatePayload({ product, video }), message); + } + assert.doesNotThrow(() => validateCreatePayload({ + product, + video: validVideo({ duration_seconds: 15, avatar: { mode: "disabled" }, creative_ad_clone: "Hook first.", subtitles_size_mode: "large" }), + })); + assert.throws( + () => validateCreatePayload({ product, video: validVideo(), metadata: { order: 1 } }), + /metadata\.order must be a string/, + ); + assert.throws( + () => validateCreatePayload({ product, video: validVideo(), metadata: ["x"] }), + /metadata must be an object/, + ); +}); + +test("validateListVideosOptions enforces documented limit and status values", () => { + assert.doesNotThrow(() => validateListVideosOptions({ limit: 1, status: "canceled" })); + assert.doesNotThrow(() => validateListVideosOptions({ limit: 100 })); + assert.throws(() => validateListVideosOptions({ limit: 0 }), /limit must be an integer from 1 through 100/); + assert.throws(() => validateListVideosOptions({ limit: 101 }), /limit/); + assert.throws(() => validateListVideosOptions({ limit: "20" }), /limit/); + assert.throws(() => validateListVideosOptions({ status: "done" }), /status must be one of/); +}); + test("validateEditPayload requires one-based clip index", () => { assert.throws(() => validateEditPayload({ clip_index: 0, instruction: "Try again." }), /one-based/); assert.doesNotThrow(() => validateEditPayload({ clip_index: 1, instruction: "Try again." })); }); +test("validateEditPayload enforces clip_index 1-40 and a 500-character instruction", () => { + assert.doesNotThrow(() => validateEditPayload({ clip_index: 40, instruction: "x".repeat(500) })); + assert.throws(() => validateEditPayload({ clip_index: 41, instruction: "Try again." }), /1 through 40/); + assert.throws(() => validateEditPayload({ clip_index: 1.5, instruction: "Try again." }), /1 through 40/); + assert.throws(() => validateEditPayload({ clip_index: 2, instruction: " " }), /instruction is required/); + assert.throws(() => validateEditPayload({ clip_index: 2, instruction: "x".repeat(501) }), /at most 500/); +}); + test("parseResponse throws structured API errors", async () => { const response = new Response(JSON.stringify({ error: { @@ -147,12 +250,12 @@ test("client keeps video IDs as strings and sends bearer auth", async () => { assert.equal(calls[0].init.headers.get("Authorization"), "Bearer test-api-key"); }); -test("client lists and creates folders through Growth Studio folder endpoints", async () => { +test("client lists and creates folders through the documented /openapi/v1/folders endpoints", async () => { const calls = []; const credentials = { ["api" + "Key"]: "test-api-key" }; const responses = [ - new Response(JSON.stringify({ ErrCode: 0, Resp: { folders: [{ folder_id: "636771263750078906", name: "REVOLVE" }] } }), { status: 200 }), - new Response(JSON.stringify({ ErrCode: 0, Resp: { folder_id: "636771263750078906" } }), { status: 200 }), + new Response('{"folders":[{"folder_id":636771263750078906,"name":"REVOLVE"}],"request_id":"req-list"}', { status: 200 }), + new Response(JSON.stringify({ folder_id: "636771263750078907", request_id: "req-create" }), { status: 201 }), ]; const client = new GrowthStudioClient({ ...credentials, @@ -164,59 +267,206 @@ test("client lists and creates folders through Growth Studio folder endpoints", }); const listResult = await client.listFolders(); - const createResult = await client.createFolder({ name: "REVOLVE" }); + const createResult = await client.createFolder({ name: "September campaign" }); assert.deepEqual(listResult.body.folders, [{ folder_id: "636771263750078906", name: "REVOLVE" }]); - assert.deepEqual(createResult.body, { folder_id: "636771263750078906" }); - assert.equal(calls[0].url, "https://growth-api.pixverse.ai/marketing_hub/folder/list"); - assert.equal(calls[1].url, "https://growth-api.pixverse.ai/marketing_hub/folder/create"); + assert.deepEqual(createResult.body, { folder_id: "636771263750078907", request_id: "req-create" }); + assert.equal(createResult.requestId, "req-create"); + assert.equal(calls[0].url, "https://growth-api.pixverse.ai/openapi/v1/folders"); + assert.equal(calls[0].init.method, "GET"); + assert.equal(calls[1].url, "https://growth-api.pixverse.ai/openapi/v1/folders"); + assert.equal(calls[1].init.method, "POST"); + assert.equal(calls[0].init.headers.get("Authorization"), "Bearer test-api-key"); assert.equal(calls[1].init.headers.get("Authorization"), "Bearer test-api-key"); - assert.equal(calls[1].init.body, JSON.stringify({ name: "REVOLVE" })); + assert.equal(calls[1].init.body, JSON.stringify({ name: "September campaign" })); }); -test("client can point folder commands at a configured Growth Studio API prefix", async () => { +test("folder errors use the standard Growth Studio error body", async () => { + const client = new GrowthStudioClient({ + ["api" + "Key"]: "test-api-key", + baseUrl: "https://growth-api.pixverse.ai", + fetchImpl: async () => new Response(JSON.stringify({ + error: { code: "FOLDER_LIMIT_EXCEEDED", message: "Folder limit reached.", retryable: false }, + request_id: "req-limit", + }), { status: 409 }), + }); + + await assert.rejects(client.createFolder({ name: "One more" }), (error) => { + assert.ok(error instanceof GrowthStudioApiError); + assert.equal(error.status, 409); + assert.equal(error.code, "FOLDER_LIMIT_EXCEEDED"); + assert.equal(error.requestId, "req-limit"); + return true; + }); +}); + +test("parseResponse keeps 64-bit integer identifiers lossless as strings", async () => { + const result = await parseResponse(new Response( + '{"video_id":627410861853514292,"price":1299,"data":[{"avatar_id":123456789012345678}]}', + { status: 200 }, + )); + + assert.equal(result.body.video_id, "627410861853514292"); + assert.equal(result.body.price, 1299); + assert.equal(result.body.data[0].avatar_id, "123456789012345678"); + assert.equal(Object.getPrototypeOf(result.body), Object.prototype); +}); + +test("parseResponse defaults retryable by status when the error body is missing", async () => { + const expectations = [[429, true], [503, true], [500, false], [400, false]]; + for (const [status, retryable] of expectations) { + await assert.rejects(parseResponse(new Response("", { + status, + headers: { "retry-after": "12", "x-request-id": `req-${status}` }, + })), (error) => { + assert.equal(error.status, status); + assert.equal(error.retryable, retryable); + assert.equal(error.retryAfter, 12); + assert.equal(error.requestId, `req-${status}`); + return true; + }); + } + await assert.rejects(parseResponse(new Response(JSON.stringify({ + error: { code: "INTERNAL_ERROR", message: "boom", retryable: true }, + }), { status: 500 })), (error) => error.retryable === true && error.code === "INTERNAL_ERROR"); +}); + +test("GET requests retry 429 and retryable 5xx honoring Retry-After seconds and HTTP dates", async () => { + const waits = []; const calls = []; + const nowMs = Date.parse("2026-10-10T00:00:00Z"); + const responses = [ + new Response("", { status: 429, headers: { "retry-after": "3" } }), + new Response("", { status: 503, headers: { "retry-after": new Date(nowMs + 9_000).toUTCString() } }), + new Response(JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "retry", retryable: true } }), { status: 500 }), + new Response(JSON.stringify({ data: [], pagination: { has_more: false, next_cursor: "" } }), { status: 200 }), + ]; + const realNow = Date.now; + Date.now = () => nowMs; + try { + const client = new GrowthStudioClient({ + ["api" + "Key"]: "test-api-key", + baseUrl: "https://growth-api.pixverse.ai", + fetchImpl: async (url, init) => { + calls.push({ url: url.toString(), method: init.method }); + return responses.shift(); + }, + sleep: async (ms) => waits.push(ms), + }); + + const result = await client.listVideos({ limit: 20, status: "succeeded" }); + + assert.deepEqual(result.body.data, []); + } finally { + Date.now = realNow; + } + assert.equal(calls.length, 4); + assert.equal(calls.every(({ method }) => method === "GET"), true); + assert.deepEqual(waits, [3000, 9000, 4000]); +}); + +test("GET retries use capped exponential backoff and stop after the attempt budget", async () => { + const waits = []; + let calls = 0; const client = new GrowthStudioClient({ ["api" + "Key"]: "test-api-key", - folderApiPrefix: "/openapi/v1/growth-studio", baseUrl: "https://growth-api.pixverse.ai", - fetchImpl: async (url, init) => { - calls.push({ url: url.toString(), init }); - return new Response(JSON.stringify({ folders: [] }), { status: 200 }); + readRetry: { maxAttempts: 5, baseDelayMs: 1_000, maxDelayMs: 3_000 }, + fetchImpl: async () => { + calls += 1; + return new Response("", { status: 503 }); }, + sleep: async (ms) => waits.push(ms), }); - await client.listFolders(); + await assert.rejects(client.listAvatars(), (error) => error.status === 503); + assert.equal(calls, 5); + assert.deepEqual(waits, [1000, 2000, 3000, 3000]); +}); - assert.equal(calls[0].url, "https://growth-api.pixverse.ai/openapi/v1/growth-studio/folder/list"); +test("GET requests do not retry non-retryable errors", async () => { + let calls = 0; + const client = new GrowthStudioClient({ + ["api" + "Key"]: "test-api-key", + baseUrl: "https://growth-api.pixverse.ai", + fetchImpl: async () => { + calls += 1; + return new Response(JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "no", retryable: false } }), { status: 500 }); + }, + sleep: async () => {}, + }); + + await assert.rejects(client.getVideo("627410861853514292"), /no/); + assert.equal(calls, 1); }); -test("client can use a separate folder API token for folder endpoints", async () => { +test("POST create, edit, upload, and folder create are never retried", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "pixverse-upload-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const imagePath = path.join(root, "product.png"); + await fs.writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); const calls = []; const client = new GrowthStudioClient({ - ["api" + "Key"]: "video-api-key", - folderApiKey: "folder-api-key", + ["api" + "Key"]: "test-api-key", baseUrl: "https://growth-api.pixverse.ai", fetchImpl: async (url, init) => { - calls.push({ url: url.toString(), init }); - return new Response(JSON.stringify({ ErrCode: 0, Resp: { folders: [] } }), { status: 200 }); + calls.push(`${init.method} ${new URL(url).pathname}`); + return new Response("", { status: calls.length % 2 ? 429 : 503, headers: { "retry-after": "1" } }); + }, + sleep: async () => { + throw new Error("POST requests must not sleep for a retry"); }, }); - await client.listFolders(); - - assert.equal(calls[0].init.headers.get("Authorization"), "Bearer folder-api-key"); + await assert.rejects(client.createVideo({ + product: { source_url: "https://shop.example.com/item" }, + video: validVideo(), + }), (error) => error.status === 429); + await assert.rejects(client.editVideo("627410861853514292", { clip_index: 2, instruction: "Smile." }), (error) => error.status === 503); + await assert.rejects(client.uploadImage(imagePath), (error) => error.status === 429); + await assert.rejects(client.createFolder({ name: "ACME" }), (error) => error.status === 503); + + assert.deepEqual(calls, [ + "POST /openapi/v1/videos", + "POST /openapi/v1/videos/627410861853514292/edit", + "POST /openapi/v1/image/upload", + "POST /openapi/v1/folders", + ]); }); -test("client rejects Marketing Hub folder envelope errors", async () => { - const credentials = { ["api" + "Key"]: "test-api-key" }; +test("uploadImage validates extension and size locally and sets the blob type", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "pixverse-upload-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const calls = []; const client = new GrowthStudioClient({ - ...credentials, + ["api" + "Key"]: "test-api-key", baseUrl: "https://growth-api.pixverse.ai", - fetchImpl: async () => new Response(JSON.stringify({ ErrCode: 10001, ErrMsg: "Token is invalid", Resp: {} }), { status: 200 }), + fetchImpl: async (url, init) => { + calls.push({ url: url.toString(), init }); + return new Response(JSON.stringify({ url: "https://media.pixverse.ai/uploads/x.webp", request_id: "req-up" }), { status: 200 }); + }, }); - await assert.rejects(client.listFolders(), /Token is invalid/); + const gifPath = path.join(root, "product.gif"); + await fs.writeFile(gifPath, "GIF89a"); + await assert.rejects(client.uploadImage(gifPath), /\.jpg, \.jpeg, \.png, or \.webp/); + + const largePath = path.join(root, "large.jpg"); + const handle = await fs.open(largePath, "w"); + await handle.truncate(20 * 1024 * 1024 + 1); + await handle.close(); + await assert.rejects(client.uploadImage(largePath), /20 MiB/); + assert.equal(calls.length, 0); + + for (const [name, type] of [["a.JPG", "image/jpeg"], ["b.jpeg", "image/jpeg"], ["c.png", "image/png"], ["d.webp", "image/webp"]]) { + const filePath = path.join(root, name); + await fs.writeFile(filePath, "fixture"); + await client.uploadImage(filePath); + const image = calls.at(-1).init.body.get("image"); + assert.equal(image.type, type); + assert.equal(image.name, name); + } + assert.equal(new URL(calls[0].url).pathname, "/openapi/v1/image/upload"); }); test("validateFolderPayload requires a folder name", () => { @@ -224,25 +474,33 @@ test("validateFolderPayload requires a folder name", () => { assert.throws(() => validateFolderPayload({ name: "" }), /non-empty name/); }); -test("pollVideo honors Retry-After and stops on succeeded", async () => { - const waits = []; - const snapshots = []; - const credentials = { ["api" + "Key"]: "test-api-key" }; - const responses = [ - new Response(JSON.stringify({ video_id: "627410861853514292", status: "processing" }), { - status: 200, - headers: { "retry-after": "7" }, - }), - new Response(JSON.stringify({ video_id: "627410861853514292", status: "succeeded", output: { video_url: "https://media.pixverse.ai/videos/result.mp4" } }), { - status: 200, - }), - ]; +function pollingClient(responses, { waits, now } = {}) { + const calls = []; const client = new GrowthStudioClient({ - ...credentials, + ["api" + "Key"]: "test-api-key", baseUrl: "https://growth-api.pixverse.ai", - fetchImpl: async () => responses.shift(), - sleep: async (ms) => waits.push(ms), + fetchImpl: async (url, init) => { + calls.push(`${init.method} ${new URL(url).pathname}`); + const next = responses.shift(); + return typeof next === "function" ? next() : next; + }, + sleep: async (ms) => waits?.push(ms), + ...(now ? { now } : {}), }); + return { client, calls }; +} + +function videoResponse(body, headers = {}) { + return new Response(JSON.stringify({ video_id: "627410861853514292", ...body }), { status: 200, headers }); +} + +test("pollVideo honors Retry-After, enforces the five-second first wait, and stops on succeeded", async () => { + const waits = []; + const snapshots = []; + const { client } = pollingClient([ + videoResponse({ status: "processing" }, { "retry-after": "7" }), + videoResponse({ status: "succeeded", output: { video_url: "https://media.pixverse.ai/videos/result.mp4" } }), + ], { waits }); const result = await client.pollVideo("627410861853514292", { initialDelaySeconds: 0, @@ -250,6 +508,69 @@ test("pollVideo honors Retry-After and stops on succeeded", async () => { }); assert.equal(result.status, "succeeded"); - assert.deepEqual(waits, [7000]); + assert.deepEqual(waits, [5000, 7000]); assert.deepEqual(snapshots, ["processing", "succeeded"]); }); + +test("pollVideo clamps the fallback delay to at least one second", async () => { + const waits = []; + const { client } = pollingClient([ + videoResponse({ status: "queued" }), + videoResponse({ status: "succeeded" }), + ], { waits }); + + await client.pollVideo("627410861853514292", { initialDelaySeconds: 2, fallbackDelaySeconds: 0 }); + + assert.deepEqual(waits, [5000, 1000]); +}); + +test("pollVideo keeps polling through 429 and retryable 5xx responses within its deadline", async () => { + const waits = []; + const { client, calls } = pollingClient([ + new Response("", { status: 429, headers: { "retry-after": "30" } }), + new Response("", { status: 503, headers: { "retry-after": "2" } }), + new Response("", { status: 429, headers: { "retry-after": "4" } }), + new Response("", { status: 503 }), + new Response("", { status: 503, headers: { "retry-after": "6" } }), + videoResponse({ status: "succeeded" }), + ], { waits }); + client.readRetry = { ...client.readRetry, maxAttempts: 2 }; + + const result = await client.pollVideo("627410861853514292", { fallbackDelaySeconds: 3 }); + + assert.equal(result.status, "succeeded"); + assert.equal(calls.length, 6); + // First wait, in-request retry (30s), poll-level Retry-After after exhausted retries (2s), + // in-request retry (4s), poll-level fallback with no Retry-After (3s), in-request retry (6s). + assert.deepEqual(waits, [5000, 30000, 2000, 4000, 3000, 6000]); +}); + +test("pollVideo stops on retryable errors once the deadline passes", async () => { + let nowMs = 0; + const { client, calls } = pollingClient([ + () => { + nowMs = 11_000; + return new Response("", { status: 503 }); + }, + ], { now: () => nowMs }); + client.readRetry = { ...client.readRetry, maxAttempts: 1 }; + + await assert.rejects( + client.pollVideo("627410861853514292", { timeoutMs: 10_000 }), + (error) => error.status === 503, + ); + assert.equal(calls.length, 1); +}); + +test("pollVideo prefers edit.status over edit.latest.status", async () => { + const waits = []; + const { client, calls } = pollingClient([ + videoResponse({ status: "succeeded", edit: { status: "processing", latest: { status: "succeeded" } } }, { "retry-after": "5" }), + videoResponse({ status: "succeeded", edit: { status: "succeeded", latest: { status: "processing" } } }), + ], { waits }); + + const result = await client.pollVideo("627410861853514292"); + + assert.equal(result.edit.status, "succeeded"); + assert.equal(calls.length, 2); +}); diff --git a/test/contract/growth-studio-client.contract.test.js b/test/contract/growth-studio-client.contract.test.js index 0bb5661..acd0e3e 100644 --- a/test/contract/growth-studio-client.contract.test.js +++ b/test/contract/growth-studio-client.contract.test.js @@ -9,42 +9,60 @@ import { } from "../../src/growth-studio/client.js"; import { createRecordingFetch, describeRecordedBody, jsonResponse } from "../helpers/recording-fetch.js"; +const VIDEO = Object.freeze({ + aspect_ratio: "9:16", + duration_seconds: 30, + resolution: "1080p", + language: "en-US", + voiceover: true, + captions: true, + background_music: true, + avatar: Object.freeze({ mode: "auto" }), +}); + +// describeRecordedBody parses losslessly into null-prototype records; compare as plain JSON. +async function recordedJson(body) { + return JSON.parse(JSON.stringify(await describeRecordedBody(body), (_key, value) => ( + typeof value === "bigint" ? value.toString() : value + ))); +} + test("Growth Studio client uses bearer auth only and never sends Platform headers", async () => { const { calls, fetchImpl } = createRecordingFetch([ jsonResponse({ video_id: "627410861853514292", status: "processing" }), - jsonResponse({ ErrCode: 0, Resp: { folders: [] } }), - jsonResponse({ ErrCode: 0, Resp: { folder_id: "630251570268735431" } }), + jsonResponse({ folders: [], request_id: "folder-list-request" }), + jsonResponse({ folder_id: "630251570268735431", request_id: "folder-create-request" }, { status: 201 }), ]); const client = new GrowthStudioClient({ ["api" + "Key"]: "growth-fixture-key", - ["folderApi" + "Key"]: "folder-fixture-key", baseUrl: "https://growth.example.test", fetchImpl, }); await client.createVideo({ product: { source_url: "https://shop.example.test/item" }, - video: { avatar: { mode: "auto" } }, + video: { ...VIDEO, folder_id: "630251570268735431" }, }, { traceId: "create-trace" }); await client.listFolders({ traceId: "folder-trace" }); await client.createFolder({ name: "ACME" }, { traceId: "create-folder-trace" }); assert.equal(calls[0].headers.get("Authorization"), "Bearer growth-fixture-key"); - assert.equal(calls[1].headers.get("Authorization"), "Bearer folder-fixture-key"); - assert.equal(calls[2].headers.get("Authorization"), "Bearer folder-fixture-key"); + assert.equal(calls[1].headers.get("Authorization"), "Bearer growth-fixture-key"); + assert.equal(calls[2].headers.get("Authorization"), "Bearer growth-fixture-key"); assert.equal(calls.every(({ headers }) => !headers.has("API-KEY")), true); assert.equal(calls.every(({ headers }) => headers.has("Ai-Trace-Id")), true); assert.deepEqual(calls.map(({ method }) => method), ["POST", "GET", "POST"]); assert.deepEqual(calls.map(({ url }) => new URL(url).pathname), [ "/openapi/v1/videos", - "/marketing_hub/folder/list", - "/marketing_hub/folder/create", + "/openapi/v1/folders", + "/openapi/v1/folders", ]); - assert.deepEqual(await describeRecordedBody(calls[0].body), { + assert.deepEqual(await recordedJson(calls[0].body), { product: { source_url: "https://shop.example.test/item" }, - video: { avatar: { mode: "auto" } }, + video: { ...VIDEO, avatar: { mode: "auto" }, folder_id: "630251570268735431" }, }); - assert.deepEqual(await describeRecordedBody(calls[2].body), { name: "ACME" }); + assert.equal(calls[1].body, undefined); + assert.deepEqual(await recordedJson(calls[2].body), { name: "ACME" }); }); test("Growth Studio client preserves string identifiers through create and poll", async () => { @@ -61,7 +79,7 @@ test("Growth Studio client preserves string identifiers through create and poll" const created = await client.createVideo({ product: { source_url: "https://shop.example.test/item" }, - video: { avatar: { mode: "auto" } }, + video: VIDEO, }); const final = await client.pollVideo(created.body.video_id, { initialDelaySeconds: 0 }); @@ -106,7 +124,7 @@ test("Growth Studio PDP uses bearer auth and the exact PDP wire contract", async assert.equal(calls[0].headers.get("Authorization"), "Bearer growth-fixture-key"); assert.equal(calls[0].headers.get("Ai-Trace-Id"), "pdp-create-fixture"); assert.equal(calls[0].headers.has("API-KEY"), false); - assert.deepEqual(await describeRecordedBody(calls[0].body), { + assert.deepEqual(await recordedJson(calls[0].body), { product: { title: "Linen Summer Shirt", images: [{ url: "https://media.pixverse.ai/example/front.webp" }], @@ -305,3 +323,59 @@ test("Growth Studio successful responses reject malformed JSON as a protocol err }, ); }); + +test("Growth Studio create sends folder_id inside video and the edit body unchanged", async () => { + const { calls, fetchImpl } = createRecordingFetch([ + jsonResponse({ video_id: "627410861853514292", status: "processing" }, { status: 202 }), + jsonResponse({ video_id: "627410861853514292", edit: { status: "processing" } }, { status: 202 }), + ]); + const client = new GrowthStudioClient({ + ["api" + "Key"]: "growth-fixture-key", + baseUrl: "https://growth.example.test", + fetchImpl, + }); + + await client.createVideo({ + product: { source_url: "https://shop.example.test/item" }, + video: { ...VIDEO, folder_id: "630251570268735431" }, + }); + await client.editVideo("627410861853514292", { clip_index: 2, instruction: "Make the expression more natural." }); + + const created = await recordedJson(calls[0].body); + assert.equal(created.folder_id, undefined); + assert.equal(created.video.folder_id, "630251570268735431"); + assert.deepEqual(calls.map(({ method, url }) => `${method} ${new URL(url).pathname}`), [ + "POST /openapi/v1/videos", + "POST /openapi/v1/videos/627410861853514292/edit", + ]); + assert.deepEqual(await recordedJson(calls[1].body), { + clip_index: 2, + instruction: "Make the expression more natural.", + }); +}); + +test("Growth Studio read requests retry 429 and 503 while writes stay single-attempt", async () => { + const waits = []; + const { calls, fetchImpl } = createRecordingFetch([ + jsonResponse({ error: { code: "RATE_LIMIT_EXCEEDED", message: "slow down", retryable: true } }, { + status: 429, + headers: { "retry-after": "30" }, + }), + jsonResponse({ error: { code: "SERVICE_UNAVAILABLE", message: "later", retryable: true } }, { status: 503 }), + jsonResponse({ folders: [{ folder_id: "630251570268735431", name: "ACME" }] }), + jsonResponse({ error: { code: "SERVICE_UNAVAILABLE", message: "later", retryable: true } }, { status: 503 }), + ]); + const client = new GrowthStudioClient({ + ["api" + "Key"]: "growth-fixture-key", + baseUrl: "https://growth.example.test", + fetchImpl, + sleep: async (ms) => waits.push(ms), + }); + + const folders = await client.listFolders(); + await assert.rejects(client.createFolder({ name: "ACME" }), (error) => error.status === 503); + + assert.equal(folders.body.folders[0].folder_id, "630251570268735431"); + assert.deepEqual(calls.map(({ method }) => method), ["GET", "GET", "GET", "POST"]); + assert.deepEqual(waits, [30_000, 2_000]); +}); diff --git a/test/contract/platform-client.contract.test.js b/test/contract/platform-client.contract.test.js index fe7bbbf..5849155 100644 --- a/test/contract/platform-client.contract.test.js +++ b/test/contract/platform-client.contract.test.js @@ -3,13 +3,14 @@ import fs from "node:fs/promises"; import test from "node:test"; import { fileURLToPath } from "node:url"; + import { PlatformClient, normalizePlatformResponseIdentifiers, } from "../../src/platform/client.js"; import { PLATFORM_OPERATIONS } from "../../src/platform/operations.js"; import { buildPlatformRequest } from "../../src/platform/request.js"; -import { createRecordingFetch, describeRecordedBody, jsonResponse } from "../helpers/recording-fetch.js"; +import { createRecordingFetch, describeRecordedBody, jsonResponse, parseLosslessJson } from "../helpers/recording-fetch.js"; const FIXTURES = new URL("../fixtures/platform/", import.meta.url); const TEST_CREDENTIAL = ["fixture", "credential"].join("-"); @@ -185,7 +186,7 @@ test("GET and HEAD read requests retry transient failures, while every non-read- assert.equal(writeRecorder.calls.length, 1); }); -test("request building preserves repeated query values and serializes local multipart files without a content type", async () => { +test("request building preserves repeated query values and types local multipart files by extension", async () => { const queryRequest = await buildPlatformRequest({ id: "fixture.query", method: "GET", path: "/items", bodyMode: "none", }, { @@ -208,8 +209,19 @@ test("request building preserves repeated query values and serializes local mult assert.equal(multipartRequest.headers.has("Content-Type"), false); assert.deepEqual(await describeRecordedBody(multipartRequest.body), { metadata: '{"role":"cover"}', - image: { name: "tiny.png", size: 90, type: "" }, + image: { name: "tiny.png", size: 90, type: "image/png" }, + }); + + // The template list documents template_ids as a single comma-separated string. + const templatesRequest = await buildPlatformRequest({ + id: "resource.templates", method: "GET", path: "/templates", bodyMode: "none", + }, { + payload: { template_ids: ["402331965264704", "402331965264705"] }, + query: {}, + pathParams: {}, + files: {}, }); + assert.equal(templatesRequest.path, "/templates?template_ids=402331965264704%2C402331965264705"); }); test("Music MV uint64 identifiers stay strings internally but serialize as lossless JSON numbers", async () => { @@ -263,15 +275,25 @@ test("Music MV uint64 identifiers stay strings internally but serialize as lossl assert.equal(fusionRequest.body, '{"model":"v6","image_references":[{"img_id":892421142,"ref_name":"front"},{"img_id":892421144,"ref_name":"side"}],"prompt":"@front @side walk.","duration":15,"quality":"1080p","aspect_ratio":"9:16","generate_audio_switch":true}'); assert.equal(fusionInput.payload.image_references[0].img_id, "892421142"); - const unrelatedRequest = await buildPlatformRequest({ - id: "video.avatar", method: "POST", path: "/avatar", bodyMode: "json", + // Fields the reference types as string stay strings even next to integer IDs. + const modifyRequest = await buildPlatformRequest({ + id: "video.modify", method: "POST", path: "/modify", bodyMode: "json", }, { - payload: { audio_media_id: "18446744073709551615" }, + payload: { video_media_id: "18446744073709551615", mask_ids: ["7"], img_ids: ["8"], keyframe_id: "1" }, query: {}, pathParams: {}, files: {}, }); - assert.equal(unrelatedRequest.body, '{"audio_media_id":"18446744073709551615"}'); + assert.equal(modifyRequest.body, '{"video_media_id":18446744073709551615,"mask_ids":["7"],"img_ids":[8],"keyframe_id":1}'); + + await assert.rejects(buildPlatformRequest({ + id: "video.image", method: "POST", path: "/img", bodyMode: "json", + }, { + payload: { img_id: "not-a-number" }, + query: {}, + pathParams: {}, + files: {}, + }), { code: "INVALID_PLATFORM_REQUEST", message: /img_id must be a non-negative integer ID/ }); }); test("upscale media IDs stay strings internally but serialize as lossless JSON numbers", async () => { @@ -316,7 +338,7 @@ test("client and request boundaries reject malformed construction inputs", async const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; async function fixture(operationId, name) { - return JSON.parse(await fs.readFile(new URL(`${operationId}/${name}.json`, FIXTURES), "utf8")); + return parseLosslessJson(await fs.readFile(new URL(`${operationId}/${name}.json`, FIXTURES), "utf8")); } function deepFreeze(value) { diff --git a/test/e2e/growth-studio-namespaced.e2e.test.js b/test/e2e/growth-studio-namespaced.e2e.test.js index 396e851..c5f4a85 100644 --- a/test/e2e/growth-studio-namespaced.e2e.test.js +++ b/test/e2e/growth-studio-namespaced.e2e.test.js @@ -13,23 +13,33 @@ test("executable Growth Studio namespace resolves folders and completes create t const jobsDir = path.join(tempRoot, "jobs"); await fs.writeFile(payloadPath, JSON.stringify({ product: { source_url: "https://product.example.invalid/item" }, - video: { avatar: { mode: "auto" } }, + video: { + aspect_ratio: "9:16", + duration_seconds: 30, + resolution: "1080p", + language: "en-US", + voiceover: true, + captions: true, + background_music: true, + avatar: { mode: "auto" }, + }, })); const server = await createMockApiServer((request, response) => { assert.equal(request.headers.authorization, "Bearer mh_live_fixture"); assert.equal(request.headers["api-key"], undefined); - if (request.method === "GET" && request.url === "/marketing_hub/folder/list") { - return sendJson(response, { ErrCode: 0, Resp: { folders: [] } }); + if (request.method === "GET" && request.url === "/openapi/v1/folders") { + return sendJson(response, { folders: [{ folder_id: "630251570268735430", name: "acme" }], request_id: "req-list" }); } - if (request.method === "POST" && request.url === "/marketing_hub/folder/create") { + if (request.method === "POST" && request.url === "/openapi/v1/folders") { assert.deepEqual(JSON.parse(request.body.toString()), { name: "ACME" }); - return sendJson(response, { ErrCode: 0, Resp: { folder_id: "630251570268735431" } }); + return sendJson(response, { folder_id: "630251570268735431", request_id: "req-create" }, 201); } if (request.method === "POST" && request.url === "/openapi/v1/videos") { const payload = JSON.parse(request.body.toString()); - assert.equal(payload.folder_id, "630251570268735431"); + assert.equal(payload.folder_id, undefined); + assert.equal(payload.video.folder_id, "630251570268735431"); return sendJson(response, { video_id: "627410861853514292", status: "processing" }); } if (request.method === "GET" && request.url === "/openapi/v1/videos/627410861853514292") { @@ -74,8 +84,8 @@ test("executable Growth Studio namespace resolves folders and completes create t assert.equal(output.status, "succeeded"); assert.equal(output.folder_id, "630251570268735431"); assert.deepEqual(server.requests.map(({ method, url }) => `${method} ${url}`), [ - "GET /marketing_hub/folder/list", - "POST /marketing_hub/folder/create", + "GET /openapi/v1/folders", + "POST /openapi/v1/folders", "POST /openapi/v1/videos", "GET /openapi/v1/videos/627410861853514292", ]); diff --git a/test/e2e/platform-recovery.e2e.test.js b/test/e2e/platform-recovery.e2e.test.js index 23fe035..199e55d 100644 --- a/test/e2e/platform-recovery.e2e.test.js +++ b/test/e2e/platform-recovery.e2e.test.js @@ -5,7 +5,7 @@ import test from "node:test"; import { PlatformClient } from "../../src/platform/client.js"; import { resumePlatformJob, submitPlatformJob } from "../../src/platform/jobs.js"; -import { createMockApiServer } from "../helpers/mock-api-server.js"; +import { createMockApiServer, sendJson } from "../helpers/mock-api-server.js"; import { createTempJobRoot, readJobArtifacts } from "../helpers/temp-job-dir.js"; test("ambiguous agent acceptance is resumable without a second billable POST", async (t) => { @@ -23,8 +23,8 @@ test("ambiguous agent acceptance is resumable without a second billable POST", a agent_id: "414562414124109", prompt: "Recreate pacing.", quality: "720p", - img_references: [{ img_url: "https://cdn.example.test/a.png" }], - video_references: [{ video_url: "https://cdn.example.test/a.mp4" }], + img_references: [{ img_id: "1" }], + video_references: [{ video_media_id: "2" }], }, { jobRoot: root, traceIdFactory: () => "11111111-1111-4111-8111-111111111111" }), (error) => error.category === "transport"); const [jobName] = await fs.readdir(root); @@ -92,3 +92,50 @@ test("unknown status is preserved until timeout with final and error artifacts", assert.equal(recoveredArtifacts["final-timeout-1.json"].status, "unknown"); assert.equal(recoveredArtifacts["final.json"].status, "succeeded"); }); + +test("over real HTTP, transient status failures keep polling with fresh traces and one billable POST", async (t) => { + const root = await createTempJobRoot(t); + const savedTrace = "11111111-1111-4111-8111-111111111111"; + let statusReads = 0; + const server = await createMockApiServer((request, response) => { + if (request.method === "POST") { + sendJson(response, { ErrCode: 0, ErrMsg: "success", Resp: { video_id: 627410861853514292 } }); + return; + } + statusReads += 1; + // Each status call exhausts the client's 3 in-call GET retries first. + if (statusReads <= 3) { + sendJson(response, { error: { message: "busy" } }, 503); + return; + } + if (statusReads === 4) { + sendJson(response, { ErrCode: 500069, ErrMsg: "System is currently experiencing high load.", Resp: null }); + return; + } + sendJson(response, { ErrCode: 0, ErrMsg: "success", Resp: { id: 627410861853514292, status: 1, url: "https://cdn.example.test/v.mp4" } }); + }); + t.after(() => server.close()); + const client = new PlatformClient({ + apiKey: "test-key", baseUrl: server.baseUrl, fetchImpl: globalThis.fetch, sleep: async () => {}, + }); + const final = await submitPlatformJob(client, "video.text", { + prompt: "x", model: "v6", duration: 5, quality: "720p", aspect_ratio: "16:9", + }, { jobRoot: root, traceIdFactory: () => savedTrace, poll: true, sleep: async () => {} }); + + assert.equal(final.status, "succeeded"); + const posts = server.requests.filter(({ method }) => method === "POST"); + const reads = server.requests.filter(({ method }) => method === "GET"); + assert.equal(posts.length, 1); + assert.equal(posts[0].headers["ai-trace-id"], savedTrace); + assert.equal(reads.length, 5); + const readTraces = reads.map(({ headers }) => headers["ai-trace-id"]); + assert.equal(new Set(readTraces).size, readTraces.length); + assert.equal(readTraces.includes(savedTrace), false); + assert.equal(final.trace_id, savedTrace); + assert.equal(final.status_trace_id, readTraces.at(-1)); + const artifacts = await readJobArtifacts(final.job_dir); + assert.deepEqual( + artifacts["polling.jsonl"].filter(({ status }) => status === "poll_error").map(({ error }) => error.code ?? error.status), + [503, 500069], + ); +}); diff --git a/test/e2e/platform-webhook.e2e.test.js b/test/e2e/platform-webhook.e2e.test.js index d88dca4..5bf5014 100644 --- a/test/e2e/platform-webhook.e2e.test.js +++ b/test/e2e/platform-webhook.e2e.test.js @@ -75,13 +75,16 @@ test("rejects invalid signatures and malformed payloads before delivery", async assert.equal(mutations, 0); }); -test("rejects replay and non-POST or oversized requests without delivery", async (t) => { +test("acknowledges a verified replay with ok without redelivery; rejects non-POST or oversized requests", async (t) => { let deliveries = 0; const rawBody = JSON.stringify({ event: "ready" }); const url = await startHandler(t, { onDelivery: async () => { deliveries += 1; } }); const options = { method: "POST", headers: headersFor(rawBody), body: rawBody }; assert.equal((await fetch(url, options)).status, 200); - assert.equal((await fetch(url, options)).status, 409); + const replay = await fetch(url, options); + // PixVerse resends until it receives exact `ok`, so a duplicate is acknowledged. + assert.equal(replay.status, 200); + assert.equal(await replay.text(), "ok"); assert.equal((await fetch(url)).status, 405); const oversized = "x".repeat(1_048_577); @@ -92,6 +95,25 @@ test("rejects replay and non-POST or oversized requests without delivery", async assert.equal(deliveries, 1); }); +test("a redelivery of the same id and status with a fresh nonce is acknowledged once", async (t) => { + const events = []; + const url = await startHandler(t, { onDelivery: async (payload) => events.push(payload) }); + const success = JSON.stringify({ id: "123456789", status: 1, url: "https://example.test/v.mp4", has_audio: true }); + for (const nonce of ["first-delivery", "redelivery-1", "redelivery-2"]) { + const response = await fetch(url, { method: "POST", headers: headersFor(success, { nonce }), body: success }); + assert.equal(response.status, 200); + assert.equal(await response.text(), "ok"); + } + assert.equal(events.length, 1); + + const later = JSON.stringify({ id: "123456789", status: 8, url: null }); + const response = await fetch(url, { method: "POST", headers: headersFor(later, { nonce: "status-8" }), body: later }); + assert.equal(response.status, 200); + assert.equal(await response.text(), "ok"); + assert.equal(events.length, 2); + assert.equal(events[1].url, null); +}); + test("blocks a concurrent duplicate while the first delivery is in progress", async (t) => { let releaseDelivery; let deliveries = 0; diff --git a/test/fixtures/platform/agent.real-estate/request.json b/test/fixtures/platform/agent.real-estate/request.json index 4d30481..39e0e3a 100644 --- a/test/fixtures/platform/agent.real-estate/request.json +++ b/test/fixtures/platform/agent.real-estate/request.json @@ -7,7 +7,7 @@ "method": "POST", "path": "/openapi/v2/video/agent/generate", "body": { - "agent_id": "419629433597950", + "agent_id": 419629433597950, "model": "pro", "product_url": "https://listing.example.test/home" } diff --git a/test/fixtures/platform/agent.viral-recreation/request.json b/test/fixtures/platform/agent.viral-recreation/request.json index afcb2c6..7d78be8 100644 --- a/test/fixtures/platform/agent.viral-recreation/request.json +++ b/test/fixtures/platform/agent.viral-recreation/request.json @@ -5,29 +5,29 @@ "quality": "720p", "img_references": [ { - "img_url": "https://cdn.example.test/a.png" + "img_id": "883671823" } ], "video_references": [ { - "video_url": "https://cdn.example.test/a.mp4" + "video_media_id": "416097137329803" } ] }, "method": "POST", "path": "/openapi/v2/video/agent/generate", "body": { - "agent_id": "414562414124109", + "agent_id": 414562414124109, "prompt": "Recreate pacing.", "quality": "720p", "img_references": [ { - "img_url": "https://cdn.example.test/a.png" + "img_id": 883671823 } ], "video_references": [ { - "video_url": "https://cdn.example.test/a.mp4" + "video_media_id": 416097137329803 } ] } diff --git a/test/fixtures/platform/image.template/request.json b/test/fixtures/platform/image.template/request.json index 81ffe6e..558bcaf 100644 --- a/test/fixtures/platform/image.template/request.json +++ b/test/fixtures/platform/image.template/request.json @@ -11,6 +11,6 @@ "img_ids": [ "629000000000000001" ], - "template_id": "42" + "template_id": 42 } } diff --git a/test/fixtures/platform/official-operation-inventory.json b/test/fixtures/platform/official-operation-inventory.json index 84b66c1..c4c83b0 100644 --- a/test/fixtures/platform/official-operation-inventory.json +++ b/test/fixtures/platform/official-operation-inventory.json @@ -29,6 +29,6 @@ { "id": "video.avatar", "command": ["video", "avatar"], "method": "POST", "path": "/openapi/v2/video/avatar/generate", "bodyMode": "json", "validationPolicy": "video.avatar", "billing": "billable", "asynchronous": true, "resultIdPath": "Resp.video_id", "documentationUrl": "https://docs.platform.pixverse.ai/avatar-generation-40528034e0" }, { "id": "agent.viral-recreation", "command": ["agent", "viral-recreation"], "method": "POST", "path": "/openapi/v2/video/agent/generate", "bodyMode": "json", "validationPolicy": "agent.viral-recreation", "billing": "billable", "asynchronous": true, "resultIdPath": "Resp.video_id", "documentationUrl": "https://docs.platform.pixverse.ai/viral-recreation-agent-41205382e0" }, { "id": "agent.real-estate", "command": ["agent", "real-estate"], "method": "POST", "path": "/openapi/v2/video/agent/generate", "bodyMode": "json", "validationPolicy": "agent.real-estate", "billing": "billable", "asynchronous": true, "resultIdPath": "Resp.video_id", "documentationUrl": "https://docs.platform.pixverse.ai/one-click-real-estate-video-42843647e0" }, - { "id": "agent.music-mv", "command": ["agent", "music-mv"], "method": "POST", "path": "/openapi/v2/video/music_mv_agent/generate", "bodyMode": "json", "validationPolicy": "agent.music-mv", "billing": "billable", "asynchronous": true, "resultIdPath": "Resp.video_id", "documentationUrl": "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc" }, + { "id": "agent.music-mv", "command": ["agent", "music-mv"], "method": "POST", "path": "/openapi/v2/video/music_mv_agent/generate", "bodyMode": "json", "validationPolicy": "agent.music-mv", "billing": "billable", "asynchronous": true, "resultIdPath": "Resp.video_id", "documentationUrl": "https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0" }, { "id": "video.status", "command": ["video", "status"], "method": "GET", "path": "/openapi/v2/video/result/{video_id}", "bodyMode": "none", "validationPolicy": "video.status", "billing": "read-only", "asynchronous": false, "resultIdPath": "Resp.id", "documentationUrl": "https://docs.platform.pixverse.ai/get-video-generation-status-13016632e0" } ] diff --git a/test/fixtures/platform/video.avatar/request.json b/test/fixtures/platform/video.avatar/request.json index abf31f8..507f4ca 100644 --- a/test/fixtures/platform/video.avatar/request.json +++ b/test/fixtures/platform/video.avatar/request.json @@ -7,8 +7,8 @@ "method": "POST", "path": "/openapi/v2/video/avatar/generate", "body": { - "img_id": "1", + "img_id": 1, "quality": "720p", - "audio_media_id": "2" + "audio_media_id": 2 } } diff --git a/test/fixtures/platform/video.extend/request.json b/test/fixtures/platform/video.extend/request.json index bd660e7..b5b180c 100644 --- a/test/fixtures/platform/video.extend/request.json +++ b/test/fixtures/platform/video.extend/request.json @@ -10,7 +10,7 @@ "method": "POST", "path": "/openapi/v2/video/extend/generate", "body": { - "source_video_id": "1", + "source_video_id": 1, "prompt": "Continue.", "seed": 1, "quality": "720p", diff --git a/test/fixtures/platform/video.image/request.json b/test/fixtures/platform/video.image/request.json index ac9effd..27de309 100644 --- a/test/fixtures/platform/video.image/request.json +++ b/test/fixtures/platform/video.image/request.json @@ -10,7 +10,7 @@ "path": "/openapi/v2/video/img/generate", "body": { "duration": 5, - "img_id": "629000000000000001", + "img_id": 629000000000000001, "model": "v6", "prompt": "Drift forward.", "quality": "720p" diff --git a/test/fixtures/platform/video.lip-sync/request.json b/test/fixtures/platform/video.lip-sync/request.json index cfb3260..c353ea4 100644 --- a/test/fixtures/platform/video.lip-sync/request.json +++ b/test/fixtures/platform/video.lip-sync/request.json @@ -6,7 +6,7 @@ "method": "POST", "path": "/openapi/v2/video/lip_sync/generate", "body": { - "source_video_id": "1", - "audio_media_id": "2" + "source_video_id": 1, + "audio_media_id": 2 } } diff --git a/test/fixtures/platform/video.modify/request.json b/test/fixtures/platform/video.modify/request.json index 1d24d2d..a125184 100644 --- a/test/fixtures/platform/video.modify/request.json +++ b/test/fixtures/platform/video.modify/request.json @@ -7,7 +7,7 @@ "method": "POST", "path": "/openapi/v2/video/modify/generate", "body": { - "source_video_id": "1", + "source_video_id": 1, "prompt": "Change sky.", "quality": "720p" } diff --git a/test/fixtures/platform/video.motion-control/request.json b/test/fixtures/platform/video.motion-control/request.json index 7e706ac..5a964b3 100644 --- a/test/fixtures/platform/video.motion-control/request.json +++ b/test/fixtures/platform/video.motion-control/request.json @@ -7,8 +7,8 @@ "method": "POST", "path": "/openapi/v2/video/mimic/generate", "body": { - "source_video_id": "1", - "img_id": "2", + "source_video_id": 1, + "img_id": 2, "quality": "720p" } } diff --git a/test/fixtures/platform/video.multi-transition/request.json b/test/fixtures/platform/video.multi-transition/request.json index c4a56ec..f95b930 100644 --- a/test/fixtures/platform/video.multi-transition/request.json +++ b/test/fixtures/platform/video.multi-transition/request.json @@ -19,11 +19,11 @@ "quality": "720p", "multi_transition": [ { - "img_id": "1", + "img_id": 1, "duration": 3 }, { - "img_id": "2" + "img_id": 2 } ] } diff --git a/test/fixtures/platform/video.restyle/request.json b/test/fixtures/platform/video.restyle/request.json index c35e179..682cf19 100644 --- a/test/fixtures/platform/video.restyle/request.json +++ b/test/fixtures/platform/video.restyle/request.json @@ -6,7 +6,7 @@ "method": "POST", "path": "/openapi/v2/video/restyle/generate", "body": { - "source_video_id": "1", - "restyle_id": "2" + "source_video_id": 1, + "restyle_id": 2 } } diff --git a/test/fixtures/platform/video.sound-effect/request.json b/test/fixtures/platform/video.sound-effect/request.json index 051adf1..d392465 100644 --- a/test/fixtures/platform/video.sound-effect/request.json +++ b/test/fixtures/platform/video.sound-effect/request.json @@ -1,12 +1,12 @@ { "input": { "source_video_id": "1", - "prompt": "Rain." + "sound_effect_content": "Rain." }, "method": "POST", "path": "/openapi/v2/video/sound_effect/generate", "body": { - "source_video_id": "1", - "prompt": "Rain." + "source_video_id": 1, + "sound_effect_content": "Rain." } } diff --git a/test/fixtures/platform/video.swap-mask/request.json b/test/fixtures/platform/video.swap-mask/request.json index 8d66568..810b396 100644 --- a/test/fixtures/platform/video.swap-mask/request.json +++ b/test/fixtures/platform/video.swap-mask/request.json @@ -6,7 +6,7 @@ "method": "POST", "path": "/openapi/v2/video/mask/selection", "body": { - "source_video_id": "1", - "keyframe_id": "1" + "source_video_id": 1, + "keyframe_id": 1 } } diff --git a/test/fixtures/platform/video.swap/request.json b/test/fixtures/platform/video.swap/request.json index adb1ee2..690ba91 100644 --- a/test/fixtures/platform/video.swap/request.json +++ b/test/fixtures/platform/video.swap/request.json @@ -9,10 +9,10 @@ "method": "POST", "path": "/openapi/v2/video/swap/generate", "body": { - "source_video_id": "1", - "keyframe_id": "1", + "source_video_id": 1, + "keyframe_id": 1, "mask_id": "person-1", - "img_id": "2", + "img_id": 2, "quality": "720p" } } diff --git a/test/fixtures/platform/video.template/request.json b/test/fixtures/platform/video.template/request.json index 7a1dc10..c3466ea 100644 --- a/test/fixtures/platform/video.template/request.json +++ b/test/fixtures/platform/video.template/request.json @@ -11,10 +11,10 @@ "path": "/openapi/v2/video/img/generate", "body": { "duration": 5, - "img_id": "629000000000000001", + "img_id": 629000000000000001, "model": "v6", "prompt": "Animate.", "quality": "720p", - "template_id": "42" + "template_id": 42 } } diff --git a/test/fixtures/platform/video.transition/request.json b/test/fixtures/platform/video.transition/request.json index 6fc80a6..f25254e 100644 --- a/test/fixtures/platform/video.transition/request.json +++ b/test/fixtures/platform/video.transition/request.json @@ -10,8 +10,8 @@ "method": "POST", "path": "/openapi/v2/video/transition/generate", "body": { - "first_frame_img": "1", - "last_frame_img": "2", + "first_frame_img": 1, + "last_frame_img": 2, "prompt": "Transition.", "model": "v6", "duration": 5, diff --git a/test/fixtures/platform/voice.create/request.json b/test/fixtures/platform/voice.create/request.json index 2ed2960..7e7dbbb 100644 --- a/test/fixtures/platform/voice.create/request.json +++ b/test/fixtures/platform/voice.create/request.json @@ -7,6 +7,6 @@ "path": "/openapi/v2/video/tts_speaker", "body": { "name": "Sample voice", - "audio_media_id": "629000000000000003" + "audio_media_id": 629000000000000003 } } diff --git a/test/folders.test.js b/test/folders.test.js index 58263c3..7d03a00 100644 --- a/test/folders.test.js +++ b/test/folders.test.js @@ -82,12 +82,75 @@ test("resolveFolderForPayload creates a missing folder", async () => { assert.deepEqual(calls, ["list", ["create", { name: "REVOLVE" }]]); }); -test("resolveFolderForPayload keeps explicit folder ids as strings", async () => { +test("resolveFolderForPayload keeps an explicit video.folder_id without listing or creating", async () => { + const client = { + async listFolders() { + throw new Error("listFolders should not be called"); + }, + async createFolder() { + throw new Error("createFolder should not be called"); + }, + }; + const folder = await resolveFolderForPayload(client, { + product: { source_url: "https://www.revolve.com/item" }, + video: { folder_id: "630251570268735431" }, + }, { folderName: "Another Folder", autoFolder: true }); + + assert.equal(folder.folderId, "630251570268735431"); + assert.equal(folder.source, "explicit-folder-id"); + assert.equal(folder.folderCreated, false); +}); + +test("resolveFolderForPayload ignores a legacy top-level folder_id", async () => { const folder = await resolveFolderForPayload({}, { folder_id: "630251570268735431", product: { source_url: "https://www.revolve.com/item" }, }, {}); - assert.equal(folder.folderId, "630251570268735431"); - assert.equal(folder.source, "explicit-folder-id"); + assert.equal(folder, null); +}); + +test("resolveFolderForPayload sends an explicit folder name trimmed but otherwise unchanged", async () => { + const calls = []; + const client = { + async listFolders() { + calls.push("list"); + return { body: { folders: [{ folder_id: "1", name: "SUMMER DROP" }, { folder_id: "2", name: "summer_drop" }] } }; + }, + async createFolder(payload) { + calls.push(["create", payload]); + return { body: { folder_id: "636771263750078906" } }; + }, + }; + + const folder = await resolveFolderForPayload(client, {}, { folderName: " summer drop " }); + + assert.deepEqual(calls, ["list", ["create", { name: "summer drop" }]]); + assert.deepEqual(folder, { + folderId: "636771263750078906", + folderName: "summer drop", + folderCreated: true, + source: "created-folder", + }); +}); + +test("resolveFolderForPayload reuses an exact explicit folder name match", async () => { + const client = { + async listFolders() { + return { body: { folders: [{ folder_id: "1", name: "Summer DROP" }, { folder_id: "2", name: "summer_drop" }] } }; + }, + async createFolder() { + throw new Error("createFolder should not be called"); + }, + }; + + const folder = await resolveFolderForPayload(client, {}, { folderName: "summer_drop" }); + + assert.equal(folder.folderId, "2"); + assert.equal(folder.folderName, "summer_drop"); + assert.equal(folder.source, "existing-folder"); +}); + +test("resolveFolderForPayload rejects a blank explicit folder name", async () => { + await assert.rejects(resolveFolderForPayload({}, {}, { folderName: " " }), /Folder name cannot be empty/); }); diff --git a/test/helpers/cli-process.js b/test/helpers/cli-process.js index 7136481..571a232 100644 --- a/test/helpers/cli-process.js +++ b/test/helpers/cli-process.js @@ -58,7 +58,6 @@ function assertLoopbackProviderUrls(overrides) { const providers = [ ["PIXVERSE_PLATFORM_API_KEY", "PIXVERSE_PLATFORM_BASE_URL"], ["PIXVERSE_GROWTH_API_KEY", "PIXVERSE_GROWTH_BASE_URL"], - ["PIXVERSE_GROWTH_FOLDER_API_KEY", "PIXVERSE_GROWTH_BASE_URL"], ]; for (const [credentialName, baseUrlName] of providers) { if (overrides[credentialName] && !isLoopbackUrl(overrides[baseUrlName])) { @@ -83,7 +82,7 @@ function assertDotEnvProviderUrlIsLoopback(args, cwd, overrides) { const dotEnv = readDotEnv(path.join(cwd, ".env")); const credentialNames = provider === "platform" ? ["PIXVERSE_PLATFORM_API_KEY"] - : ["PIXVERSE_GROWTH_API_KEY", "PIXVERSE_GROWTH_FOLDER_API_KEY"]; + : ["PIXVERSE_GROWTH_API_KEY"]; const baseUrlName = provider === "platform" ? "PIXVERSE_PLATFORM_BASE_URL" : "PIXVERSE_GROWTH_BASE_URL"; diff --git a/test/helpers/recording-fetch.js b/test/helpers/recording-fetch.js index 8fa0182..cbbe547 100644 --- a/test/helpers/recording-fetch.js +++ b/test/helpers/recording-fetch.js @@ -1,3 +1,21 @@ +import JSONBigFactory from "json-bigint"; + +// Parse recorded JSON bodies losslessly so an integer ID above 2^53 can be +// told apart from a rounded neighbour (and from a JSON string). +const JSON_BIG = JSONBigFactory({ useNativeBigInt: true }); + +export function parseLosslessJson(text) { + return withPlainPrototypes(JSON_BIG.parse(text)); +} + +function withPlainPrototypes(value) { + if (Array.isArray(value)) return value.map(withPlainPrototypes); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, withPlainPrototypes(item)])); + } + return value; +} + export function createRecordingFetch(responses = []) { const calls = []; let responseIndex = 0; @@ -30,7 +48,7 @@ export function jsonResponse(body, init = {}) { export async function describeRecordedBody(body) { if (body === undefined || body === null) return null; - if (typeof body === "string") return JSON.parse(body); + if (typeof body === "string") return parseLosslessJson(body); if (body instanceof FormData) { const fields = {}; for (const [name, value] of body.entries()) { diff --git a/test/integration/cli-routing.test.js b/test/integration/cli-routing.test.js index 57797ed..3ec6893 100644 --- a/test/integration/cli-routing.test.js +++ b/test/integration/cli-routing.test.js @@ -302,7 +302,7 @@ test("Growth Studio executable still loads provider configuration from cwd doten const home = await fs.mkdtemp(path.join(os.tmpdir(), "pixverse-growth-home-")); t.after(() => fs.rm(home, { recursive: true, force: true })); const server = http.createServer((request, response) => { - assert.equal(request.url, "/marketing_hub/folder/list"); + assert.equal(request.url, "/openapi/v1/folders"); response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ folders: [] })); }); @@ -411,7 +411,7 @@ test("executable routes load dedicated API credentials from the user auth store" test("legacy executable preserves JSON stdout and emits one deprecation line", async () => { const server = http.createServer((request, response) => { - assert.equal(request.url, "/marketing_hub/folder/list"); + assert.equal(request.url, "/openapi/v1/folders"); response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ folders: [{ folder_id: "630251570268735431" }] })); }); diff --git a/test/integration/growth-studio-cli.test.js b/test/integration/growth-studio-cli.test.js index 7691dd3..10acc8a 100644 --- a/test/integration/growth-studio-cli.test.js +++ b/test/integration/growth-studio-cli.test.js @@ -11,7 +11,7 @@ import { createRecordingFetch, jsonResponse } from "../helpers/recording-fetch.j test("canonical Growth Studio namespace is independent from Platform configuration", async () => { const output = captureOutput(); const { calls, fetchImpl } = createRecordingFetch([ - jsonResponse({ ErrCode: 0, Resp: { folders: [{ folder_id: "630251570268735431" }] } }), + jsonResponse('{"folders":[{"folder_id":630251570268735431,"name":"ACME"}],"request_id":"req-folders"}'), ]); const exitCode = await main(["growth-studio", "folders", "list"], { env: { @@ -23,9 +23,14 @@ test("canonical Growth Studio namespace is independent from Platform configurati }); assert.equal(exitCode, 0); - assert.deepEqual(JSON.parse(output.stdout()), { folders: [{ folder_id: "630251570268735431" }] }); + assert.deepEqual(JSON.parse(output.stdout()), { + folders: [{ folder_id: "630251570268735431", name: "ACME" }], + request_id: "req-folders", + }); assert.equal(output.stderr(), ""); assert.equal(calls.length, 1); + assert.equal(calls[0].method, "GET"); + assert.equal(new URL(calls[0].url).pathname, "/openapi/v1/folders"); assert.equal(calls[0].headers.get("Authorization"), "Bearer mh_live_fixture"); assert.equal(calls[0].headers.has("API-KEY"), false); }); diff --git a/test/jobs.test.js b/test/jobs.test.js index 722eb18..fb143aa 100644 --- a/test/jobs.test.js +++ b/test/jobs.test.js @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { GrowthStudioApiError } from "../src/http.js"; import { resumeGrowthStudioJob, runVideoJob } from "../src/jobs.js"; test("runVideoJob writes durable job artifacts and returns final output", async () => { @@ -102,7 +103,7 @@ test("runVideoJob can inject a folder_id override into the submitted payload", a async createVideo(receivedPayload) { assert.deepEqual(receivedPayload, { ...originalPayload, - folder_id: "630251570268735431", + video: { ...originalPayload.video, folder_id: "630251570268735431" }, }); return { body: { video_id: "627410861853514292", status: "processing" } }; }, @@ -118,8 +119,9 @@ test("runVideoJob can inject a folder_id override into the submitted payload", a }); const request = JSON.parse(await fs.readFile(path.join(result.job_dir, "request.json"), "utf8")); - assert.equal(request.payload.folder_id, "630251570268735431"); - assert.equal(originalPayload.folder_id, undefined); + assert.equal(request.payload.video.folder_id, "630251570268735431"); + assert.equal(request.payload.folder_id, undefined); + assert.equal(originalPayload.video.folder_id, undefined); }); test("runVideoJob resolves an auto folder before creating the video", async () => { @@ -137,7 +139,8 @@ test("runVideoJob resolves an auto folder before creating the video", async () = throw new Error("createFolder should not be called"); }, async createVideo(receivedPayload) { - assert.equal(receivedPayload.folder_id, "636771263750078906"); + assert.equal(receivedPayload.video.folder_id, "636771263750078906"); + assert.equal(receivedPayload.folder_id, undefined); return { body: { video_id: "627410861853514292", status: "processing" } }; }, async pollVideo() { @@ -153,11 +156,36 @@ test("runVideoJob resolves an auto folder before creating the video", async () = const request = JSON.parse(await fs.readFile(path.join(result.job_dir, "request.json"), "utf8")); const folder = JSON.parse(await fs.readFile(path.join(result.job_dir, "folder.json"), "utf8")); - assert.equal(request.payload.folder_id, "636771263750078906"); + assert.equal(request.payload.video.folder_id, "636771263750078906"); assert.equal(result.folder_id, "636771263750078906"); assert.equal(result.folder_name, "REVOLVE"); assert.equal(folder.source, "existing-folder"); - assert.equal(originalPayload.folder_id, undefined); + assert.equal(originalPayload.video.folder_id, undefined); +}); + +test("runVideoJob keeps a payload video.folder_id without resolving another folder", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "pixverse-api-job-")); + const payload = { + metadata: { customer: "REVOLVE" }, + product: { source_url: "https://www.revolve.com/item" }, + video: { aspect_ratio: "9:16", folder_id: "630251570268735431" }, + }; + const client = { + async listFolders() { + throw new Error("listFolders should not be called"); + }, + async createFolder() { + throw new Error("createFolder should not be called"); + }, + async createVideo(receivedPayload) { + assert.deepEqual(receivedPayload, payload); + return { body: { video_id: "627410861853514292", status: "processing" } }; + }, + }; + + const result = await runVideoJob(client, payload, { jobsDir: root, autoFolder: true, poll: false }); + + assert.equal(result.folder_id, "630251570268735431"); }); test("runVideoJob writes private atomic artifacts with recursive credential redaction", async () => { @@ -279,6 +307,35 @@ test("runVideoJob persists a redacted recovery error without exposing upstream d assert.equal(JSON.stringify(failure).includes("super-secret"), false); }); +test("runVideoJob persists request_id and redacted details in error.json", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "pixverse-api-error-")); + await assert.rejects( + runVideoJob({ + async createVideo() { + throw new GrowthStudioApiError("Insufficient balance.", { + status: 402, + code: "INSUFFICIENT_BALANCE", + retryable: false, + requestId: "req_402_fixture", + details: { required: "1.00", api_key: "leaked-fixture-value" }, + }); + }, + }, { + product: { source_url: "https://shop.example.com/item" }, + video: { aspect_ratio: "9:16" }, + }, { jobsDir: root, traceId: "trace-402" }), + /Insufficient balance/, + ); + const [jobDir] = (await fs.readdir(root)).map((entry) => path.join(root, entry)); + const failure = JSON.parse(await fs.readFile(path.join(jobDir, "error.json"), "utf8")); + + assert.equal(failure.status, 402); + assert.equal(failure.code, "INSUFFICIENT_BALANCE"); + assert.equal(failure.request_id, "req_402_fixture"); + assert.deepEqual(failure.details, { required: "1.00", api_key: "[REDACTED]" }); + assert.equal(JSON.stringify(failure).includes("leaked-fixture-value"), false); +}); + async function fileExists(filePath) { try { await fs.access(filePath); diff --git a/test/platform-skill.test.js b/test/platform-skill.test.js index 1a63b4b..2d53392 100644 --- a/test/platform-skill.test.js +++ b/test/platform-skill.test.js @@ -14,7 +14,11 @@ const REFERENCES = [ "operation-catalog.md", "payload-examples.json", ]; -const MUSIC_MV_DOCUMENTATION_URL = "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc"; +// audio.verify has no public page yet; Music MV is documented as One-click VibeMV. +const MUSIC_MV_DOCUMENTATION_URLS = { + "audio.verify": "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc", + "agent.music-mv": "https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0", +}; const MUSIC_MV_OPERATION_IDS = new Set(["audio.verify", "agent.music-mv"]); async function readSkill(relativePath) { @@ -100,7 +104,7 @@ test("operation reference is traceable to every executable catalog row", async ( assert.notEqual(row.prerequisite, ""); assert.equal(row.documentationUrl, operation.documentationUrl); if (MUSIC_MV_OPERATION_IDS.has(operation.id)) { - assert.equal(row.documentationUrl, MUSIC_MV_DOCUMENTATION_URL); + assert.equal(row.documentationUrl, MUSIC_MV_DOCUMENTATION_URLS[operation.id]); } else { assert.equal(new URL(row.documentationUrl).hostname, "docs.platform.pixverse.ai"); } @@ -156,7 +160,7 @@ test("model, pricing, and limit guidance is dated and linked to primary sources" assert.match(reference, /Verified against official docs: 2026-09-20/); assert.match(reference, /refresh|re-check|verify live/i); for (const page of officialPages) assert.ok(reference.includes(page), page); - assert.ok(reference.includes(MUSIC_MV_DOCUMENTATION_URL)); + assert.ok(reference.includes(MUSIC_MV_DOCUMENTATION_URLS["agent.music-mv"])); assert.match(reference, /15 credits.*second/i); assert.match(reference, /22\.5 credits.*second/i); assert.match(reference, /10.*360 seconds/i); diff --git a/test/unit/core-errors.test.js b/test/unit/core-errors.test.js index c1c98c0..a3c6031 100644 --- a/test/unit/core-errors.test.js +++ b/test/unit/core-errors.test.js @@ -62,3 +62,20 @@ test("PixverseCliError also accepts a single options object", () => { code: "BAD_INPUT", }); }); + +test("isTransientReadError classifies transport, 429, 5xx, and retryable provider codes", async () => { + const { isTransientReadError, PixverseCliError } = await import("../../src/core/errors.js"); + const make = (fields) => new PixverseCliError("x", fields); + assert.equal(isTransientReadError(make({ category: "transport" })), true); + assert.equal(isTransientReadError(make({ category: "http", status: 429 })), true); + assert.equal(isTransientReadError(make({ category: "http", status: 503 })), true); + assert.equal(isTransientReadError(make({ category: "provider", status: 500, retryable: false })), true); + assert.equal(isTransientReadError(make({ category: "response", status: 502 })), true); + assert.equal(isTransientReadError(make({ category: "provider", status: 200, code: 500069, retryable: true })), true); + assert.equal(isTransientReadError(make({ category: "provider", status: 200, code: 400017, retryable: false })), false); + assert.equal(isTransientReadError(make({ category: "http", status: 400 })), false); + assert.equal(isTransientReadError(make({ category: "response", status: 200 })), false); + const abort = new DOMException("aborted", "AbortError"); + assert.equal(isTransientReadError(make({ category: "transport", cause: abort })), false); + assert.equal(isTransientReadError(undefined), false); +}); diff --git a/test/unit/core-http.test.js b/test/unit/core-http.test.js index 5194516..1ae083d 100644 --- a/test/unit/core-http.test.js +++ b/test/unit/core-http.test.js @@ -179,3 +179,70 @@ test("requestHttp does not retry an AbortError raised during fetch", async () => assert.equal(attempts, 1); assert.deepEqual(delays, []); }); + +test("requestHttp gives each read-only retry attempt a fresh trace header", async () => { + const seen = []; + const traces = ["trace-2", "trace-3"]; + let attempts = 0; + const result = await requestHttp({ + url: "https://example.test/status", + headers: new Headers({ "Ai-trace-id": "trace-1", Accept: "application/json" }), + traceId: "trace-1", + nextTraceId: () => traces.shift(), + retry: { maxAttempts: 3, delayMs: 0 }, + sleep: async () => {}, + fetchImpl: async (_url, { headers }) => { + seen.push(headers.get("Ai-trace-id")); + assert.equal(headers.get("Accept"), "application/json"); + attempts += 1; + return attempts < 3 ? new Response("busy", { status: 503 }) : new Response('{"ok":true}'); + }, + }); + assert.deepEqual(seen, ["trace-1", "trace-2", "trace-3"]); + assert.equal(result.traceId, "trace-3"); + + // Plain-object headers are rewritten case-insensitively; failures report the attempt trace. + const objectSeen = []; + await assert.rejects(requestHttp({ + url: "https://example.test/status", + headers: { "ai-trace-id": "first" }, + traceId: "first", + nextTraceId: () => "second", + retry: { maxAttempts: 2, delayMs: 0 }, + sleep: async () => {}, + fetchImpl: async (_url, { headers }) => { + objectSeen.push(headers); + return new Response("busy", { status: 503 }); + }, + }), (error) => error.traceId === "second"); + assert.deepEqual(objectSeen, [{ "ai-trace-id": "first" }, { "Ai-trace-id": "second" }]); +}); + +test("requestHttp keeps the supplied trace when no per-attempt factory is given and never retries POST", async () => { + const seen = []; + await requestHttp({ + url: "https://example.test/status", + headers: new Headers({ "Ai-trace-id": "fixed" }), + traceId: "fixed", + retry: { maxAttempts: 2, delayMs: 0 }, + sleep: async () => {}, + fetchImpl: async (_url, { headers }) => { + seen.push(headers.get("Ai-trace-id")); + return seen.length === 1 ? new Response("busy", { status: 503 }) : new Response("{}"); + }, + }); + assert.deepEqual(seen, ["fixed", "fixed"]); + + let factoryCalls = 0; + let posts = 0; + await assert.rejects(requestHttp({ + url: "https://example.test/generate", + method: "POST", + traceId: "billable", + nextTraceId: () => { factoryCalls += 1; return "other"; }, + retry: { maxAttempts: 3 }, + fetchImpl: async () => { posts += 1; return new Response("busy", { status: 503 }); }, + }), (error) => error.traceId === "billable"); + assert.equal(posts, 1); + assert.equal(factoryCalls, 0); +}); diff --git a/test/unit/core-polling.test.js b/test/unit/core-polling.test.js index a92bffd..5a0c2e3 100644 --- a/test/unit/core-polling.test.js +++ b/test/unit/core-polling.test.js @@ -54,3 +54,95 @@ test("pollUntilTerminal validates callbacks and time bounds", async () => { /timeoutMs must be a non-negative number/i, ); }); + +function transient(fields = {}) { + return Object.assign(new Error("transient"), { category: "transport", ...fields }); +} + +test("pollUntilTerminal keeps polling through retryable errors with backoff and Retry-After", async () => { + let nowMs = 0; + const delays = []; + const outcomes = [ + transient(), + transient({ category: "http", status: 503 }), + transient({ category: "http", status: 429, retryAfter: 30 }), + { status: 5 }, + transient(), + { status: 1 }, + ]; + const errors = []; + const result = await pollUntilTerminal({ + poll: async () => { + const next = outcomes.shift(); + if (next instanceof Error) throw next; + return next; + }, + isTerminal: (snapshot) => snapshot.status === 1, + isRetryableError: () => true, + onPollError: async (error, { consecutiveErrors }) => errors.push(consecutiveErrors), + intervalMs: 1_000, + maxBackoffMs: 3_000, + timeoutMs: 600_000, + now: () => nowMs, + sleep: async (milliseconds) => { delays.push(milliseconds); nowMs += milliseconds; }, + }); + assert.deepEqual(result, { status: 1 }); + // 1s, 2s backoff, then Retry-After 30s beats the 3s cap, success resets the streak. + assert.deepEqual(delays, [1_000, 2_000, 30_000, 1_000, 1_000]); + assert.deepEqual(errors, [1, 2, 3, 1]); +}); + +test("pollUntilTerminal rethrows non-retryable errors immediately", async () => { + let polls = 0; + const fatal = Object.assign(new Error("bad id"), { category: "provider", retryable: false }); + await assert.rejects(pollUntilTerminal({ + poll: async () => { polls += 1; throw fatal; }, + isTerminal: () => false, + isRetryableError: (error) => error.retryable === true, + sleep: async () => { throw new Error("must not sleep"); }, + }), (error) => error === fatal); + assert.equal(polls, 1); + + // Without the opt-in classifier, any error still fails fast (backward compatible). + await assert.rejects(pollUntilTerminal({ + poll: async () => { throw transient(); }, + isTerminal: () => false, + }), /transient/); +}); + +test("pollUntilTerminal times out with the last error when transient failures persist", async () => { + let nowMs = 0; + await assert.rejects(pollUntilTerminal({ + poll: async () => { throw transient({ status: 502, traceId: "t-1" }); }, + isTerminal: () => false, + isRetryableError: () => true, + intervalMs: 100, + timeoutMs: 1_000, + now: () => nowMs, + sleep: async (milliseconds) => { nowMs += milliseconds; }, + provider: "platform", + }), (error) => error.category === "timeout" + && error.details.last_error.category === "transport" + && error.details.last_error.status === 502 + && error.details.last_error.trace_id === "t-1"); + assert.ok(nowMs <= 1_000); + + // A Retry-After longer than the remaining budget stops instead of polling early. + nowMs = 0; + let polls = 0; + await assert.rejects(pollUntilTerminal({ + poll: async () => { polls += 1; throw transient({ retryAfter: 5 }); }, + isTerminal: () => false, + isRetryableError: () => true, + intervalMs: 100, + timeoutMs: 1_000, + now: () => nowMs, + sleep: async (milliseconds) => { nowMs += milliseconds; }, + }), (error) => error.category === "timeout"); + assert.equal(polls, 1); + + await assert.rejects( + pollUntilTerminal({ poll: async () => ({}), isTerminal: () => true, isRetryableError: true }), + /isRetryableError must be a function/i, + ); +}); diff --git a/test/unit/growth-studio-compatibility.test.js b/test/unit/growth-studio-compatibility.test.js index 964c5f8..54f6c16 100644 --- a/test/unit/growth-studio-compatibility.test.js +++ b/test/unit/growth-studio-compatibility.test.js @@ -51,9 +51,8 @@ test("flat modules preserve Growth Studio named exports", () => { assert.equal(flatHttp.parseResponse, growthClient.parseResponse); }); -test("Growth Studio config has a provider-specific name and preserves folder key override", () => { +test("Growth Studio config has a provider-specific name and ignores removed folder key settings", () => { const apiKeyField = "api" + "Key"; - const folderApiKeyField = "folderApi" + "Key"; const config = growthConfig.getGrowthStudioConfig({ PIXVERSE_GROWTH_API_KEY: "mh_" + "live_fixture_video", PIXVERSE_GROWTH_FOLDER_API_KEY: "mh_" + "live_fixture_folder", @@ -64,8 +63,6 @@ test("Growth Studio config has a provider-specific name and preserves folder key assert.deepEqual(config, { [apiKeyField]: "mh_" + "live_fixture_video", - [folderApiKeyField]: "mh_" + "live_fixture_folder", - folderApiPrefix: "/openapi/v1/growth-studio", baseUrl: "https://growth.example.test", }); assert.equal(growthConfig.getConfig, growthConfig.getGrowthStudioConfig); @@ -179,7 +176,8 @@ test("namespaced jobs preserve artifacts and do not mutate payloads during folde const client = { async createVideo(received) { assert.notEqual(received, payload); - assert.equal(received.folder_id, "630251570268735431"); + assert.equal(received.video.folder_id, "630251570268735431"); + assert.equal(received.folder_id, undefined); return { body: { video_id: "627410861853514292", status: "processing" } }; }, }; @@ -190,7 +188,7 @@ test("namespaced jobs preserve artifacts and do not mutate payloads during folde poll: false, }); - assert.equal(payload.folder_id, undefined); + assert.equal(payload.video.folder_id, undefined); assert.equal(result.video_id, "627410861853514292"); assert.equal(result.folder_id, "630251570268735431"); assert.deepEqual( @@ -272,7 +270,7 @@ test("Growth Studio command adapter preserves read-only result shapes", async () assert.equal(calls[0][0], "avatars"); assert.equal(calls[1][0], "folders"); assert.deepEqual(calls[2][1], { - limit: "20", + limit: 20, status: "succeeded", cursor: "next", traceId: calls[2][1].traceId, @@ -377,9 +375,10 @@ test("Growth Studio command adapter preserves create, edit, upload, and job beha assert.equal(job.video_id, "627410861853514292"); assert.ok(job.job_dir.startsWith(jobsDir)); assert.deepEqual(calls.map(([name]) => name), ["upload", "create", "create", "edit", "create"]); - assert.equal(calls[1][1].folder_id, "630251570268735431"); - assert.equal(calls[2][1].folder_id, undefined); - assert.equal(calls[4][1].folder_id, undefined); + assert.equal(calls[1][1].video.folder_id, "630251570268735431"); + assert.equal(calls[1][1].folder_id, undefined); + assert.equal(calls[2][1].video.folder_id, undefined); + assert.equal(calls[4][1].video.folder_id, undefined); }); test("Growth Studio folder ensure accepts explicit option form", async () => { @@ -396,13 +395,47 @@ test("Growth Studio folder ensure accepts explicit option form", async () => { "folders", "ensure", "--folder-name", - "fixture-folder", + "Fixture Folder", ], { client }); assert.equal(result.folderId, "630251570268735431"); assert.equal(result.source, "existing-folder"); }); +test("Growth Studio folder ensure sends the user-provided name without normalization", async () => { + const created = []; + const client = { + async listFolders() { + return { body: { folders: [{ folder_id: "630251570268735431", name: "Fixture Folder" }] } }; + }, + async createFolder(payload) { + created.push(payload); + return { body: { folder_id: "630251570268735432" } }; + }, + }; + + const result = await runGrowthStudioCommand(["folders", "ensure", " fixture-folder "], { client }); + + assert.deepEqual(created, [{ name: "fixture-folder" }]); + assert.equal(result.folderId, "630251570268735432"); + assert.equal(result.folderName, "fixture-folder"); + assert.equal(result.source, "created-folder"); +}); + +test("Growth Studio video list rejects undocumented limits and statuses before client access", async () => { + let clientAccesses = 0; + const context = { + get client() { + clientAccesses += 1; + throw new Error("client must not be loaded for invalid list options"); + }, + }; + await assert.rejects(runGrowthStudioCommand(["video", "list", "--limit", "0"], context), /--limit must be a safe integer from 1 through 100/); + await assert.rejects(runGrowthStudioCommand(["video", "list", "--limit", "101"], context), /--limit/); + await assert.rejects(runGrowthStudioCommand(["video", "list", "--limit", "2.5"], context), /--limit/); + assert.equal(clientAccesses, 0); +}); + test("Growth Studio command adapter rejects incomplete commands without configuration", async () => { await assert.rejects( runGrowthStudioCommand(["folders", "ensure"], { client: {} }), diff --git a/test/unit/growth-studio-payload-boundary.test.js b/test/unit/growth-studio-payload-boundary.test.js index 340a441..b884137 100644 --- a/test/unit/growth-studio-payload-boundary.test.js +++ b/test/unit/growth-studio-payload-boundary.test.js @@ -11,39 +11,57 @@ const freeze = (value) => { return value; }; +const video = (overrides = {}) => ({ + aspect_ratio: "9:16", + duration_seconds: 15, + resolution: "720p", + language: "en-US", + voiceover: true, + captions: true, + background_music: true, + avatar: { mode: "auto" }, + ...overrides, +}); + test("Growth Studio accepts a neutral product URL payload without mutation", () => { const payload = freeze({ - folder_id: "630251570268735431", product: { source_url: "https://shop.example.com/products/example-item" }, - video: { aspect_ratio: "9:16", duration_seconds: 15, resolution: "720p" }, + video: video({ folder_id: "630251570268735431" }), }); assert.doesNotThrow(() => validateCreatePayload(payload)); assert.deepEqual(payload, { - folder_id: "630251570268735431", product: { source_url: "https://shop.example.com/products/example-item" }, - video: { aspect_ratio: "9:16", duration_seconds: 15, resolution: "720p" }, + video: video({ folder_id: "630251570268735431" }), }); }); test("Growth Studio accepts only PixVerse upload URLs for image payloads", () => { assert.doesNotThrow(() => validateCreatePayload(freeze({ product: { + title: "Example item", images: [{ url: "https://media.pixverse.ai/marketing_hub_website/openapi_video_inputs/uploads/example/product.webp" }], }, - video: { aspect_ratio: "9:16", duration_seconds: 15, resolution: "720p" }, + video: video(), }))); assert.throws(() => validateCreatePayload({ - product: { images: [{ url: "https://shop.example.com/product.webp" }] }, - video: { aspect_ratio: "9:16", duration_seconds: 15, resolution: "720p" }, + product: { title: "Example item", images: [{ url: "https://shop.example.com/product.webp" }] }, + video: video(), }), /Product images must use URLs returned by the image upload endpoint/); }); test("Growth Studio rejects unsafe numeric folder IDs before network access", () => { assert.throws(() => validateCreatePayload({ - folder_id: 630251570268735431, product: { source_url: "https://shop.example.com/products/example-item" }, - video: { aspect_ratio: "9:16", duration_seconds: 15, resolution: "720p" }, + video: video({ folder_id: 630251570268735431 }), }), /folder_id must be a numeric string/); }); + +test("Growth Studio rejects a top-level folder_id because it belongs at video.folder_id", () => { + assert.throws(() => validateCreatePayload({ + folder_id: "630251570268735431", + product: { source_url: "https://shop.example.com/products/example-item" }, + video: video(), + }), /video\.folder_id/); +}); diff --git a/test/unit/platform-client-retry-trace.test.js b/test/unit/platform-client-retry-trace.test.js new file mode 100644 index 0000000..8b77ea0 --- /dev/null +++ b/test/unit/platform-client-retry-trace.test.js @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PlatformClient } from "../../src/platform/client.js"; + +const BASE_URL = "https://platform.example.test"; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function recorder(responses) { + const calls = []; + return { + calls, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), method: init.method, trace: new Headers(init.headers).get("Ai-trace-id") }); + const next = responses.shift(); + if (next instanceof Error) throw next; + return next; + }, + }; +} + +test("read-only GET retries send a unique Ai-trace-id per attempt and report the successful one", async () => { + const fetch = recorder([ + new TypeError("socket closed"), + new Response("busy", { status: 503 }), + new Response('{"ErrCode":0,"ErrMsg":"success","Resp":{"id":"9","status":5}}'), + ]); + const client = new PlatformClient({ + apiKey: "fixture-credential", baseUrl: BASE_URL, fetchImpl: fetch.fetchImpl, sleep: async () => {}, + }); + const result = await client.execute("video.status", { video_id: "9" }); + const traces = fetch.calls.map(({ trace }) => trace); + assert.equal(traces.length, 3); + assert.equal(new Set(traces).size, 3); + for (const trace of traces) assert.match(trace, UUID); + assert.equal(result.traceId, traces.at(-1)); +}); + +test("a billable submission is sent once with its recovery trace, even on 503 or a retryable ErrCode", async () => { + const savedTrace = "11111111-1111-4111-8111-111111111111"; + for (const response of [ + new Response("busy", { status: 503 }), + new Response('{"ErrCode":500069,"ErrMsg":"high load","Resp":null}'), + ]) { + const fetch = recorder([response, new Response('{"ErrCode":0,"Resp":{"video_id":"1"}}')]); + const client = new PlatformClient({ + apiKey: "fixture-credential", baseUrl: BASE_URL, fetchImpl: fetch.fetchImpl, sleep: async () => {}, + }); + await assert.rejects(client.execute("video.text", { + aspect_ratio: "16:9", duration: 5, model: "v6", prompt: "hello", quality: "720p", + }, { recovery: true, traceId: savedTrace }), (error) => error.traceId === savedTrace); + assert.equal(fetch.calls.length, 1); + assert.equal(fetch.calls[0].method, "POST"); + assert.equal(fetch.calls[0].trace, savedTrace); + } +}); diff --git a/test/unit/platform-envelope.test.js b/test/unit/platform-envelope.test.js index 5c90833..ef138a2 100644 --- a/test/unit/platform-envelope.test.js +++ b/test/unit/platform-envelope.test.js @@ -43,3 +43,12 @@ test("parsePlatformEnvelope rejects malformed response bodies", () => { assert.throws(() => parsePlatformEnvelope(body), /Platform API response envelope/i); } }); + +test("parsePlatformEnvelope marks documented transient ErrCodes retryable and others not", () => { + for (const [code, retryable] of [[500069, true], ["500069", true], [500044, true], [10005, false], [500063, false]]) { + assert.throws( + () => parsePlatformEnvelope({ ErrCode: code, ErrMsg: "x", Resp: null }, { retryAfter: 3 }), + (error) => error.retryable === retryable && error.retryAfter === 3, + ); + } +}); diff --git a/test/unit/platform-jobs.test.js b/test/unit/platform-jobs.test.js index 1565569..be9c229 100644 --- a/test/unit/platform-jobs.test.js +++ b/test/unit/platform-jobs.test.js @@ -19,8 +19,8 @@ const AGENT_INPUT = Object.freeze({ agent_id: "414562414124109", prompt: "Recreate pacing.", quality: "720p", - img_references: Object.freeze([{ img_url: "https://cdn.example.test/a.png" }]), - video_references: Object.freeze([{ video_url: "https://cdn.example.test/a.mp4" }]), + img_references: Object.freeze([{ img_id: "1" }]), + video_references: Object.freeze([{ video_media_id: "2" }]), }); const MUSIC_MV_INPUT = Object.freeze({ mv_agent_type: "vibe_mv_v3_custom", @@ -376,3 +376,103 @@ test("polling respects a successful response Retry-After hint", async (t) => { }); assert.deepEqual(delays, [2_000]); }); + +function transientStatusClient(outcomes, counters) { + return { + execute: async (id) => { + if (id === "video.text") { + counters.submits += 1; + return { operation: id, traceId: TRACE, envelope: { ErrCode: 0, Resp: { video_id: "9" } }, data: { video_id: "9" } }; + } + counters.reads += 1; + const next = outcomes.shift(); + if (next instanceof Error) throw next; + return { + operation: id, traceId: `status-${counters.reads}`, + envelope: { ErrCode: 0, Resp: { id: "9", status: next } }, data: { id: "9", status: next }, + }; + }, + }; +} + +test("status polling survives transient read errors and keeps a single billable submit", async (t) => { + const { PixverseCliError } = await import("../../src/core/errors.js"); + const root = await createTempJobRoot(t); + const counters = { submits: 0, reads: 0 }; + const delays = []; + let clock = 0; + const final = await submitPlatformJob(transientStatusClient([ + 5, + new PixverseCliError("socket closed", { category: "transport", provider: "platform", traceId: "t-a" }), + new PixverseCliError("busy", { category: "http", status: 503, retryAfter: 7 }), + new PixverseCliError("high load", { category: "provider", status: 200, code: 500069, retryable: true }), + 1, + ], counters), "video.text", INPUT, { + jobRoot: root, traceIdFactory: () => TRACE, poll: true, + now: () => clock, + sleep: async (milliseconds) => { delays.push(milliseconds); clock += milliseconds; }, + }); + assert.equal(final.status, "succeeded"); + assert.equal(counters.submits, 1); + assert.equal(counters.reads, 5); + // Default 5s cadence; then error backoff 5s, max(10s backoff, 7s Retry-After), 20s. + assert.deepEqual(delays, [5_000, 5_000, 10_000, 20_000]); + const artifacts = await readJobArtifacts(final.job_dir); + const errors = artifacts["polling.jsonl"].filter((entry) => entry.status === "poll_error"); + assert.deepEqual(errors.map((entry) => entry.error.category), ["transport", "http", "provider"]); + assert.equal(errors[2].error.code, 500069); + assert.equal(artifacts["polling.jsonl"].at(-1).status, "succeeded"); + assert.equal(artifacts["error.json"], undefined); +}); + +test("status polling fails immediately on a non-retryable read error", async (t) => { + const { PixverseCliError } = await import("../../src/core/errors.js"); + const root = await createTempJobRoot(t); + const counters = { submits: 0, reads: 0 }; + await assert.rejects(submitPlatformJob(transientStatusClient([ + 5, + new PixverseCliError("invalid id", { category: "provider", status: 200, code: 400017, retryable: false }), + 1, + ], counters), "video.text", INPUT, { + jobRoot: root, traceIdFactory: () => TRACE, poll: true, sleep: async () => {}, now: () => 0, + }), (error) => error.code === 400017); + assert.equal(counters.submits, 1); + assert.equal(counters.reads, 2); +}); + +test("status polling stops retrying transient errors once the caller aborts", async (t) => { + const { PixverseCliError } = await import("../../src/core/errors.js"); + const root = await createTempJobRoot(t); + const counters = { submits: 0, reads: 0 }; + const controller = new AbortController(); + const client = transientStatusClient([], counters); + const execute = client.execute; + client.execute = async (id, input, options) => { + if (id !== "video.text") { + controller.abort(); + throw new PixverseCliError("socket closed", { category: "transport" }); + } + return execute(id, input, options); + }; + await assert.rejects(submitPlatformJob(client, "video.text", INPUT, { + jobRoot: root, traceIdFactory: () => TRACE, poll: true, signal: controller.signal, + sleep: async () => {}, now: () => 0, + }), (error) => error.category === "transport"); +}); + +test("default polling uses a 5s cadence and a resumable 30 minute timeout", async (t) => { + const root = await createTempJobRoot(t); + const counters = { submits: 0, reads: 0 }; + const delays = []; + let clock = 0; + const outcomes = new Array(1_000).fill(5); + await assert.rejects(submitPlatformJob(transientStatusClient(outcomes, counters), "video.text", INPUT, { + jobRoot: root, traceIdFactory: () => TRACE, poll: true, + now: () => clock, + sleep: async (milliseconds) => { delays.push(milliseconds); clock += milliseconds; }, + }), (error) => error.category === "timeout" && error.details.timeout_ms === 30 * 60 * 1_000 + && error.details.id === "9"); + assert.ok(delays.every((delay) => delay === 5_000)); + assert.equal(clock, 30 * 60 * 1_000); + assert.equal(counters.submits, 1); +}); diff --git a/test/unit/platform-operations.test.js b/test/unit/platform-operations.test.js index a945803..2fd3bd8 100644 --- a/test/unit/platform-operations.test.js +++ b/test/unit/platform-operations.test.js @@ -17,7 +17,11 @@ const successShapes = JSON.parse(fs.readFileSync( "utf8", )); -const MUSIC_MV_DOCUMENTATION_URL = "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc"; +// audio.verify has no public page yet; Music MV is documented as One-click VibeMV. +const MUSIC_MV_DOCUMENTATION_URLS = { + "audio.verify": "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc", + "agent.music-mv": "https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0", +}; const MUSIC_MV_OPERATION_IDS = new Set(["audio.verify", "agent.music-mv"]); test("Platform catalog matches every independently recorded official operation", () => { @@ -54,7 +58,7 @@ test("every operation links to its exact official documentation page", () => { const publicUrls = []; for (const operation of PLATFORM_OPERATIONS) { if (MUSIC_MV_OPERATION_IDS.has(operation.id)) { - assert.equal(operation.documentationUrl, MUSIC_MV_DOCUMENTATION_URL); + assert.equal(operation.documentationUrl, MUSIC_MV_DOCUMENTATION_URLS[operation.id]); } else { assert.equal(new URL(operation.documentationUrl).hostname, "docs.platform.pixverse.ai"); publicUrls.push(operation.documentationUrl); diff --git a/test/unit/platform-validation.test.js b/test/unit/platform-validation.test.js index 4899019..0add4e3 100644 --- a/test/unit/platform-validation.test.js +++ b/test/unit/platform-validation.test.js @@ -305,7 +305,8 @@ test("Music MV rejects ambiguous aliases, unsupported enums, invalid switches, s await assert.rejects(normalizeAndValidatePlatformInput(operation, input), pattern); } - for (const mv_agent_type of ["vibe_mv", "vibe_mv_v3_custom"]) { + await assert.rejects(normalizeAndValidatePlatformInput(operation, { ...base, mv_agent_type: "vibe_mv" }), /mv_agent_type/); + for (const mv_agent_type of ["vibe_mv_v3", "vibe_mv_v3_custom"]) { const result = await normalizeAndValidatePlatformInput(operation, { ...base, mv_agent_type }); assert.equal(result.payload.mv_agent_type, mv_agent_type); } @@ -357,8 +358,8 @@ const distinguishingFieldCases = [ }, "template_id"], ["video.sound-effect", { source_video_id: "1", - prompt: "Soft rain and distant thunder", - }, "prompt"], + sound_effect_content: "Soft rain and distant thunder", + }, "source_video_id"], ]; for (const [id, validInput, distinguishingField] of distinguishingFieldCases) { @@ -473,7 +474,20 @@ test("official multi-transition order and bounds are validated", async () => { await assert.rejects(normalizeAndValidatePlatformInput(operation, { ...base, multi_transition: [{ img_id: "1", duration: 0 }, { img_id: "2" }], - }), /duration.*positive/i); + }), /duration.*1 to 8/i); + // Documented bounds: 1-8 per item, 1-5 with three or more items, last item 0 or omitted. + await assert.rejects(normalizeAndValidatePlatformInput(operation, { + ...base, + multi_transition: [{ img_id: "1", duration: 9 }, { img_id: "2" }], + }), /duration.*1 to 8/i); + await assert.rejects(normalizeAndValidatePlatformInput(operation, { + ...base, + multi_transition: [{ img_id: "1", duration: 6 }, { img_id: "2", duration: 5 }, { img_id: "3" }], + }), /duration.*1 to 5/i); + await assert.rejects(normalizeAndValidatePlatformInput(operation, { + ...base, + multi_transition: [{ img_id: "1", duration: 3 }, { img_id: "2", duration: 4 }], + }), /last.*0 or omitted/i); }); test("official mutually-exclusive source and lip-sync combinations are validated", async () => { @@ -503,11 +517,11 @@ test("swap and swap-mask accept positive keyframe identifiers after string-safe }); assert.equal(mask.payload.keyframe_id, "12"); - const firstFrameMask = await normalizeAndValidatePlatformInput(getPlatformOperation("video.swap-mask"), { + // The documented keyframe range starts at 1 for swap-mask as well as swap. + await assert.rejects(normalizeAndValidatePlatformInput(getPlatformOperation("video.swap-mask"), { source_video_id: "5", keyframe_id: 0, - }); - assert.equal(firstFrameMask.payload.keyframe_id, "0"); + }), /keyframe_id/); }); test("keyframe identifiers apply endpoint-specific zero rules and reject invalid values", async () => { @@ -554,11 +568,11 @@ test("checked-in tiny media fixtures are non-empty and viral-agent local referen agent_id: "414562414124109", prompt: "Recreate this rhythm without copying branding.", quality: "720p", - img_references: [{ file: "/safe/tiny.png" }], - video_references: [{ file: "/safe/tiny.mp4" }], + img_references: [{ img_id: "11", file: "/safe/tiny.png" }], + video_references: [{ video_media_id: "12", file: "/safe/tiny.mp4" }], }; const calls = []; - await normalizeAndValidatePlatformInput(operation, input, { + const validated = await normalizeAndValidatePlatformInput(operation, input, { inspectLocalMedia: async (file, options) => { calls.push({ file, options }); return file.endsWith(".png") @@ -569,6 +583,18 @@ test("checked-in tiny media fixtures are non-empty and viral-agent local referen assert.equal(calls.length, 2); assert.equal(calls[0].options.maxBytes, 20 * 1024 * 1024); assert.equal(calls[1].options.maxBytes, 100 * 1024 * 1024); + // Local paths only drive the media checks; the API receives the uploaded IDs. + assert.deepEqual(validated.payload.img_references, [{ img_id: "11" }]); + assert.deepEqual(validated.payload.video_references, [{ video_media_id: "12" }]); + await assert.rejects(normalizeAndValidatePlatformInput(operation, { + ...input, + img_references: [{ img_url: "https://cdn.example.test/a.png" }], + }), /img_references\[0\] requires an img_id/); + await assert.rejects(normalizeAndValidatePlatformInput(operation, { + ...input, + img_references: [{ img_id: "11" }], + video_references: [{ video_url: "https://cdn.example.test/a.mp4" }], + }), /video_references\[0\]/); await assert.rejects(normalizeAndValidatePlatformInput(operation, input, { inspectLocalMedia: async (file) => file.endsWith(".png") @@ -576,3 +602,59 @@ test("checked-in tiny media fixtures are non-empty and viral-agent local referen : { size_bytes: 100, width: 1080, height: 1080, duration_seconds: 31 }, }), /duration.*2.*30/i); }); + +test("validators follow the documented field rules found in the API doc review", async () => { + const validate = (id, input) => normalizeAndValidatePlatformInput(getPlatformOperation(id), input); + + // Template prompts may be empty because the template supplies them. + const template = await validate("video.template", { + template_id: "302325299692608", img_id: "1", model: "v6", duration: 5, quality: "720p", + }); + assert.equal(template.payload.prompt, ""); + + // Sound effects describe the sound with sound_effect_content; prompt is not an API field. + await validate("video.sound-effect", { video_media_id: "1" }); + await assert.rejects(validate("video.sound-effect", { video_media_id: "1", prompt: "Rain" }), /sound_effect_content/); + await assert.rejects(validate("video.sound-effect", { video_media_id: "1", original_sound_switch: "yes" }), /boolean/); + + // Music MV: aspect_ratio and quality are optional, and the style lists match the public doc. + const mvBase = { mv_agent_type: "vibe_mv_v3_custom", audio_media_id: "1" }; + await validate("agent.music-mv", mvBase); + await validate("agent.music-mv", { ...mvBase, music_style: "Hip Hop", mv_style: "Golden Age" }); + await assert.rejects(validate("agent.music-mv", { ...mvBase, mv_style: "Vintage" }), /mv_style/); + await assert.rejects(validate("agent.music-mv", { + ...mvBase, mv_style: "Cinematic", style_img_references: [{ img_id: "2" }], + }), /style_img_references.*Custom/); + + // Transition only reads first_frame_img/last_frame_img. + await assert.rejects(validate("video.transition", { + prompt: "Morph", model: "v6", duration: 5, quality: "720p", first_frame_url: "https://cdn.example.test/a.png", last_frame_img: "2", + }), /first_frame_img is required/); + + // Avatar TTS scripts must be 30-200 characters. + await assert.rejects(validate("video.avatar", { + img_id: "1", quality: "720p", lip_sync_tts_speaker_id: "auto", lip_sync_tts_content: "Too short.", + }), /30 to 200/); + + // v6 omni fusion may reference only videos, at most two. + const omni = { model: "v6", reference_mode: "omni", prompt: "Recreate @video1", duration: 0, quality: "720p", aspect_ratio: "auto" }; + await validate("video.fusion", { ...omni, video_references: [{ video_media_id: "1" }] }); + await assert.rejects(validate("video.fusion", { + ...omni, video_references: [{ video_media_id: "1" }, { video_media_id: "2" }, { video_media_id: "3" }], + }), /two reference videos/); + + // Extend is not available on c1; real-estate pro-only fields are rejected for fast. + await assert.rejects(validate("video.extend", { + source_video_id: "1", prompt: "Continue", seed: 1, quality: "720p", duration: 5, model: "c1", + }), /extend model/); + await assert.rejects(validate("agent.real-estate", { + agent_id: "419629433597950", model: "fast", product_url: "https://listing.example.test/home", video_style: "cozy", + }), /video_style.*pro/); + + // Template list query values are documented as strings. + const templates = await validate("resource.templates", { page: "1", pageSize: "50" }); + assert.equal(templates.query.pageSize, "50"); + + // Restyle seeds share the documented 32-bit ceiling. + await assert.rejects(validate("video.restyle", { video_media_id: "1", restyle_id: "2", seed: 2_147_483_648 }), /seed/); +}); diff --git a/test/unit/platform-webhooks.test.js b/test/unit/platform-webhooks.test.js index 4e39194..5a2affb 100644 --- a/test/unit/platform-webhooks.test.js +++ b/test/unit/platform-webhooks.test.js @@ -5,6 +5,7 @@ import test from "node:test"; import { createMemoryNonceStore, createPlatformWebhookHandler, + DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS, parsePlatformWebhook, verifyPlatformWebhook, } from "../../src/platform/webhooks.js"; @@ -96,7 +97,11 @@ for (const [name, configure] of [ }); } -for (const [name, timestamp] of [["stale", TIMESTAMP - 301], ["future", TIMESTAMP + 301]]) { +for (const [name, timestamp, options] of [ + ["stale (default 6h window)", TIMESTAMP - (6 * 60 * 60) - 1, {}], + ["stale (configured window)", TIMESTAMP - 301, { timestampToleranceSeconds: 300 }], + ["future", TIMESTAMP + 301, {}], +]) { test(`rejects a ${name} timestamp`, async () => { await assert.rejects(verifyPlatformWebhook({ rawBody: RAW_BODY, @@ -104,20 +109,122 @@ for (const [name, timestamp] of [["stale", TIMESTAMP - 301], ["future", TIMESTAM secret: SIGNING_KEY, now: () => TIMESTAMP, nonceStore: createMemoryNonceStore(), + ...options, }), (error) => error.code === "WEBHOOK_TIMESTAMP_OUT_OF_RANGE"); }); } -test("rejects nonce replay, including concurrent verification", async () => { +test("default timestamp window accepts the documented retry schedule reusing the original timestamp", async () => { + // 2s + 10s + 30s + 1m + 5m + 15m + 1h + 4h after the original attempt. + const lastRetryAge = 2 + 10 + 30 + 60 + 300 + 900 + 3_600 + 14_400; + const verified = await verifyPlatformWebhook({ + rawBody: RAW_BODY, + headers: signedHeaders(RAW_BODY, { timestamp: TIMESTAMP - lastRetryAge }), + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }); + assert.equal(await verified.release(), true); + assert.equal(DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS, 6 * 60 * 60); + assert.ok(DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS > lastRetryAge); +}); + +test("timestamp tolerance is configurable and validated", async () => { + const input = { + rawBody: RAW_BODY, + headers: signedHeaders(RAW_BODY, { timestamp: TIMESTAMP - 100 }), + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }; + await assert.rejects( + verifyPlatformWebhook({ ...input, timestampToleranceSeconds: 99 }), + (error) => error.code === "WEBHOOK_TIMESTAMP_OUT_OF_RANGE", + ); + const verified = await verifyPlatformWebhook({ ...input, timestampToleranceSeconds: 100 }); + assert.equal(await verified.release(), true); + for (const invalid of [0, -1, 1.5, "300", 8 * 24 * 60 * 60]) { + await assert.rejects( + verifyPlatformWebhook({ ...input, timestampToleranceSeconds: invalid }), + (error) => error.code === "WEBHOOK_CONFIGURATION_INVALID", + ); + assert.throws( + () => createPlatformWebhookHandler({ + secret: SIGNING_KEY, onDelivery: async () => {}, timestampToleranceSeconds: invalid, + }), + (error) => error.code === "WEBHOOK_CONFIGURATION_INVALID", + ); + } +}); + +test("nonce is held for the whole timestamp window", async () => { + const nonceStore = createMemoryNonceStore(); + const first = await verifyPlatformWebhook({ + rawBody: RAW_BODY, headers: signedHeaders(), secret: SIGNING_KEY, now: () => TIMESTAMP, nonceStore, + }); + assert.equal(await first.commit(), true); + await assert.rejects(verifyPlatformWebhook({ + rawBody: RAW_BODY, + headers: signedHeaders(), + secret: SIGNING_KEY, + now: () => TIMESTAMP + DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS, + nonceStore, + }), (error) => error.code === "WEBHOOK_REPLAY" && error.duplicate === true); +}); + +test("blocks concurrent verification of an in-flight nonce and flags committed replays as duplicates", async () => { const nonceStore = createMemoryNonceStore(); const attempt = () => verifyPlatformWebhook({ rawBody: RAW_BODY, headers: signedHeaders(), secret: SIGNING_KEY, now: () => TIMESTAMP, nonceStore, }); const results = await Promise.allSettled([attempt(), attempt(), attempt()]); assert.equal(results.filter(({ status }) => status === "fulfilled").length, 1); - assert.equal(results.filter(({ status, reason }) => status === "rejected" && reason.code === "WEBHOOK_REPLAY").length, 2); + assert.equal(results.filter(({ status, reason }) => status === "rejected" + && reason.code === "WEBHOOK_DELIVERY_IN_PROGRESS" && reason.statusCode === 409).length, 2); const [{ value }] = results.filter(({ status }) => status === "fulfilled"); - assert.equal(await value.release(), true); + assert.equal(await value.commit(), true); + await assert.rejects(attempt(), (error) => error.code === "WEBHOOK_REPLAY" + && error.duplicate === true && error.statusCode === 200); +}); + +test("deduplicates a redelivered event by payload id and status even with a fresh nonce", async () => { + const nonceStore = createMemoryNonceStore(); + const success = JSON.stringify({ id: "123456789", status: 1, url: "https://example.test/v.mp4" }); + const verify = (rawBody, nonce) => verifyPlatformWebhook({ + rawBody, headers: signedHeaders(rawBody, { nonce }), secret: SIGNING_KEY, now: () => TIMESTAMP, nonceStore, + }); + const first = await verify(success, "nonce-a"); + assert.equal(await first.commit(), true); + await assert.rejects(verify(success, "nonce-b"), (error) => error.code === "WEBHOOK_DUPLICATE_EVENT" + && error.duplicate === true && error.statusCode === 200); + // The rejected redelivery released its own nonce reservation. + await assert.rejects(verify(success, "nonce-b"), (error) => error.code === "WEBHOOK_DUPLICATE_EVENT"); + + // A different status for the same id is a new event. + const failed = JSON.stringify({ id: "123456789", status: 8, url: null }); + const next = await verify(failed, "nonce-c"); + assert.equal(await next.commit(), true); + + // A released (failed) delivery leaves the event open for the retry. + const other = JSON.stringify({ id: "987", status: 1 }); + const attempt = await verify(other, "nonce-d"); + assert.equal(await attempt.release(), true); + const retry = await verify(other, "nonce-e"); + assert.equal(await retry.commit(), true); +}); + +test("an in-flight event blocks a concurrent redelivery with a different nonce", async () => { + const nonceStore = createMemoryNonceStore(); + const rawBody = JSON.stringify({ id: "55", status: 1 }); + const verify = (nonce) => verifyPlatformWebhook({ + rawBody, headers: signedHeaders(rawBody, { nonce }), secret: SIGNING_KEY, now: () => TIMESTAMP, nonceStore, + }); + const first = await verify("first"); + await assert.rejects(verify("second"), (error) => error.code === "WEBHOOK_DELIVERY_IN_PROGRESS"); + assert.equal(await first.release(), true); + // The losing attempt released its nonce, so its retry proceeds normally. + const retry = await verify("second"); + assert.equal(await retry.commit(), true); }); test("bounds and expires the in-memory nonce store", () => { @@ -141,7 +248,7 @@ test("supports byte bodies, Headers, Date clocks, and Set replay stores", async }; const reserved = await verifyPlatformWebhook(input); assert.equal(reserved.nonce, "nonce-1"); - await assert.rejects(verifyPlatformWebhook(input), (error) => error.code === "WEBHOOK_REPLAY"); + await assert.rejects(verifyPlatformWebhook(input), (error) => error.code === "WEBHOOK_DELIVERY_IN_PROGRESS"); assert.equal(await reserved.release(), true); const committed = await verifyPlatformWebhook(input); assert.equal(await committed.commit(), true); @@ -158,7 +265,7 @@ test("standalone verification requires an explicit commit or release", async () nonceStore, }; const first = await verifyPlatformWebhook(input); - await assert.rejects(verifyPlatformWebhook(input), (error) => error.code === "WEBHOOK_REPLAY"); + await assert.rejects(verifyPlatformWebhook(input), (error) => error.code === "WEBHOOK_DELIVERY_IN_PROGRESS"); assert.equal(await first.release(), true); const retry = await verifyPlatformWebhook(input); assert.equal(await retry.commit(), true); @@ -207,9 +314,8 @@ test("matches independent official-style insertion-order and sorted signature ve } }); -test("rejects composite and null values absent from the official canonicalization", async () => { +test("rejects composite values absent from the official canonicalization", async () => { for (const [nonce, rawBody] of [ - ["null-value", '{"id":"1","value":null}'], ["array-value", '{"id":"1","value":[1,2]}'], ["object-value", '{"id":"1","value":{"nested":true}}'], ]) { @@ -223,6 +329,65 @@ test("rejects composite and null values absent from the official canonicalizatio } }); +test("accepts null values signed as the official Node example serializes them", async () => { + const rawBody = '{"id":"123456789","status":8,"url":null,"has_audio":false}'; + // Node example: new URLSearchParams(entries.map(([k, v]) => [k, String(v)])) -> url=null + const query = "id=123456789&status=8&url=null&has_audio=false"; + const signature = createHmac("sha256", SIGNING_KEY) + .update(`${TIMESTAMP}\nnull-node\n${query}`) + .digest("base64"); + const verified = await verifyPlatformWebhook({ + rawBody, + headers: { "Webhook-Timestamp": String(TIMESTAMP), "Webhook-Nonce": "null-node", "Webhook-Signature": signature }, + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }); + assert.equal(verified.payload.url, null); + assert.equal(await verified.release(), true); + + // Go url.Values.Encode (sorted, fmt %v -> ) is accepted too. + const goQuery = "has_audio=false&id=123456789&status=8&url=%3Cnil%3E"; + const goSignature = createHmac("sha256", SIGNING_KEY) + .update(`${TIMESTAMP}\nnull-go\n${goQuery}`) + .digest("base64"); + const goVerified = await verifyPlatformWebhook({ + rawBody, + headers: { "Webhook-Timestamp": String(TIMESTAMP), "Webhook-Nonce": "null-go", "Webhook-Signature": goSignature }, + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }); + assert.equal(await goVerified.release(), true); + + // A null cannot be swapped for a different value under the same signature. + await assert.rejects(verifyPlatformWebhook({ + rawBody: '{"id":"123456789","status":8,"url":"null","has_audio":false}', + headers: { "Webhook-Timestamp": String(TIMESTAMP), "Webhook-Nonce": "null-swap", "Webhook-Signature": goSignature }, + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }), (error) => error.code === "WEBHOOK_SIGNATURE_INVALID"); +}); + +test("sorted signatures use code-point key order, not locale order", async () => { + // localeCompare sorts "a" before "B"; Go/Java byte order sorts "B" first. + const rawBody = '{"a":"1","B":"2","_c":"3"}'; + const byteOrderQuery = "B=2&_c=3&a=1"; + assert.notEqual(["a", "B", "_c"].sort((l, r) => l.localeCompare(r)).join(), "B,_c,a"); + const signature = createHmac("sha256", SIGNING_KEY) + .update(`${TIMESTAMP}\nbyte-order\n${byteOrderQuery}`) + .digest("base64"); + const verified = await verifyPlatformWebhook({ + rawBody, + headers: { "Webhook-Timestamp": String(TIMESTAMP), "Webhook-Nonce": "byte-order", "Webhook-Signature": signature }, + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }); + assert.equal(await verified.release(), true); +}); + test("fails closed for invalid replay, clock, and header inputs", async () => { const base = { rawBody: RAW_BODY, secret: SIGNING_KEY, now: () => TIMESTAMP }; await assert.rejects( From e7353fa12840911c53538ffe84b2a6e9ab84d0eb Mon Sep 17 00:00:00 2001 From: Scout Field Date: Sat, 10 Oct 2026 19:49:31 -0700 Subject: [PATCH 2/3] fix(platform): send fusion video_references and image.template img_ids as integers Live API checks showed both schemas are wrong: quoted IDs in fusion video_references[] and image.template img_ids[] are rejected with ErrCode 400013, while integers are accepted, matching the v6 video reference and image template guide examples. Co-Authored-By: Claude Opus 5.5 --- .../references/operation-catalog.md | 2 +- .../references/workflows-and-recovery.md | 2 +- src/platform/request.js | 15 +++++++++++---- test/contract/platform-client.contract.test.js | 12 ++++++++++++ .../fixtures/platform/image.template/request.json | 2 +- 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.agents/skills/pixverse-platform-api/references/operation-catalog.md b/.agents/skills/pixverse-platform-api/references/operation-catalog.md index 13cadb5..f1c40f8 100644 --- a/.agents/skills/pixverse-platform-api/references/operation-catalog.md +++ b/.agents/skills/pixverse-platform-api/references/operation-catalog.md @@ -14,7 +14,7 @@ Every documented Platform API endpoint currently covered by `pixverse-api platfo | operation:resource.restyle-effects | `platform resource restyle-effects` | GET | `/openapi/v2/video/restyle/list` | read-only | no | none | Live page filters before choosing `restyle_id` | https://docs.platform.pixverse.ai/restyle-effect-list-21992862e0 | | operation:voice.create | `platform voice create` | POST | `/openapi/v2/video/tts_speaker` | non-billable | no | none | Voice name plus uploaded audio `media_id` string | https://docs.platform.pixverse.ai/create-custom-voice-40528181e0 | | operation:voice.delete | `platform voice delete` | DELETE | `/openapi/v2/video/tts_speaker/{speaker_id}` | non-billable | no | none | Known custom `speaker_id` string | https://docs.platform.pixverse.ai/delete-custom-voice-40528243e0 | -| operation:image.template | `platform image template` | POST | `/openapi/v2/image/template/generate` | billable | yes | `Resp.image_id` | Uploaded `img_ids` strings plus live `template_id` | https://docs.platform.pixverse.ai/image-template-generation-27564921e0 | +| operation:image.template | `platform image template` | POST | `/openapi/v2/image/template/generate` | billable | yes | `Resp.image_id` | Uploaded `img_ids` plus live `template_id` | https://docs.platform.pixverse.ai/image-template-generation-27564921e0 | | operation:image.status | `platform image status` | GET | `/openapi/v2/image/result/{image_id}` | read-only | no | `Resp.image_id` | Known `image_id` string; never resubmit generation to poll | https://docs.platform.pixverse.ai/get-image-generation-27565028e0 | | operation:video.text | `platform video text` | POST | `/openapi/v2/video/text/generate` | billable | yes | `Resp.video_id` | Prompt plus compatible model/duration/quality/aspect ratio | https://docs.platform.pixverse.ai/text-to-video-generation-13016634e0 | | operation:video.image | `platform video image` | POST | `/openapi/v2/video/img/generate` | billable | yes | `Resp.video_id` | Uploaded `img_id` string plus prompt/model/duration/quality | https://docs.platform.pixverse.ai/image-to-video-generation-13016633e0 | diff --git a/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md b/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md index b0ac418..7145f71 100644 --- a/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md +++ b/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md @@ -38,7 +38,7 @@ Platform billable commands wait for a terminal result by default. To return imme `image_references` is accepted as a documented input alias and normalized to `img_references`. Do not send both fields. Lip sync requires recognizable vocals; audio verification does not authorize an automatic paid submission. -Keep identifiers as decimal strings in payload files and artifacts. The request layer losslessly serializes every field an operation's API reference types as integer (for example `img_id`, `img_ids`, `template_id`, `source_video_id`, `video_media_id`, `audio_media_id`, `keyframe_id`, `restyle_id`, `agent_id`, and reference `img_id` values) as JSON number tokens, because the endpoints reject quoted identifiers with ErrCode 400013. Fields the reference types as string (`mask_id`, `mask_ids`, `lip_sync_tts_speaker_id`, `webhook_id`) stay quoted. +Keep identifiers as decimal strings in payload files and artifacts. The request layer losslessly serializes every field an operation's API reference types as integer, plus fusion `video_references` IDs and `image.template` `img_ids`, which the schemas list as string but the live API only accepts as integers (for example `img_id`, `img_ids`, `template_id`, `source_video_id`, `video_media_id`, `audio_media_id`, `keyframe_id`, `restyle_id`, `agent_id`, and reference `img_id` values) as JSON number tokens, because the endpoints reject quoted identifiers with ErrCode 400013. Fields the reference types as string (`mask_id`, `mask_ids`, `lip_sync_tts_speaker_id`, `webhook_id`) stay quoted. ### Durable artifact meanings diff --git a/src/platform/request.js b/src/platform/request.js index a3c4d7f..9db00c9 100644 --- a/src/platform/request.js +++ b/src/platform/request.js @@ -58,20 +58,27 @@ export async function buildPlatformRequest(operation, normalizedInput) { // Fields each operation's API reference types as integer. The validators keep // identifiers as decimal strings so 64-bit values stay exact; they are turned // back into JSON integers here. Fields typed as string (mask_id, mask_ids, -// lip_sync_tts_speaker_id, webhook_id, fusion video_references, image.template -// img_ids) are deliberately absent. +// lip_sync_tts_speaker_id, webhook_id) are deliberately absent. const VIDEO_SOURCE_IDS = ["source_video_id", "video_media_id"]; const INTEGER_ID_FIELDS = new Map(Object.entries({ "audio.verify": ["audio_media_id"], "voice.create": ["audio_media_id"], - "image.template": ["template_id"], + // The schema types img_ids items as string, but the live API rejects quoted + // values (400013), matching the image template guide's integer examples. + "image.template": ["template_id", "img_ids[]"], "video.text": ["template_id"], "video.image": ["img_id", "template_id"], "video.template": ["img_id", "img_ids[]", "template_id"], "video.transition": ["first_frame_img", "last_frame_img"], "video.multi-transition": ["multi_transition[].img_id"], "video.lip-sync": [...VIDEO_SOURCE_IDS, "audio_media_id"], - "video.fusion": ["image_references[].img_id"], + // The fusion schema types video_references IDs as string, but the live API + // rejects quoted values (400013), matching the v6 video reference guide. + "video.fusion": [ + "image_references[].img_id", + "video_references[].source_video_id", + "video_references[].video_media_id", + ], "video.avatar": ["img_id", "audio_media_id"], "video.restyle": [...VIDEO_SOURCE_IDS, "restyle_id"], "video.swap-mask": [...VIDEO_SOURCE_IDS, "keyframe_id"], diff --git a/test/contract/platform-client.contract.test.js b/test/contract/platform-client.contract.test.js index 5849155..c03fc13 100644 --- a/test/contract/platform-client.contract.test.js +++ b/test/contract/platform-client.contract.test.js @@ -275,6 +275,18 @@ test("Music MV uint64 identifiers stay strings internally but serialize as lossl assert.equal(fusionRequest.body, '{"model":"v6","image_references":[{"img_id":892421142,"ref_name":"front"},{"img_id":892421144,"ref_name":"side"}],"prompt":"@front @side walk.","duration":15,"quality":"1080p","aspect_ratio":"9:16","generate_audio_switch":true}'); assert.equal(fusionInput.payload.image_references[0].img_id, "892421142"); + // Live API checks: fusion video_references IDs must be integers even though + // the schema says string (quoted values are rejected with 400013). + const fusionVideoRequest = await buildPlatformRequest({ + id: "video.fusion", method: "POST", path: "/fusion", bodyMode: "json", + }, { + payload: { video_references: [{ source_video_id: "429187502447488", ref_name: "video1" }] }, + query: {}, + pathParams: {}, + files: {}, + }); + assert.equal(fusionVideoRequest.body, '{"video_references":[{"source_video_id":429187502447488,"ref_name":"video1"}]}'); + // Fields the reference types as string stay strings even next to integer IDs. const modifyRequest = await buildPlatformRequest({ id: "video.modify", method: "POST", path: "/modify", bodyMode: "json", diff --git a/test/fixtures/platform/image.template/request.json b/test/fixtures/platform/image.template/request.json index 558bcaf..adca85c 100644 --- a/test/fixtures/platform/image.template/request.json +++ b/test/fixtures/platform/image.template/request.json @@ -9,7 +9,7 @@ "path": "/openapi/v2/image/template/generate", "body": { "img_ids": [ - "629000000000000001" + 629000000000000001 ], "template_id": 42 } From a3b7b8c122fc57c51cdaf2c907d6bba25e32a18b Mon Sep 17 00:00:00 2001 From: Scout Field Date: Sat, 10 Oct 2026 21:48:36 -0700 Subject: [PATCH 3/3] docs(growth-studio): state that folder targeting is optional Co-Authored-By: Claude Opus 5.5 --- docs/api/command-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/command-reference.md b/docs/api/command-reference.md index 6e9ce11..4efc325 100644 --- a/docs/api/command-reference.md +++ b/docs/api/command-reference.md @@ -188,7 +188,7 @@ pixverse-api growth-studio video list [--limit 20] [--status succeeded] [--curso pixverse-api growth-studio video edit ``` -Folder options for creation are mutually exclusive targeting choices: +Folders are optional. Without a folder option (and without `video.folder_id` in the payload) no `folder_id` is sent, nothing is listed or created, and the video is filed at the workspace root. Folder options for creation are mutually exclusive targeting choices: - `--folder-id ` uses a verified existing folder. - `--folder-name ` reuses an exact (trimmed) name match or creates the folder with that name unchanged.