Files
2026-07-13 13:39:12 +08:00

220 lines
8.6 KiB
TypeScript

/**
* Google Flow (labs.google/flow) video generation — pure helpers.
*
* Google Flow drives Veo video generation through Google's internal AI Sandbox
* endpoint (`aisandbox-pa.googleapis.com`) using a Google account OAuth bearer
* token — the same Google OAuth credential family that the Antigravity provider
* already uses (accessToken + Cloud Code projectId). The request/response shape
* mirrors the *documented* Veo `predictLongRunning` long-running-operation API
* (see `open-sse/executors/vertexMedia.ts::vertexGenerateVideo`):
*
* submit → { instances: [{ prompt, image? }], parameters: { sampleCount, ... } } → { name: <operation> }
* poll → { operationName } → { done, error?, response: { videos: [{ bytesBase64Encoded | gcsUri }] } }
*
* ⚠️ PENDING LIVE VALIDATION (Hard Rule #18): the exact AI-Sandbox URL path and
* whether Flow wraps the Veo body in the Cloud-Code `{ project, request }` envelope
* cannot be unit-tested — they require a real Google Flow account + a captured HAR.
* Everything that depends on the wire host/path is isolated in the two constants
* below and in the handler, so confirming a captured HAR is a one-line change.
* The transformation logic in this file is grounded in the documented Veo shape
* and is fully unit-tested.
*/
/**
* Google Flow has no standalone connection — it reuses the Antigravity Google
* OAuth credential (accessToken + Cloud Code projectId). Credential lookups for
* the `googleflow` provider resolve against this provider id.
*/
export const GOOGLE_FLOW_CREDENTIAL_PROVIDER = "antigravity";
/** Map a video provider id to the provider id whose stored credentials it uses. */
export function resolveVideoCredentialProvider(provider: string): string {
return provider === "googleflow" ? GOOGLE_FLOW_CREDENTIAL_PROVIDER : provider;
}
// --- Wire endpoint (isolated; confirm against a real Flow HAR — Rule #18) ---
export const GOOGLE_FLOW_HOST = "https://aisandbox-pa.googleapis.com";
export const GOOGLE_FLOW_SUBMIT_PATH = "/v1:generateVideo";
export const GOOGLE_FLOW_POLL_PATH = "/v1:fetchOperation";
export interface FlowVideoParams {
prompt: string;
aspectRatio?: string;
durationSeconds?: number;
sampleCount: number;
negativePrompt?: string;
resolution?: string;
}
export interface FlowOperationResult {
done: boolean;
error?: string;
base64?: string;
url?: string;
format?: string;
}
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
const ASPECT_RATIO_RE = /^\d{1,2}:\d{1,2}$/;
/**
* Normalize an OpenAI-style /v1/videos/generations body into Veo parameters.
* Accepts both snake_case (OpenAI) and camelCase (native) field names, and
* treats a `size` that looks like a ratio (e.g. "16:9") as the aspect ratio.
*/
export function normalizeFlowVideoParams(body: Record<string, unknown> | null | undefined): FlowVideoParams {
const b = body ?? {};
const prompt = typeof b.prompt === "string" ? b.prompt : String(b.prompt ?? "");
const sizeMaybeRatio = asTrimmedString(b.size);
const aspectRatio =
asTrimmedString(b.aspect_ratio) ??
asTrimmedString(b.aspectRatio) ??
(sizeMaybeRatio && ASPECT_RATIO_RE.test(sizeMaybeRatio) ? sizeMaybeRatio : undefined);
const durationRaw =
typeof b.duration === "number"
? b.duration
: typeof b.durationSeconds === "number"
? b.durationSeconds
: undefined;
const durationSeconds =
typeof durationRaw === "number" && Number.isFinite(durationRaw) && durationRaw > 0
? Math.floor(durationRaw)
: undefined;
const nRaw = typeof b.n === "number" ? b.n : undefined;
const sampleCount =
typeof nRaw === "number" && Number.isFinite(nRaw) && nRaw > 0 ? Math.floor(nRaw) : 1;
return {
prompt,
aspectRatio,
durationSeconds,
sampleCount,
negativePrompt: asTrimmedString(b.negative_prompt) ?? asTrimmedString(b.negativePrompt),
resolution: asTrimmedString(b.resolution),
};
}
/**
* Build the documented Veo `predictLongRunning` request body for the submit call.
* Only includes optional parameters that were actually provided.
*/
export function buildGoogleFlowSubmitBody(params: FlowVideoParams): {
instances: Array<Record<string, unknown>>;
parameters: Record<string, unknown>;
} {
const parameters: Record<string, unknown> = { sampleCount: params.sampleCount };
if (params.aspectRatio) parameters.aspectRatio = params.aspectRatio;
if (typeof params.durationSeconds === "number") parameters.durationSeconds = params.durationSeconds;
if (params.negativePrompt) parameters.negativePrompt = params.negativePrompt;
if (params.resolution) parameters.resolution = params.resolution;
return {
instances: [{ prompt: params.prompt }],
parameters,
};
}
/**
* Extract the long-running-operation name from a submit response.
* Tolerates both `{ name }` and `{ operation: { name } }` envelopes.
*/
export function parseFlowOperationName(json: unknown): string | null {
if (!json || typeof json !== "object") return null;
const direct = (json as { name?: unknown }).name;
if (typeof direct === "string" && direct.length > 0) return direct;
const op = (json as { operation?: { name?: unknown } }).operation;
if (op && typeof op === "object" && typeof op.name === "string" && op.name.length > 0) {
return op.name;
}
return null;
}
function extractVideoFromResponse(response: unknown): { base64?: string; url?: string } | null {
if (!response || typeof response !== "object") return null;
// Documented Veo LRO shape: response.videos[].bytesBase64Encoded | gcsUri
const videos = (response as { videos?: unknown }).videos;
if (Array.isArray(videos) && videos.length > 0) {
const v = videos[0];
if (v && typeof v === "object") {
const rec = v as Record<string, unknown>;
if (typeof rec.bytesBase64Encoded === "string") return { base64: rec.bytesBase64Encoded };
if (typeof rec.gcsUri === "string") return { url: rec.gcsUri };
if (typeof rec.uri === "string") return { url: rec.uri };
}
}
// Alternate Veo shape: response.generateVideoResponse.generatedSamples[].video.uri
const gen = (response as { generateVideoResponse?: { generatedSamples?: unknown } })
.generateVideoResponse;
const samples = gen && typeof gen === "object" ? gen.generatedSamples : undefined;
if (Array.isArray(samples) && samples.length > 0) {
const sample = samples[0];
const video =
sample && typeof sample === "object"
? (sample as { video?: unknown }).video
: undefined;
if (video && typeof video === "object") {
const rec = video as Record<string, unknown>;
if (typeof rec.uri === "string") return { url: rec.uri };
if (typeof rec.bytesBase64Encoded === "string") return { base64: rec.bytesBase64Encoded };
}
}
return null;
}
/**
* Interpret a poll/fetch-operation response into a normalized result.
* `done: false` means still running; callers should keep polling.
*/
export function parseFlowOperationResult(json: unknown): FlowOperationResult {
if (!json || typeof json !== "object") return { done: false };
const done = Boolean((json as { done?: unknown }).done);
if (!done) return { done: false };
const opError = (json as { error?: { message?: unknown } }).error;
if (opError && typeof opError === "object") {
return { done: true, error: String(opError.message || "Google Flow video operation failed") };
}
const response = (json as { response?: unknown }).response;
const video = extractVideoFromResponse(response);
if (video?.base64) return { done: true, base64: video.base64, format: "mp4" };
if (video?.url) return { done: true, url: video.url, format: "mp4" };
return { done: true, error: "Google Flow operation completed but returned no video" };
}
/** Resolve the Cloud Code projectId from the OAuth credential record (mirrors Antigravity). */
export function resolveFlowProjectId(
credentials: Record<string, unknown> | null | undefined
): string | null {
const cred = credentials ?? {};
const direct = asTrimmedString(cred.projectId);
if (direct) return direct;
const psd = cred.providerSpecificData;
if (psd && typeof psd === "object") {
const fromPsd = asTrimmedString((psd as Record<string, unknown>).projectId);
if (fromPsd) return fromPsd;
}
return null;
}
/** Resolve the OAuth bearer token from the credential record. */
export function resolveFlowAccessToken(
credentials: Record<string, unknown> | null | undefined
): string | null {
const cred = credentials ?? {};
return asTrimmedString(cred.accessToken) ?? asTrimmedString(cred.apiKey) ?? null;
}