Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
27
extensions/pixverse/README.md
Normal file
27
extensions/pixverse/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# @openclaw/pixverse-provider
|
||||
|
||||
Official PixVerse video generation provider plugin for OpenClaw.
|
||||
|
||||
This plugin registers PixVerse as a `video_generate` provider for text-to-video and image-to-video workflows.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/pixverse-provider
|
||||
```
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
## Configure
|
||||
|
||||
Store your PixVerse API key in OpenClaw config or expose the supported environment variable to the Gateway. Then select PixVerse as a video generation provider.
|
||||
|
||||
Full setup and model/provider examples:
|
||||
|
||||
- https://docs.openclaw.ai/providers/pixverse
|
||||
|
||||
## Package
|
||||
|
||||
- Plugin id: `pixverse`
|
||||
- Package: `@openclaw/pixverse-provider`
|
||||
- Minimum OpenClaw host: `2026.5.26`
|
||||
13
extensions/pixverse/constants.ts
Normal file
13
extensions/pixverse/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Pixverse plugin module implements constants behavior.
|
||||
export const PIXVERSE_PROVIDER_ID = "pixverse";
|
||||
|
||||
export const PIXVERSE_BASE_URL_BY_REGION = {
|
||||
international: "https://app-api.pixverse.ai/openapi/v2",
|
||||
cn: "https://app-api.pixverseai.cn/openapi/v2",
|
||||
} as const;
|
||||
|
||||
export type PixVerseApiRegion = keyof typeof PIXVERSE_BASE_URL_BY_REGION;
|
||||
|
||||
export const DEFAULT_PIXVERSE_REGION = "international" satisfies PixVerseApiRegion;
|
||||
export const DEFAULT_PIXVERSE_MODEL_ID = "v6";
|
||||
export const PIXVERSE_DEFAULT_VIDEO_MODEL_REF = `${PIXVERSE_PROVIDER_ID}/${DEFAULT_PIXVERSE_MODEL_ID}`;
|
||||
197
extensions/pixverse/index.test.ts
Normal file
197
extensions/pixverse/index.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// Pixverse tests cover index plugin behavior.
|
||||
import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
PIXVERSE_BASE_URL_BY_REGION,
|
||||
PIXVERSE_DEFAULT_VIDEO_MODEL_REF,
|
||||
PIXVERSE_PROVIDER_ID,
|
||||
} from "./constants.js";
|
||||
import plugin from "./index.js";
|
||||
import { applyPixVerseConfig, applyPixVerseProviderConfig } from "./onboard.js";
|
||||
|
||||
function registerPixVerseProvider() {
|
||||
const captured = capturePluginRegistration(plugin);
|
||||
expect(captured.videoGenerationProviders.map((provider) => provider.id)).toEqual([
|
||||
PIXVERSE_PROVIDER_ID,
|
||||
]);
|
||||
const provider = captured.providers[0];
|
||||
if (!provider) {
|
||||
throw new Error("expected PixVerse setup provider");
|
||||
}
|
||||
expect(provider.id).toBe(PIXVERSE_PROVIDER_ID);
|
||||
return provider;
|
||||
}
|
||||
|
||||
function createRuntimeContext(region: "international" | "cn") {
|
||||
const select = vi.fn(async (params: { message: string }) => {
|
||||
expect(params.message).toBe("Select PixVerse API region");
|
||||
return region;
|
||||
});
|
||||
const ctx = {
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
pixverse: {
|
||||
baseUrl: "https://proxy.example/openapi/v2",
|
||||
models: [],
|
||||
params: { quality: "720p" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
prompter: {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
select,
|
||||
multiselect: vi.fn(),
|
||||
text: vi.fn(async () => "pixverse-test-key"),
|
||||
confirm: vi.fn(),
|
||||
progress: vi.fn(() => ({
|
||||
update: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
},
|
||||
runtime: {
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
secretInputMode: "plaintext",
|
||||
isRemote: false,
|
||||
openUrl: vi.fn(),
|
||||
oauth: {
|
||||
createVpsAwareHandlers: vi.fn(),
|
||||
},
|
||||
} as never;
|
||||
return { ctx, select };
|
||||
}
|
||||
|
||||
describe("pixverse plugin", () => {
|
||||
it("registers provider auth for the setup wizard", () => {
|
||||
const provider = registerPixVerseProvider();
|
||||
const auth = provider?.auth?.[0];
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
id: PIXVERSE_PROVIDER_ID,
|
||||
label: "PixVerse",
|
||||
docsPath: "/providers/pixverse",
|
||||
envVars: ["PIXVERSE_API_KEY"],
|
||||
});
|
||||
expect(auth).toMatchObject({
|
||||
id: "api-key",
|
||||
label: "PixVerse API key",
|
||||
kind: "api_key",
|
||||
wizard: {
|
||||
choiceId: "pixverse-api-key",
|
||||
choiceLabel: "PixVerse API key",
|
||||
choiceHint: "Prompts for International or CN endpoint",
|
||||
groupId: "pixverse",
|
||||
groupLabel: "PixVerse",
|
||||
groupHint: "Video generation",
|
||||
onboardingScopes: ["image-generation"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prompts for the PixVerse region and writes provider config", async () => {
|
||||
const provider = registerPixVerseProvider();
|
||||
const auth = provider?.auth?.[0];
|
||||
if (!auth) {
|
||||
throw new Error("expected PixVerse auth method");
|
||||
}
|
||||
|
||||
const { ctx, select } = createRuntimeContext("cn");
|
||||
const result = await auth.run(ctx);
|
||||
const regionSelect = select.mock.calls[0]?.[0];
|
||||
|
||||
expect(regionSelect).toEqual({
|
||||
message: "Select PixVerse API region",
|
||||
initialValue: "international",
|
||||
options: [
|
||||
{
|
||||
value: "international",
|
||||
label: "International",
|
||||
hint: PIXVERSE_BASE_URL_BY_REGION.international,
|
||||
},
|
||||
{
|
||||
value: "cn",
|
||||
label: "CN",
|
||||
hint: PIXVERSE_BASE_URL_BY_REGION.cn,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.profiles).toEqual([
|
||||
{
|
||||
profileId: "pixverse:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: PIXVERSE_PROVIDER_ID,
|
||||
key: "pixverse-test-key",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.configPatch?.models?.providers?.pixverse).toEqual({
|
||||
baseUrl: PIXVERSE_BASE_URL_BY_REGION.cn,
|
||||
models: [],
|
||||
params: { quality: "720p" },
|
||||
region: "cn",
|
||||
});
|
||||
expect(result.defaultModel).toBeUndefined();
|
||||
expect(result.configPatch?.agents?.defaults?.videoGenerationModel).toEqual({
|
||||
primary: PIXVERSE_DEFAULT_VIDEO_MODEL_REF,
|
||||
});
|
||||
expect(result.notes).toEqual([`PixVerse endpoint: CN (${PIXVERSE_BASE_URL_BY_REGION.cn})`]);
|
||||
});
|
||||
|
||||
it("only resets custom baseUrl when a region is explicitly selected", () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
pixverse: {
|
||||
baseUrl: "https://proxy.example/openapi/v2",
|
||||
models: [],
|
||||
params: { quality: "720p" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
applyPixVerseProviderConfig(config, "international").models?.providers?.pixverse,
|
||||
).toEqual({
|
||||
baseUrl: "https://proxy.example/openapi/v2",
|
||||
models: [],
|
||||
params: { quality: "720p" },
|
||||
region: "international",
|
||||
});
|
||||
expect(
|
||||
applyPixVerseProviderConfig(config, "cn", { resetBaseUrl: true }).models?.providers?.pixverse,
|
||||
).toEqual({
|
||||
baseUrl: PIXVERSE_BASE_URL_BY_REGION.cn,
|
||||
models: [],
|
||||
params: { quality: "720p" },
|
||||
region: "cn",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an existing video generation default", () => {
|
||||
const result = applyPixVerseConfig(
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
videoGenerationModel: {
|
||||
primary: "openai/sora-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"international",
|
||||
);
|
||||
|
||||
expect(result.agents?.defaults?.videoGenerationModel).toEqual({
|
||||
primary: "openai/sora-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
21
extensions/pixverse/index.ts
Normal file
21
extensions/pixverse/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Pixverse plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { PIXVERSE_PROVIDER_ID } from "./constants.js";
|
||||
import { buildPixVerseApiKeyAuthMethod } from "./onboard.js";
|
||||
import { buildPixVerseVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: PIXVERSE_PROVIDER_ID,
|
||||
name: "PixVerse Provider",
|
||||
description: "Official external PixVerse video provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PIXVERSE_PROVIDER_ID,
|
||||
label: "PixVerse",
|
||||
docsPath: "/providers/pixverse",
|
||||
envVars: ["PIXVERSE_API_KEY"],
|
||||
auth: [buildPixVerseApiKeyAuthMethod()],
|
||||
});
|
||||
api.registerVideoGenerationProvider(buildPixVerseVideoGenerationProvider());
|
||||
},
|
||||
});
|
||||
20
extensions/pixverse/npm-shrinkwrap.json
generated
Normal file
20
extensions/pixverse/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@openclaw/pixverse-provider",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/pixverse-provider",
|
||||
"version": "2026.6.11",
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
253
extensions/pixverse/onboard.ts
Normal file
253
extensions/pixverse/onboard.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
// Pixverse setup module handles plugin onboarding behavior.
|
||||
import type {
|
||||
ProviderAuthContext,
|
||||
ProviderAuthMethod,
|
||||
ProviderAuthMethodNonInteractiveContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
buildApiKeyCredential,
|
||||
ensureApiKeyFromOptionEnvOrPrompt,
|
||||
normalizeApiKeyInput,
|
||||
normalizeOptionalSecretInput,
|
||||
type OpenClawConfig,
|
||||
type SecretInput,
|
||||
upsertAuthProfileWithLock,
|
||||
validateApiKeyInput,
|
||||
} from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
DEFAULT_PIXVERSE_REGION,
|
||||
PIXVERSE_BASE_URL_BY_REGION,
|
||||
PIXVERSE_DEFAULT_VIDEO_MODEL_REF,
|
||||
PIXVERSE_PROVIDER_ID,
|
||||
type PixVerseApiRegion,
|
||||
} from "./constants.js";
|
||||
|
||||
const PROFILE_ID = `${PIXVERSE_PROVIDER_ID}:default`;
|
||||
|
||||
type PixVerseAuthResult = {
|
||||
profiles: Array<{ profileId: string; credential: ReturnType<typeof buildApiKeyCredential> }>;
|
||||
configPatch: OpenClawConfig;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
|
||||
|
||||
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
|
||||
const updated = await upsertAuthProfileWithLock(params);
|
||||
if (!updated) {
|
||||
throw new Error(
|
||||
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePixVerseRegion(value: unknown): PixVerseApiRegion | undefined {
|
||||
const region = normalizeOptionalString(value)?.toLowerCase();
|
||||
switch (region) {
|
||||
case "cn":
|
||||
case "china":
|
||||
case "mainland":
|
||||
case "pai":
|
||||
return "cn";
|
||||
case "global":
|
||||
case "intl":
|
||||
case "international":
|
||||
return "international";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function pixVerseRegionNote(region: PixVerseApiRegion): string {
|
||||
const label = region === "cn" ? "CN" : "International";
|
||||
return `PixVerse endpoint: ${label} (${PIXVERSE_BASE_URL_BY_REGION[region]})`;
|
||||
}
|
||||
|
||||
export function applyPixVerseProviderConfig(
|
||||
cfg: OpenClawConfig,
|
||||
region: PixVerseApiRegion,
|
||||
options?: { resetBaseUrl?: boolean },
|
||||
): OpenClawConfig {
|
||||
const existingProvider: Partial<ModelProviderConfig> =
|
||||
cfg.models?.providers?.[PIXVERSE_PROVIDER_ID] ?? {};
|
||||
const selectedBaseUrl = PIXVERSE_BASE_URL_BY_REGION[region];
|
||||
const baseUrl = options?.resetBaseUrl
|
||||
? selectedBaseUrl
|
||||
: (normalizeOptionalString(existingProvider.baseUrl) ?? selectedBaseUrl);
|
||||
return {
|
||||
...cfg,
|
||||
models: {
|
||||
...cfg.models,
|
||||
providers: {
|
||||
...cfg.models?.providers,
|
||||
[PIXVERSE_PROVIDER_ID]: {
|
||||
...existingProvider,
|
||||
baseUrl,
|
||||
models: existingProvider.models ?? [],
|
||||
region,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyPixVerseConfig(
|
||||
cfg: OpenClawConfig,
|
||||
region: PixVerseApiRegion,
|
||||
options?: { resetBaseUrl?: boolean },
|
||||
): OpenClawConfig {
|
||||
const next = applyPixVerseProviderConfig(cfg, region, options);
|
||||
if (next.agents?.defaults?.videoGenerationModel) {
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
videoGenerationModel: {
|
||||
primary: PIXVERSE_DEFAULT_VIDEO_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function promptForPixVerseRegion(ctx: ProviderAuthContext): Promise<PixVerseApiRegion> {
|
||||
return await ctx.prompter.select<PixVerseApiRegion>({
|
||||
message: "Select PixVerse API region",
|
||||
initialValue: DEFAULT_PIXVERSE_REGION,
|
||||
options: [
|
||||
{
|
||||
value: "international",
|
||||
label: "International",
|
||||
hint: PIXVERSE_BASE_URL_BY_REGION.international,
|
||||
},
|
||||
{
|
||||
value: "cn",
|
||||
label: "CN",
|
||||
hint: PIXVERSE_BASE_URL_BY_REGION.cn,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function runPixVerseApiKeyAuth(ctx: ProviderAuthContext): Promise<PixVerseAuthResult> {
|
||||
let capturedSecretInput: SecretInput | undefined;
|
||||
let capturedCredential = false;
|
||||
let capturedMode: "plaintext" | "ref" | undefined;
|
||||
|
||||
await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token:
|
||||
normalizeOptionalSecretInput(ctx.opts?.pixverseApiKey) ??
|
||||
normalizeOptionalSecretInput(ctx.opts?.token),
|
||||
tokenProvider: normalizeOptionalSecretInput(ctx.opts?.pixverseApiKey)
|
||||
? PIXVERSE_PROVIDER_ID
|
||||
: normalizeOptionalSecretInput(ctx.opts?.tokenProvider),
|
||||
secretInputMode:
|
||||
ctx.allowSecretRefPrompt === false
|
||||
? (ctx.secretInputMode ?? "plaintext")
|
||||
: ctx.secretInputMode,
|
||||
config: ctx.config,
|
||||
env: ctx.env,
|
||||
expectedProviders: [PIXVERSE_PROVIDER_ID],
|
||||
provider: PIXVERSE_PROVIDER_ID,
|
||||
envLabel: "PIXVERSE_API_KEY",
|
||||
promptMessage: "Enter PixVerse API key",
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: ctx.prompter,
|
||||
setCredential: async (apiKey, mode) => {
|
||||
capturedSecretInput = apiKey;
|
||||
capturedCredential = true;
|
||||
capturedMode = mode;
|
||||
},
|
||||
});
|
||||
|
||||
if (!capturedCredential) {
|
||||
throw new Error("Missing PixVerse API key.");
|
||||
}
|
||||
|
||||
const region = await promptForPixVerseRegion(ctx);
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: PROFILE_ID,
|
||||
credential: buildApiKeyCredential(
|
||||
PIXVERSE_PROVIDER_ID,
|
||||
capturedSecretInput ?? "",
|
||||
undefined,
|
||||
capturedMode
|
||||
? {
|
||||
secretInputMode: capturedMode,
|
||||
config: ctx.config,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
},
|
||||
],
|
||||
configPatch: applyPixVerseConfig(ctx.config, region, { resetBaseUrl: true }),
|
||||
notes: [pixVerseRegionNote(region)],
|
||||
};
|
||||
}
|
||||
|
||||
async function runPixVerseApiKeyAuthNonInteractive(ctx: ProviderAuthMethodNonInteractiveContext) {
|
||||
const resolved = await ctx.resolveApiKey({
|
||||
provider: PIXVERSE_PROVIDER_ID,
|
||||
flagValue: normalizeOptionalSecretInput(ctx.opts.pixverseApiKey),
|
||||
flagName: "--pixverse-api-key",
|
||||
envVar: "PIXVERSE_API_KEY",
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (resolved.source !== "profile") {
|
||||
const credential = ctx.toApiKeyCredential({
|
||||
provider: PIXVERSE_PROVIDER_ID,
|
||||
resolved,
|
||||
});
|
||||
if (!credential) {
|
||||
return null;
|
||||
}
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
profileId: PROFILE_ID,
|
||||
credential,
|
||||
agentDir: ctx.agentDir,
|
||||
});
|
||||
}
|
||||
|
||||
const next = applyAuthProfileConfig(ctx.config, {
|
||||
profileId: PROFILE_ID,
|
||||
provider: PIXVERSE_PROVIDER_ID,
|
||||
mode: "api_key",
|
||||
});
|
||||
const explicitRegion = normalizePixVerseRegion(ctx.opts.pixverseRegion);
|
||||
return applyPixVerseConfig(next, explicitRegion ?? DEFAULT_PIXVERSE_REGION, {
|
||||
resetBaseUrl: explicitRegion !== undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildPixVerseApiKeyAuthMethod(): ProviderAuthMethod {
|
||||
return {
|
||||
id: "api-key",
|
||||
label: "PixVerse API key",
|
||||
hint: "Video generation API key",
|
||||
kind: "api_key",
|
||||
wizard: {
|
||||
choiceId: "pixverse-api-key",
|
||||
choiceLabel: "PixVerse API key",
|
||||
choiceHint: "Prompts for International or CN endpoint",
|
||||
groupId: "pixverse",
|
||||
groupLabel: "PixVerse",
|
||||
groupHint: "Video generation",
|
||||
onboardingScopes: ["image-generation"],
|
||||
},
|
||||
run: runPixVerseApiKeyAuth,
|
||||
runNonInteractive: runPixVerseApiKeyAuthNonInteractive,
|
||||
};
|
||||
}
|
||||
37
extensions/pixverse/openclaw.plugin.json
Normal file
37
extensions/pixverse/openclaw.plugin.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"id": "pixverse",
|
||||
"name": "PixVerse",
|
||||
"description": "OpenClaw PixVerse video generation provider plugin.",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providerAuthEnvVars": {
|
||||
"pixverse": ["PIXVERSE_API_KEY"]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "pixverse",
|
||||
"method": "api-key",
|
||||
"choiceId": "pixverse-api-key",
|
||||
"choiceLabel": "PixVerse API key",
|
||||
"choiceHint": "Wizard prompts for International or CN endpoint.",
|
||||
"groupId": "pixverse",
|
||||
"groupLabel": "PixVerse",
|
||||
"groupHint": "Video generation",
|
||||
"onboardingScopes": ["image-generation"],
|
||||
"optionKey": "pixverseApiKey",
|
||||
"cliFlag": "--pixverse-api-key",
|
||||
"cliOption": "--pixverse-api-key <key>",
|
||||
"cliDescription": "PixVerse API key"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"videoGenerationProviders": ["pixverse"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
44
extensions/pixverse/package.json
Normal file
44
extensions/pixverse/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@openclaw/pixverse-provider",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw PixVerse video generation provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/pixverse-provider",
|
||||
"npmSpec": "@openclaw/pixverse-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.26"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
16
extensions/pixverse/tsconfig.json
Normal file
16
extensions/pixverse/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
735
extensions/pixverse/video-generation-provider.test.ts
Normal file
735
extensions/pixverse/video-generation-provider.test.ts
Normal file
@@ -0,0 +1,735 @@
|
||||
// Pixverse tests cover video generation provider plugin behavior.
|
||||
import {
|
||||
getProviderHttpMocks,
|
||||
installProviderHttpMockCleanup,
|
||||
} from "openclaw/plugin-sdk/provider-http-test-mocks";
|
||||
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
postJsonRequestMock,
|
||||
postMultipartRequestMock,
|
||||
fetchWithTimeoutMock,
|
||||
pollProviderOperationJsonMock,
|
||||
resolveProviderHttpRequestConfigMock,
|
||||
sanitizeConfiguredModelProviderRequestMock,
|
||||
} = getProviderHttpMocks();
|
||||
|
||||
let buildPixVerseVideoGenerationProvider: typeof import("./video-generation-provider.js").buildPixVerseVideoGenerationProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ buildPixVerseVideoGenerationProvider } = await import("./video-generation-provider.js"));
|
||||
});
|
||||
|
||||
installProviderHttpMockCleanup();
|
||||
|
||||
function firstPostJsonRequest() {
|
||||
const [call] = postJsonRequestMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected PixVerse create request");
|
||||
}
|
||||
return call[0] as { url?: string; body?: Record<string, unknown>; headers?: Headers };
|
||||
}
|
||||
|
||||
function firstMultipartRequest() {
|
||||
const [call] = postMultipartRequestMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected PixVerse image upload request");
|
||||
}
|
||||
return call[0] as { url?: string; body?: FormData; headers?: Headers };
|
||||
}
|
||||
|
||||
function firstPollRequest() {
|
||||
const [call] = pollProviderOperationJsonMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected PixVerse status poll request");
|
||||
}
|
||||
return call[0] as {
|
||||
url?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
dispatcherPolicy?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
function pollFetchHeaders(callIndex: number): Headers | undefined {
|
||||
const [, init] = fetchWithTimeoutMock.mock.calls[callIndex] ?? [];
|
||||
return (init as { headers?: Headers } | undefined)?.headers;
|
||||
}
|
||||
|
||||
function streamedJsonResponse(payload: unknown): Response {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(JSON.stringify(payload)));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Drives an unbounded JSON body (>16 MiB, no Content-Length) so the bounded
|
||||
// reader has to cancel the stream instead of buffering it all. A hard ceiling
|
||||
// guards the test from hanging if the reader ever fails to cancel.
|
||||
function oversizedJsonResponse(): {
|
||||
response: Response;
|
||||
state: { canceled: boolean; enqueuedBytes: number };
|
||||
} {
|
||||
const state = { canceled: false, enqueuedBytes: 0 };
|
||||
const chunk = 1024 * 1024;
|
||||
const maxChunks = 64; // 64 MiB ceiling, 4x the 16 MiB cap.
|
||||
let emitted = 0;
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
if (emitted >= maxChunks) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
emitted += 1;
|
||||
state.enqueuedBytes += chunk;
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
},
|
||||
cancel() {
|
||||
state.canceled = true;
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
return { response, state };
|
||||
}
|
||||
|
||||
describe("pixverse video generation provider", () => {
|
||||
it("declares explicit mode capabilities", () => {
|
||||
expectExplicitVideoGenerationCapabilities(buildPixVerseVideoGenerationProvider());
|
||||
});
|
||||
|
||||
it("submits text-to-video, polls status, and returns the output URL", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: {
|
||||
id: 123,
|
||||
status: 1,
|
||||
url: "https://media.pixverse.ai/out.mp4",
|
||||
seed: 42,
|
||||
outputWidth: 960,
|
||||
outputHeight: 540,
|
||||
},
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "pixverse/v6",
|
||||
prompt: "a tiny lobster DJ under neon lights",
|
||||
cfg: {},
|
||||
durationSeconds: 4,
|
||||
aspectRatio: "21:9",
|
||||
resolution: "720P",
|
||||
audio: true,
|
||||
providerOptions: {
|
||||
seed: 42,
|
||||
negativePrompt: "blur",
|
||||
cameraMovement: "zoom_in",
|
||||
},
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledTimes(1);
|
||||
const createRequest = firstPostJsonRequest();
|
||||
expect(createRequest.url).toBe("https://app-api.pixverse.ai/openapi/v2/video/text/generate");
|
||||
expect(createRequest.body).toEqual({
|
||||
duration: 4,
|
||||
model: "v6",
|
||||
prompt: "a tiny lobster DJ under neon lights",
|
||||
quality: "720p",
|
||||
aspect_ratio: "21:9",
|
||||
negative_prompt: "blur",
|
||||
camera_movement: "zoom_in",
|
||||
seed: 42,
|
||||
generate_audio_switch: true,
|
||||
});
|
||||
expect(createRequest.headers?.get("API-KEY")).toBe("provider-key");
|
||||
expect(createRequest.headers?.get("Ai-trace-id")).toMatch(/^[0-9a-f-]{36}$/u);
|
||||
expect(fetchWithTimeoutMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://app-api.pixverse.ai/openapi/v2/video/result/123",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
videos: [
|
||||
{
|
||||
url: "https://media.pixverse.ai/out.mp4",
|
||||
mimeType: "video/mp4",
|
||||
fileName: "video-1.mp4",
|
||||
metadata: {
|
||||
sourceUrl: "https://media.pixverse.ai/out.mp4",
|
||||
outputWidth: 960,
|
||||
outputHeight: 540,
|
||||
},
|
||||
},
|
||||
],
|
||||
model: "v6",
|
||||
metadata: {
|
||||
endpoint: "/video/text/generate",
|
||||
videoId: 123,
|
||||
status: 1,
|
||||
seed: 42,
|
||||
size: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("drops malformed seed values before creating videos", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "pixverse/v6",
|
||||
prompt: "a quiet city street at sunrise",
|
||||
cfg: {},
|
||||
providerOptions: {
|
||||
seed: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(firstPostJsonRequest().body).not.toHaveProperty("seed");
|
||||
});
|
||||
|
||||
it("drops malformed response seed metadata", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: {
|
||||
id: 123,
|
||||
status: 1,
|
||||
url: "https://media.pixverse.ai/out.mp4",
|
||||
seed: 1.5,
|
||||
},
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "pixverse/v6",
|
||||
prompt: "a quiet city street at sunrise",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(result.metadata).toEqual({
|
||||
endpoint: "/video/text/generate",
|
||||
videoId: 123,
|
||||
status: 1,
|
||||
seed: undefined,
|
||||
size: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects fractional video ids before polling", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123.5 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "pixverse/v6",
|
||||
prompt: "a quiet city street at sunrise",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("PixVerse video generation response missing video_id");
|
||||
expect(fetchWithTimeoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds an unbounded successful PixVerse create JSON body and cancels the stream", async () => {
|
||||
const oversized = oversizedJsonResponse();
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: oversized.response,
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "pixverse/v6",
|
||||
prompt: "oversized create body",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("PixVerse video generation failed: JSON response exceeds 16777216 bytes");
|
||||
// The bounded reader cancelled the stream rather than buffering the whole
|
||||
// body, and stopped reading well before the 64 MiB ceiling.
|
||||
expect(oversized.state.canceled).toBe(true);
|
||||
expect(oversized.state.enqueuedBytes).toBeLessThan(64 * 1024 * 1024);
|
||||
expect(fetchWithTimeoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds an unbounded successful PixVerse image upload JSON body and cancels the stream", async () => {
|
||||
const oversized = oversizedJsonResponse();
|
||||
postMultipartRequestMock.mockResolvedValue({
|
||||
response: oversized.response,
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "c1",
|
||||
prompt: "oversized upload body",
|
||||
cfg: {},
|
||||
inputImages: [{ buffer: Buffer.from("png-bytes"), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow("PixVerse image upload failed: JSON response exceeds 16777216 bytes");
|
||||
expect(oversized.state.canceled).toBe(true);
|
||||
expect(oversized.state.enqueuedBytes).toBeLessThan(64 * 1024 * 1024);
|
||||
expect(postJsonRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uploads local image input before submitting image-to-video", async () => {
|
||||
postMultipartRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { img_id: 456, img_url: "https://media.pixverse.ai/image.png" },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 789 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 789, status: 1, url: "https://media.pixverse.ai/i2v.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "c1",
|
||||
prompt: "animate the product",
|
||||
cfg: {},
|
||||
inputImages: [{ buffer: Buffer.from("png-bytes"), mimeType: "image/png" }],
|
||||
durationSeconds: 99,
|
||||
providerOptions: {
|
||||
motionMode: "fast",
|
||||
templateId: 302325299692608,
|
||||
},
|
||||
});
|
||||
|
||||
expect(postMultipartRequestMock).toHaveBeenCalledOnce();
|
||||
const uploadRequest = firstMultipartRequest();
|
||||
expect(uploadRequest.url).toBe("https://app-api.pixverse.ai/openapi/v2/image/upload");
|
||||
expect(uploadRequest.headers?.get("Content-Type")).toBeNull();
|
||||
expect(uploadRequest.body?.get("image")).toBeInstanceOf(File);
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledOnce();
|
||||
const createRequest = firstPostJsonRequest();
|
||||
expect(createRequest.url).toBe("https://app-api.pixverse.ai/openapi/v2/video/img/generate");
|
||||
expect(createRequest.body).toEqual({
|
||||
duration: 15,
|
||||
model: "c1",
|
||||
prompt: "animate the product",
|
||||
quality: "540p",
|
||||
img_id: 456,
|
||||
motion_mode: "fast",
|
||||
template_id: 302325299692608,
|
||||
});
|
||||
});
|
||||
|
||||
it("uploads remote image URLs through PixVerse image upload", async () => {
|
||||
postMultipartRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { img_id: 111 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 222 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 222, status: 1, url: "https://media.pixverse.ai/remote.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "animate the remote image",
|
||||
cfg: {},
|
||||
inputImages: [{ url: "https://example.com/input.png" }],
|
||||
});
|
||||
|
||||
const uploadRequest = firstMultipartRequest();
|
||||
expect(uploadRequest.body?.get("image_url")).toBe("https://example.com/input.png");
|
||||
const createRequest = firstPostJsonRequest();
|
||||
expect(createRequest.body?.img_id).toBe(111);
|
||||
});
|
||||
|
||||
it("rejects PixVerse API errors before polling", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 400017,
|
||||
ErrMsg: "Invalid parameter",
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "bad request",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("PixVerse video generation failed: Invalid parameter");
|
||||
expect(fetchWithTimeoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports PixVerse moderation failures from status polling", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 333 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 333, status: 7 },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "moderated request",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("PixVerse video generation failed content moderation");
|
||||
});
|
||||
|
||||
it("uses configured baseUrl", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "custom base",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
pixverse: {
|
||||
baseUrl: "https://proxy.example/openapi/v2",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(firstPostJsonRequest().url).toBe("https://proxy.example/openapi/v2/video/text/generate");
|
||||
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://proxy.example/openapi/v2/video/result/123",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the guarded provider transport for status polling", async () => {
|
||||
const dispatcherPolicy = { mode: "direct" };
|
||||
resolveProviderHttpRequestConfigMock.mockReturnValueOnce({
|
||||
baseUrl: "https://proxy.example/openapi/v2",
|
||||
allowPrivateNetwork: true,
|
||||
headers: new Headers({ "API-KEY": "provider-key", "X-Proxy": "enabled" }),
|
||||
dispatcherPolicy,
|
||||
} as never);
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "custom base",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(firstPostJsonRequest().url).toBe("https://proxy.example/openapi/v2/video/text/generate");
|
||||
expect(firstPostJsonRequest().headers?.get("X-Proxy")).toBe("enabled");
|
||||
expect(firstPollRequest()).toMatchObject({
|
||||
url: "https://proxy.example/openapi/v2/video/result/123",
|
||||
allowPrivateNetwork: true,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
const pollHeaders = pollFetchHeaders(0);
|
||||
expect(pollHeaders?.get("X-Proxy")).toBe("enabled");
|
||||
});
|
||||
|
||||
it("passes configured provider request overrides into the HTTP resolver", async () => {
|
||||
const request = {
|
||||
allowPrivateNetwork: true,
|
||||
headers: { "X-Proxy": "enabled" },
|
||||
};
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "custom request config",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
pixverse: {
|
||||
request,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(sanitizeConfiguredModelProviderRequestMock).toHaveBeenCalledWith(request);
|
||||
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ request }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a fresh trace id for each status poll", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 5 },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "fresh trace ids",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
const firstHeaders = pollFetchHeaders(0);
|
||||
const secondHeaders = pollFetchHeaders(1);
|
||||
expect(firstHeaders?.get("Ai-trace-id")).toMatch(/^[0-9a-f-]{36}$/u);
|
||||
expect(secondHeaders?.get("Ai-trace-id")).toMatch(/^[0-9a-f-]{36}$/u);
|
||||
expect(secondHeaders?.get("Ai-trace-id")).not.toBe(firstHeaders?.get("Ai-trace-id"));
|
||||
});
|
||||
|
||||
it("uses the configured CN API region", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "cn endpoint",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
pixverse: {
|
||||
region: "cn",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(firstPostJsonRequest().url).toBe(
|
||||
"https://app-api.pixverseai.cn/openapi/v2/video/text/generate",
|
||||
);
|
||||
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://app-api.pixverseai.cn/openapi/v2/video/result/123",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers configured baseUrl over API region", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { video_id: 123 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ErrCode: 0,
|
||||
ErrMsg: "success",
|
||||
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
|
||||
}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const provider = buildPixVerseVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "pixverse",
|
||||
model: "v6",
|
||||
prompt: "custom base",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
pixverse: {
|
||||
baseUrl: "https://proxy.example/openapi/v2",
|
||||
region: "cn",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(firstPostJsonRequest().url).toBe("https://proxy.example/openapi/v2/video/text/generate");
|
||||
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://proxy.example/openapi/v2/video/result/123",
|
||||
);
|
||||
});
|
||||
});
|
||||
521
extensions/pixverse/video-generation-provider.ts
Normal file
521
extensions/pixverse/video-generation-provider.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
// Pixverse provider module implements model/runtime integration.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
createProviderOperationDeadline,
|
||||
pollProviderOperationJson,
|
||||
postJsonRequest,
|
||||
postMultipartRequest,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
resolveProviderHttpRequestConfig,
|
||||
sanitizeConfiguredModelProviderRequest,
|
||||
type ProviderOperationDeadline,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
asFiniteNumber,
|
||||
asSafeIntegerInRange,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type {
|
||||
GeneratedVideoAsset,
|
||||
VideoGenerationProvider,
|
||||
VideoGenerationRequest,
|
||||
VideoGenerationSourceAsset,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
import {
|
||||
DEFAULT_PIXVERSE_MODEL_ID,
|
||||
DEFAULT_PIXVERSE_REGION,
|
||||
PIXVERSE_BASE_URL_BY_REGION,
|
||||
PIXVERSE_PROVIDER_ID,
|
||||
type PixVerseApiRegion,
|
||||
} from "./constants.js";
|
||||
|
||||
const DEFAULT_PIXVERSE_BASE_URL = PIXVERSE_BASE_URL_BY_REGION[DEFAULT_PIXVERSE_REGION];
|
||||
const DEFAULT_PIXVERSE_QUALITY = "540p";
|
||||
const DEFAULT_TIMEOUT_MS = 300_000;
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const MAX_POLL_ATTEMPTS = 180;
|
||||
const MAX_DURATION_SECONDS = 15;
|
||||
const PIXVERSE_SEED_MAX = 2_147_483_647;
|
||||
const PIXVERSE_VIDEO_MODELS = ["v6", "c1"] as const;
|
||||
const PIXVERSE_TEXT_ASPECT_RATIOS = [
|
||||
"16:9",
|
||||
"4:3",
|
||||
"1:1",
|
||||
"3:4",
|
||||
"9:16",
|
||||
"2:3",
|
||||
"3:2",
|
||||
"21:9",
|
||||
] as const;
|
||||
const PIXVERSE_QUALITIES = ["360p", "540p", "720p", "1080p"] as const;
|
||||
|
||||
type PixVerseEnvelope<T> = {
|
||||
ErrCode?: unknown;
|
||||
ErrMsg?: unknown;
|
||||
Resp?: T;
|
||||
};
|
||||
|
||||
type PixVerseUploadImageResponse = {
|
||||
img_id?: unknown;
|
||||
img_url?: unknown;
|
||||
};
|
||||
|
||||
type PixVerseVideoCreateResponse = {
|
||||
video_id?: unknown;
|
||||
};
|
||||
|
||||
type PixVerseVideoResultResponse = {
|
||||
id?: unknown;
|
||||
status?: unknown;
|
||||
url?: unknown;
|
||||
outputWidth?: unknown;
|
||||
outputHeight?: unknown;
|
||||
seed?: unknown;
|
||||
size?: unknown;
|
||||
};
|
||||
|
||||
function resolvePixVerseBaseUrl(req: VideoGenerationRequest): string {
|
||||
const provider = req.cfg?.models?.providers?.[PIXVERSE_PROVIDER_ID];
|
||||
const configuredBaseUrl = normalizeOptionalString(provider?.baseUrl);
|
||||
if (configuredBaseUrl) {
|
||||
return configuredBaseUrl;
|
||||
}
|
||||
const region = resolvePixVerseApiRegion(provider?.region);
|
||||
return PIXVERSE_BASE_URL_BY_REGION[region];
|
||||
}
|
||||
|
||||
function resolvePixVerseApiRegion(value: unknown): PixVerseApiRegion {
|
||||
const region = normalizeOptionalString(value)?.toLowerCase();
|
||||
switch (region) {
|
||||
case "cn":
|
||||
case "china":
|
||||
case "mainland":
|
||||
case "pai":
|
||||
return "cn";
|
||||
case "global":
|
||||
case "intl":
|
||||
case "international":
|
||||
case undefined:
|
||||
return DEFAULT_PIXVERSE_REGION;
|
||||
default:
|
||||
throw new Error(`Unsupported PixVerse API region "${region}". Use "international" or "cn".`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePixVerseModel(model: string | undefined): string {
|
||||
const normalized = normalizeOptionalString(model)?.replace(/^pixverse\//iu, "");
|
||||
return normalized?.toLowerCase() || DEFAULT_PIXVERSE_MODEL_ID;
|
||||
}
|
||||
|
||||
function resolvePixVerseQuality(req: VideoGenerationRequest): string {
|
||||
const optionQuality =
|
||||
normalizeOptionalString(req.providerOptions?.quality) ??
|
||||
normalizeOptionalString(req.resolution);
|
||||
const requested = optionQuality ?? normalizeOptionalString(req.size);
|
||||
if (!requested) {
|
||||
return DEFAULT_PIXVERSE_QUALITY;
|
||||
}
|
||||
const normalized = requested.toLowerCase() === "480p" ? "540p" : requested.toLowerCase();
|
||||
return PIXVERSE_QUALITIES.includes(normalized as (typeof PIXVERSE_QUALITIES)[number])
|
||||
? normalized
|
||||
: DEFAULT_PIXVERSE_QUALITY;
|
||||
}
|
||||
|
||||
function resolvePixVerseDurationSeconds(value: number | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return 5;
|
||||
}
|
||||
return Math.max(1, Math.min(MAX_DURATION_SECONDS, Math.round(value)));
|
||||
}
|
||||
|
||||
function appendOptionalNumber(body: Record<string, unknown>, key: string, value: unknown): void {
|
||||
const numberValue = asFiniteNumber(value);
|
||||
if (numberValue != null) {
|
||||
body[key] = numberValue;
|
||||
}
|
||||
}
|
||||
|
||||
function appendOptionalInt32Seed(body: Record<string, unknown>, value: unknown): void {
|
||||
const seed = asSafeIntegerInRange(value, { min: 0, max: PIXVERSE_SEED_MAX });
|
||||
if (seed !== undefined) {
|
||||
body.seed = seed;
|
||||
}
|
||||
}
|
||||
|
||||
function readPixVerseSeed(value: unknown): number | undefined {
|
||||
return asSafeIntegerInRange(value, { min: 0, max: PIXVERSE_SEED_MAX });
|
||||
}
|
||||
|
||||
function appendOptionalString(body: Record<string, unknown>, key: string, value: unknown): void {
|
||||
const stringValue = normalizeOptionalString(value);
|
||||
if (stringValue) {
|
||||
body[key] = stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPixVerseHeaders(headers: Headers, contentType?: string): Headers {
|
||||
const next = new Headers(headers);
|
||||
next.set("Ai-trace-id", randomUUID());
|
||||
if (contentType) {
|
||||
next.set("Content-Type", contentType);
|
||||
} else {
|
||||
next.delete("Content-Type");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function readPixVerseSuccess<T>(payload: PixVerseEnvelope<T>, label: string): T {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error(`${label}: malformed JSON response`);
|
||||
}
|
||||
const code = asFiniteNumber(payload.ErrCode);
|
||||
if (code !== 0) {
|
||||
const message = normalizeOptionalString(payload.ErrMsg) ?? `ErrCode ${String(payload.ErrCode)}`;
|
||||
throw new Error(`${label}: ${message}`);
|
||||
}
|
||||
if (payload.Resp === undefined || payload.Resp === null) {
|
||||
throw new Error(`${label}: response missing Resp`);
|
||||
}
|
||||
return payload.Resp;
|
||||
}
|
||||
|
||||
// Reads a PixVerse JSON response through the shared provider JSON reader so a
|
||||
// provider that streams an unbounded body cannot force the runtime to buffer the
|
||||
// whole payload before parsing it on the success path. The shared helper applies
|
||||
// the established 16 MiB provider JSON cap and the standard malformed-JSON
|
||||
// wrapping; PixVerse envelope validation stays local via readPixVerseSuccess.
|
||||
async function readPixVerseJson<T>(response: Response, label: string): Promise<T> {
|
||||
const payload = await readProviderJsonResponse(response, label);
|
||||
return readPixVerseSuccess(payload as PixVerseEnvelope<T>, label);
|
||||
}
|
||||
|
||||
function readPixVerseVideoId(payload: PixVerseVideoCreateResponse): number {
|
||||
const videoId = asSafeIntegerInRange(payload.video_id, { min: 0 });
|
||||
if (videoId == null) {
|
||||
throw new Error("PixVerse video generation response missing video_id");
|
||||
}
|
||||
return videoId;
|
||||
}
|
||||
|
||||
function readPixVerseImageId(payload: PixVerseUploadImageResponse): number {
|
||||
const imageId = asSafeIntegerInRange(payload.img_id, { min: 0 });
|
||||
if (imageId == null) {
|
||||
throw new Error("PixVerse image upload response missing img_id");
|
||||
}
|
||||
return imageId;
|
||||
}
|
||||
|
||||
function readPixVerseStatus(payload: PixVerseVideoResultResponse): number {
|
||||
const status = asSafeIntegerInRange(payload.status, { min: 0 });
|
||||
if (status == null) {
|
||||
throw new Error("PixVerse video status response missing status");
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function buildUploadImageForm(asset: VideoGenerationSourceAsset): FormData {
|
||||
const form = new FormData();
|
||||
const url = normalizeOptionalString(asset.url);
|
||||
if (url) {
|
||||
form.set("image_url", url);
|
||||
return form;
|
||||
}
|
||||
if (!asset.buffer) {
|
||||
throw new Error("PixVerse image-to-video input is missing image data.");
|
||||
}
|
||||
const mimeType = normalizeOptionalString(asset.mimeType) ?? "image/png";
|
||||
const extension = extensionForMime(mimeType)?.slice(1) ?? "png";
|
||||
const fileName = normalizeOptionalString(asset.fileName) ?? `image.${extension}`;
|
||||
const bytes = new Uint8Array(asset.buffer.byteLength);
|
||||
bytes.set(asset.buffer);
|
||||
form.set("image", new File([bytes], fileName, { type: mimeType }));
|
||||
return form;
|
||||
}
|
||||
|
||||
function buildVideoBody(
|
||||
req: VideoGenerationRequest,
|
||||
model: string,
|
||||
imageId?: number,
|
||||
): Record<string, unknown> {
|
||||
const options = req.providerOptions ?? {};
|
||||
const body: Record<string, unknown> = {
|
||||
duration: resolvePixVerseDurationSeconds(req.durationSeconds),
|
||||
model,
|
||||
prompt: req.prompt,
|
||||
quality: resolvePixVerseQuality(req),
|
||||
};
|
||||
if (imageId !== undefined) {
|
||||
body.img_id = imageId;
|
||||
body.motion_mode =
|
||||
normalizeOptionalString(options.motion_mode) ??
|
||||
normalizeOptionalString(options.motionMode) ??
|
||||
"normal";
|
||||
} else {
|
||||
body.aspect_ratio = normalizeOptionalString(req.aspectRatio) ?? "16:9";
|
||||
}
|
||||
appendOptionalString(
|
||||
body,
|
||||
"negative_prompt",
|
||||
normalizeOptionalString(options.negative_prompt) ??
|
||||
normalizeOptionalString(options.negativePrompt),
|
||||
);
|
||||
appendOptionalString(
|
||||
body,
|
||||
"camera_movement",
|
||||
normalizeOptionalString(options.camera_movement) ??
|
||||
normalizeOptionalString(options.cameraMovement),
|
||||
);
|
||||
appendOptionalNumber(
|
||||
body,
|
||||
"template_id",
|
||||
asFiniteNumber(options.template_id) ?? asFiniteNumber(options.templateId),
|
||||
);
|
||||
appendOptionalInt32Seed(body, options.seed);
|
||||
if (req.audio !== undefined) {
|
||||
body.generate_audio_switch = req.audio;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function readPixVerseFailureMessage(payload: PixVerseVideoResultResponse): string | undefined {
|
||||
switch (readPixVerseStatus(payload)) {
|
||||
case 7:
|
||||
return "PixVerse video generation failed content moderation";
|
||||
case 8:
|
||||
return "PixVerse video generation failed";
|
||||
case 6:
|
||||
return "PixVerse video generation was deleted before completion";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollPixVerseVideo(params: {
|
||||
videoId: number;
|
||||
baseUrl: string;
|
||||
deadline: ProviderOperationDeadline;
|
||||
fetchFn: typeof fetch;
|
||||
allowPrivateNetwork: boolean;
|
||||
dispatcherPolicy?: Parameters<typeof postJsonRequest>[0]["dispatcherPolicy"];
|
||||
headers: Headers;
|
||||
}): Promise<PixVerseVideoResultResponse> {
|
||||
const readResult = (payload: PixVerseEnvelope<PixVerseVideoResultResponse>) =>
|
||||
readPixVerseSuccess(payload, "PixVerse video status request failed");
|
||||
const payload = await pollProviderOperationJson<PixVerseEnvelope<PixVerseVideoResultResponse>>({
|
||||
url: `${params.baseUrl}/video/result/${params.videoId}`,
|
||||
headers: () => buildPixVerseHeaders(params.headers),
|
||||
deadline: params.deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
fetchFn: params.fetchFn,
|
||||
maxAttempts: MAX_POLL_ATTEMPTS,
|
||||
pollIntervalMs: POLL_INTERVAL_MS,
|
||||
requestFailedMessage: "PixVerse video status request failed",
|
||||
timeoutMessage: `PixVerse video generation task ${params.videoId} did not finish in time`,
|
||||
isComplete: (candidate) => readPixVerseStatus(readResult(candidate)) === 1,
|
||||
getFailureMessage: (candidate) => readPixVerseFailureMessage(readResult(candidate)),
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
dispatcherPolicy: params.dispatcherPolicy,
|
||||
});
|
||||
return readResult(payload);
|
||||
}
|
||||
|
||||
function extractPixVerseVideo(payload: PixVerseVideoResultResponse): GeneratedVideoAsset {
|
||||
const url = normalizeOptionalString(payload.url);
|
||||
if (!url) {
|
||||
throw new Error("PixVerse video generation completed without output URL");
|
||||
}
|
||||
return {
|
||||
url,
|
||||
mimeType: "video/mp4",
|
||||
fileName: "video-1.mp4",
|
||||
metadata: {
|
||||
sourceUrl: url,
|
||||
outputWidth: asFiniteNumber(payload.outputWidth),
|
||||
outputHeight: asFiniteNumber(payload.outputHeight),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider {
|
||||
return {
|
||||
id: PIXVERSE_PROVIDER_ID,
|
||||
label: "PixVerse",
|
||||
defaultModel: DEFAULT_PIXVERSE_MODEL_ID,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
models: [...PIXVERSE_VIDEO_MODELS],
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: PIXVERSE_PROVIDER_ID,
|
||||
agentDir,
|
||||
}),
|
||||
capabilities: {
|
||||
generate: {
|
||||
maxVideos: 1,
|
||||
maxDurationSeconds: MAX_DURATION_SECONDS,
|
||||
supportedDurationSeconds: Array.from(
|
||||
{ length: MAX_DURATION_SECONDS },
|
||||
(_, index) => index + 1,
|
||||
),
|
||||
aspectRatios: [...PIXVERSE_TEXT_ASPECT_RATIOS],
|
||||
resolutions: ["360P", "540P", "720P", "1080P"],
|
||||
supportsAspectRatio: true,
|
||||
supportsResolution: true,
|
||||
supportsAudio: true,
|
||||
providerOptions: {
|
||||
seed: "number",
|
||||
negative_prompt: "string",
|
||||
negativePrompt: "string",
|
||||
quality: "string",
|
||||
camera_movement: "string",
|
||||
cameraMovement: "string",
|
||||
template_id: "number",
|
||||
templateId: "number",
|
||||
},
|
||||
},
|
||||
imageToVideo: {
|
||||
enabled: true,
|
||||
maxVideos: 1,
|
||||
maxInputImages: 1,
|
||||
maxDurationSeconds: MAX_DURATION_SECONDS,
|
||||
supportedDurationSeconds: Array.from(
|
||||
{ length: MAX_DURATION_SECONDS },
|
||||
(_, index) => index + 1,
|
||||
),
|
||||
resolutions: ["360P", "540P", "720P", "1080P"],
|
||||
supportsResolution: true,
|
||||
supportsAudio: true,
|
||||
providerOptions: {
|
||||
seed: "number",
|
||||
negative_prompt: "string",
|
||||
negativePrompt: "string",
|
||||
quality: "string",
|
||||
motion_mode: "string",
|
||||
motionMode: "string",
|
||||
camera_movement: "string",
|
||||
cameraMovement: "string",
|
||||
template_id: "number",
|
||||
templateId: "number",
|
||||
},
|
||||
},
|
||||
videoToVideo: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
async generateVideo(req) {
|
||||
if ((req.inputVideos?.length ?? 0) > 0) {
|
||||
throw new Error("PixVerse video generation does not support video reference inputs.");
|
||||
}
|
||||
if ((req.inputImages?.length ?? 0) > 1) {
|
||||
throw new Error("PixVerse image-to-video supports at most one input image.");
|
||||
}
|
||||
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: PIXVERSE_PROVIDER_ID,
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("PixVerse API key missing");
|
||||
}
|
||||
|
||||
const model = normalizePixVerseModel(req.model);
|
||||
const fetchFn = fetch;
|
||||
const providerConfig = req.cfg?.models?.providers?.[PIXVERSE_PROVIDER_ID];
|
||||
const deadline = createProviderOperationDeadline({
|
||||
timeoutMs: req.timeoutMs,
|
||||
label: "PixVerse video generation",
|
||||
});
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: resolvePixVerseBaseUrl(req),
|
||||
defaultBaseUrl: DEFAULT_PIXVERSE_BASE_URL,
|
||||
request: sanitizeConfiguredModelProviderRequest(providerConfig?.request),
|
||||
defaultHeaders: {
|
||||
"API-KEY": auth.apiKey,
|
||||
},
|
||||
provider: PIXVERSE_PROVIDER_ID,
|
||||
capability: "video",
|
||||
transport: "http",
|
||||
});
|
||||
|
||||
const image = req.inputImages?.[0];
|
||||
let imageId: number | undefined;
|
||||
if (image) {
|
||||
const upload = await postMultipartRequest({
|
||||
url: `${baseUrl}/image/upload`,
|
||||
headers: buildPixVerseHeaders(headers),
|
||||
body: buildUploadImageForm(image),
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}),
|
||||
fetchFn,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowHttpError(upload.response, "PixVerse image upload failed");
|
||||
imageId = readPixVerseImageId(
|
||||
await readPixVerseJson<PixVerseUploadImageResponse>(
|
||||
upload.response,
|
||||
"PixVerse image upload failed",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await upload.release();
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = imageId === undefined ? "/video/text/generate" : "/video/img/generate";
|
||||
const create = await postJsonRequest({
|
||||
url: `${baseUrl}${endpoint}`,
|
||||
headers: buildPixVerseHeaders(headers, "application/json"),
|
||||
body: buildVideoBody(req, model, imageId),
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}),
|
||||
fetchFn,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowHttpError(create.response, "PixVerse video generation failed");
|
||||
const videoId = readPixVerseVideoId(
|
||||
await readPixVerseJson<PixVerseVideoCreateResponse>(
|
||||
create.response,
|
||||
"PixVerse video generation failed",
|
||||
),
|
||||
);
|
||||
const completed = await pollPixVerseVideo({
|
||||
videoId,
|
||||
baseUrl,
|
||||
deadline,
|
||||
fetchFn,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
headers,
|
||||
});
|
||||
return {
|
||||
videos: [extractPixVerseVideo(completed)],
|
||||
model,
|
||||
metadata: {
|
||||
endpoint,
|
||||
videoId,
|
||||
status: readPixVerseStatus(completed),
|
||||
seed: readPixVerseSeed(completed.seed),
|
||||
size: asFiniteNumber(completed.size),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await create.release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user