mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(google): download direct veo video uri
This commit is contained in:
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- OpenAI image generation: use `gpt-5.5` for the Codex OAuth responses transport instead of the retired `gpt-5.4` model, fixing 500s from ChatGPT Codex image generation. Fixes #71513. Thanks @baolongl.
|
||||
- Google video generation: download direct MLDev Veo `video.uri` results instead of passing them through the Files API path, fixing 404s after successful generation/polling. Fixes #71200. Thanks @panhaishan.
|
||||
- MiniMax music generation: switch the bundled default model from the unsupported `music-2.5+` id to the current `music-2.6` API model. Fixes #64870 and addresses the music default from #62315. Thanks @noahclanman and @edwardzheng1.
|
||||
- Google media generation: strip a configured trailing `/v1beta` from Google music/video provider base URLs before calling the Google GenAI SDK, preventing doubled `/v1beta/v1beta` paths. Fixes #63240. (#63258) Thanks @Hybirdss.
|
||||
- Discord: restore direct-message voice-note preflight transcription and classify URL-only Ogg/Opus voice attachments as audio while skipping partial attachments without usable URLs. Fixes #61314 and #64803.
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createGoogleGenAIMock, generateVideosMock, getVideosOperationMock } = vi.hoisted(() => {
|
||||
const generateVideosMock = vi.fn();
|
||||
const getVideosOperationMock = vi.fn();
|
||||
const createGoogleGenAIMock = vi.fn(() => {
|
||||
return {
|
||||
models: {
|
||||
generateVideos: generateVideosMock,
|
||||
},
|
||||
operations: {
|
||||
getVideosOperation: getVideosOperationMock,
|
||||
},
|
||||
files: {
|
||||
download: vi.fn(),
|
||||
},
|
||||
};
|
||||
const { createGoogleGenAIMock, downloadMock, generateVideosMock, getVideosOperationMock } =
|
||||
vi.hoisted(() => {
|
||||
const generateVideosMock = vi.fn();
|
||||
const getVideosOperationMock = vi.fn();
|
||||
const downloadMock = vi.fn();
|
||||
const createGoogleGenAIMock = vi.fn(() => {
|
||||
return {
|
||||
models: {
|
||||
generateVideos: generateVideosMock,
|
||||
},
|
||||
operations: {
|
||||
getVideosOperation: getVideosOperationMock,
|
||||
},
|
||||
files: {
|
||||
download: downloadMock,
|
||||
},
|
||||
};
|
||||
});
|
||||
return { createGoogleGenAIMock, downloadMock, generateVideosMock, getVideosOperationMock };
|
||||
});
|
||||
return { createGoogleGenAIMock, generateVideosMock, getVideosOperationMock };
|
||||
});
|
||||
|
||||
vi.mock("./google-genai-runtime.js", () => ({
|
||||
createGoogleGenAI: createGoogleGenAIMock,
|
||||
@@ -30,6 +32,8 @@ import { buildGoogleVideoGenerationProvider } from "./video-generation-provider.
|
||||
describe("google video generation provider", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
downloadMock.mockReset();
|
||||
generateVideosMock.mockReset();
|
||||
getVideosOperationMock.mockReset();
|
||||
createGoogleGenAIMock.mockClear();
|
||||
@@ -139,6 +143,51 @@ describe("google video generation provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("downloads MLDev direct video uri responses without routing through the Files API", async () => {
|
||||
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "google-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
generateVideosMock.mockResolvedValue({
|
||||
done: true,
|
||||
response: {
|
||||
generatedVideos: [
|
||||
{
|
||||
video: {
|
||||
uri: "https://generativelanguage.googleapis.com/v1beta/files/generated-video:download?alt=media",
|
||||
mimeType: "video/mp4",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response("direct-mp4", {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { "content-type": "video/mp4" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildGoogleVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "google",
|
||||
model: "veo-3.1-fast-generate-preview",
|
||||
prompt: "A tiny robot watering a windowsill garden",
|
||||
cfg: {},
|
||||
durationSeconds: 3,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://generativelanguage.googleapis.com/v1beta/files/generated-video:download?alt=media&key=google-key",
|
||||
);
|
||||
expect(downloadMock).not.toHaveBeenCalled();
|
||||
expect(result.videos[0]?.buffer).toEqual(Buffer.from("direct-mp4"));
|
||||
expect(result.videos[0]?.mimeType).toBe("video/mp4");
|
||||
});
|
||||
|
||||
it("does NOT strip /v1beta when it appears mid-path (end-anchor proof)", async () => {
|
||||
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "google-key",
|
||||
|
||||
@@ -150,6 +150,76 @@ async function downloadGeneratedVideo(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGoogleGeneratedVideoDownloadUrl(params: {
|
||||
uri: string | undefined;
|
||||
apiKey: string;
|
||||
configuredBaseUrl?: string;
|
||||
}): string | undefined {
|
||||
const trimmed = normalizeOptionalString(params.uri);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
const allowedOrigins = new Set(["https://generativelanguage.googleapis.com"]);
|
||||
if (params.configuredBaseUrl) {
|
||||
try {
|
||||
const configuredOrigin = new URL(params.configuredBaseUrl).origin;
|
||||
if (configuredOrigin.startsWith("https://")) {
|
||||
allowedOrigins.add(configuredOrigin);
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid configured origins; resolveConfiguredGoogleVideoBaseUrl already normalizes.
|
||||
}
|
||||
}
|
||||
if (!allowedOrigins.has(url.origin)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!url.searchParams.has("key")) {
|
||||
url.searchParams.set("key", params.apiKey);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function downloadGeneratedVideoFromUri(params: {
|
||||
uri: string | undefined;
|
||||
apiKey: string;
|
||||
configuredBaseUrl?: string;
|
||||
mimeType?: string;
|
||||
index: number;
|
||||
}): Promise<GeneratedVideoAsset | undefined> {
|
||||
const downloadUrl = resolveGoogleGeneratedVideoDownloadUrl({
|
||||
uri: params.uri,
|
||||
apiKey: params.apiKey,
|
||||
configuredBaseUrl: params.configuredBaseUrl,
|
||||
});
|
||||
if (!downloadUrl) {
|
||||
return undefined;
|
||||
}
|
||||
const response = await fetch(downloadUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to download Google generated video: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
buffer,
|
||||
mimeType:
|
||||
normalizeOptionalString(response.headers.get("content-type")) ||
|
||||
normalizeOptionalString(params.mimeType) ||
|
||||
"video/mp4",
|
||||
fileName: `video-${params.index + 1}.mp4`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider {
|
||||
return {
|
||||
...createGoogleVideoGenerationProviderMetadata(),
|
||||
@@ -233,6 +303,16 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider {
|
||||
fileName: `video-${index + 1}.mp4`,
|
||||
};
|
||||
}
|
||||
const directDownload = await downloadGeneratedVideoFromUri({
|
||||
uri: inline?.uri,
|
||||
apiKey: auth.apiKey,
|
||||
configuredBaseUrl,
|
||||
mimeType: inline?.mimeType,
|
||||
index,
|
||||
});
|
||||
if (directDownload) {
|
||||
return directDownload;
|
||||
}
|
||||
if (!inline) {
|
||||
throw new Error("Google generated video missing file handle");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user