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

342 lines
13 KiB
TypeScript

/**
* Vertex AI media generation client.
*
* Google's Vertex AI serves speech (Gemini TTS), transcription (Gemini), music
* (Lyria) and video (Veo) — but through the same `aiplatform.googleapis.com`
* surface that the chat executor authenticates against, NOT through the
* third-party media registries (kie/suno/deepgram/…). This module reuses the
* Vertex chat executor's auth (Service Account JSON → OAuth bearer, or Express
* API key) and implements the verified per-model contracts:
*
* - Speech: `{model}:generateContent` + responseModalities:["AUDIO"] → PCM L16 → WAV
* - Transcription: `{model}:generateContent` with inline audio + text prompt → text
* - Music (Lyria): `{model}:predict` → predictions[0].bytesBase64Encoded (WAV)
* - Video (Veo): `{model}:predictLongRunning` → poll `{model}:fetchPredictOperation`
* → response.videos[0].bytesBase64Encoded (MP4)
*/
import { Buffer } from "node:buffer";
import { sleep } from "../utils/sleep.ts";
import {
parseSAFromApiKey,
getAccessToken,
looksLikeServiceAccountJson,
isExpressApiKey,
} from "./vertex.ts";
export interface VertexMediaCredentials {
apiKey?: string | null;
accessToken?: string | null;
providerSpecificData?: Record<string, unknown> | null;
}
interface ResolvedVertexAuth {
project: string;
region: string;
bearerToken: string | null;
expressKey: string | null;
}
const DEFAULT_REGION = "us-central1";
function resolveRegion(credentials: VertexMediaCredentials | null | undefined): string {
const psd = credentials?.providerSpecificData;
if (psd && typeof psd === "object") {
const region = (psd as Record<string, unknown>).region;
if (typeof region === "string" && region.trim().length > 0) return region.trim();
}
return DEFAULT_REGION;
}
async function resolveVertexAuth(
credentials: VertexMediaCredentials | null | undefined
): Promise<ResolvedVertexAuth> {
const apiKey = typeof credentials?.apiKey === "string" ? credentials.apiKey.trim() : "";
const region = resolveRegion(credentials);
let bearerToken =
typeof credentials?.accessToken === "string" && credentials.accessToken.trim().length > 0
? credentials.accessToken.trim()
: null;
let project = "";
let expressKey: string | null = null;
if (looksLikeServiceAccountJson(apiKey)) {
const sa = parseSAFromApiKey(apiKey);
project = typeof sa.project_id === "string" ? sa.project_id : "";
if (!bearerToken) bearerToken = await getAccessToken(sa);
} else if (isExpressApiKey(apiKey)) {
expressKey = apiKey;
}
return { project, region, bearerToken, expressKey };
}
/**
* Build the request URL + headers for a Vertex publisher-model action.
* SA path → project-scoped regional endpoint + Bearer auth.
* Express path (best-effort) → project-less global publisher endpoint + ?key=.
*/
function buildModelRequest(
auth: ResolvedVertexAuth,
model: string,
action: string
): { url: string; headers: Record<string, string> } {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (auth.bearerToken && auth.project) {
headers["Authorization"] = `Bearer ${auth.bearerToken}`;
return {
url: `https://${auth.region}-aiplatform.googleapis.com/v1/projects/${auth.project}/locations/${auth.region}/publishers/google/models/${model}:${action}`,
headers,
};
}
if (auth.expressKey) {
return {
url: `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${action}?key=${encodeURIComponent(
auth.expressKey
)}`,
headers,
};
}
throw new Error(
"Vertex AI requires a Service Account JSON (with project_id) or a Vertex AI Express API key"
);
}
interface VertexHttpError extends Error {
status?: number;
}
async function vertexError(res: Response): Promise<VertexHttpError> {
let detail = "";
try {
detail = await res.text();
} catch {
/* ignore */
}
let message = `Vertex AI error (${res.status})`;
if (detail) {
try {
const parsed = JSON.parse(detail);
message = parsed?.error?.message || message;
} catch {
message = detail.slice(0, 300);
}
}
const err = new Error(message) as VertexHttpError;
err.status = res.status;
return err;
}
/** Wrap raw little-endian 16-bit PCM mono samples in a minimal WAV container. */
export function pcmToWav(pcm: Buffer, sampleRate = 24000, channels = 1, bitsPerSample = 16): Buffer {
const blockAlign = (channels * bitsPerSample) / 8;
const byteRate = sampleRate * blockAlign;
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36 + pcm.length, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20); // PCM
header.writeUInt16LE(channels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(bitsPerSample, 34);
header.write("data", 36);
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
function parseSampleRate(mimeType: string | undefined): number {
if (!mimeType) return 24000;
const match = /rate=(\d+)/i.exec(mimeType);
return match ? parseInt(match[1], 10) : 24000;
}
function extractInlineAudio(
data: unknown
): { base64: string; mimeType: string } | null {
const parts = (data as { candidates?: Array<{ content?: { parts?: unknown[] } }> })?.candidates?.[0]
?.content?.parts;
if (!Array.isArray(parts)) return null;
for (const part of parts) {
const inline = (part as { inlineData?: { data?: unknown; mimeType?: unknown } })?.inlineData;
if (inline && typeof inline.data === "string" && inline.data.length > 0) {
return {
base64: inline.data,
mimeType: typeof inline.mimeType === "string" ? inline.mimeType : "audio/L16;rate=24000",
};
}
}
return null;
}
function extractText(data: unknown): string {
const parts = (data as { candidates?: Array<{ content?: { parts?: unknown[] } }> })?.candidates?.[0]
?.content?.parts;
if (!Array.isArray(parts)) return "";
return parts
.map((part) => (part as { text?: unknown })?.text)
.filter((text): text is string => typeof text === "string")
.join("")
.trim();
}
/** Gemini TTS → WAV audio buffer. */
export async function vertexGenerateSpeech(
credentials: VertexMediaCredentials,
options: { model: string; input: string; voice?: string }
): Promise<{ audio: Buffer; contentType: string }> {
const auth = await resolveVertexAuth(credentials);
const { url, headers } = buildModelRequest(auth, options.model, "generateContent");
const payload = {
contents: [{ role: "user", parts: [{ text: options.input }] }],
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName: options.voice && options.voice.trim() ? options.voice.trim() : "Kore" },
},
},
},
};
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
if (!res.ok) throw await vertexError(res);
const data = await res.json();
const inline = extractInlineAudio(data);
if (!inline) throw new Error("Vertex TTS returned no audio content");
const pcm = Buffer.from(inline.base64, "base64");
return { audio: pcmToWav(pcm, parseSampleRate(inline.mimeType)), contentType: "audio/wav" };
}
/** Gemini transcription (audio → text). `audioBase64` is the raw file bytes, base64-encoded. */
export async function vertexTranscribe(
credentials: VertexMediaCredentials,
options: { model: string; audioBase64: string; mimeType?: string; prompt?: string; language?: string }
): Promise<string> {
const auth = await resolveVertexAuth(credentials);
const { url, headers } = buildModelRequest(auth, options.model, "generateContent");
const instruction =
options.prompt && options.prompt.trim().length > 0
? options.prompt.trim()
: `Transcribe this audio verbatim. Output only the spoken words${
options.language ? ` (language: ${options.language})` : ""
}, with no commentary.`;
const payload = {
contents: [
{
role: "user",
parts: [
{ text: instruction },
{ inlineData: { mimeType: options.mimeType || "audio/wav", data: options.audioBase64 } },
],
},
],
};
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
if (!res.ok) throw await vertexError(res);
return extractText(await res.json());
}
/** Lyria music generation → { base64 WAV, format }. */
export async function vertexGenerateMusic(
credentials: VertexMediaCredentials,
options: { model?: string; prompt: string; negativePrompt?: string; sampleCount?: number; seed?: number }
): Promise<{ base64: string; format: string }> {
const auth = await resolveVertexAuth(credentials);
const model = options.model && options.model.trim() ? options.model.trim() : "lyria-002";
const { url, headers } = buildModelRequest(auth, model, "predict");
const instance: Record<string, unknown> = { prompt: options.prompt };
if (options.negativePrompt) instance.negative_prompt = options.negativePrompt;
if (typeof options.seed === "number") instance.seed = options.seed;
const parameters: Record<string, unknown> = {};
if (typeof options.sampleCount === "number") parameters.sample_count = options.sampleCount;
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ instances: [instance], parameters }),
});
if (!res.ok) throw await vertexError(res);
const data = await res.json();
const base64 = (data as { predictions?: Array<{ bytesBase64Encoded?: unknown }> })?.predictions?.[0]
?.bytesBase64Encoded;
if (typeof base64 !== "string" || base64.length === 0) {
throw new Error("Vertex Lyria returned no audio");
}
return { base64, format: "wav" };
}
/** Veo video generation (async long-running) → { base64 MP4 or gcsUri, format }. */
export async function vertexGenerateVideo(
credentials: VertexMediaCredentials,
options: {
model: string;
prompt: string;
aspectRatio?: string;
durationSeconds?: number;
sampleCount?: number;
negativePrompt?: string;
image?: { bytesBase64Encoded: string; mimeType: string };
pollIntervalMs?: number;
maxWaitMs?: number;
}
): Promise<{ base64?: string; url?: string; format: string }> {
const auth = await resolveVertexAuth(credentials);
const submit = buildModelRequest(auth, options.model, "predictLongRunning");
const instance: Record<string, unknown> = { prompt: options.prompt };
if (options.image) instance.image = options.image;
const parameters: Record<string, unknown> = {
sampleCount: typeof options.sampleCount === "number" ? options.sampleCount : 1,
};
if (options.aspectRatio) parameters.aspectRatio = options.aspectRatio;
if (typeof options.durationSeconds === "number") parameters.durationSeconds = options.durationSeconds;
if (options.negativePrompt) parameters.negativePrompt = options.negativePrompt;
const submitRes = await fetch(submit.url, {
method: "POST",
headers: submit.headers,
body: JSON.stringify({ instances: [instance], parameters }),
});
if (!submitRes.ok) throw await vertexError(submitRes);
const op = await submitRes.json();
const operationName = (op as { name?: unknown })?.name;
if (typeof operationName !== "string" || operationName.length === 0) {
throw new Error("Vertex Veo did not return an operation name");
}
const poll = buildModelRequest(auth, options.model, "fetchPredictOperation");
const intervalMs = options.pollIntervalMs && options.pollIntervalMs > 0 ? options.pollIntervalMs : 10000;
const maxWaitMs = options.maxWaitMs && options.maxWaitMs > 0 ? options.maxWaitMs : 5 * 60 * 1000;
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
await sleep(intervalMs);
const pollRes = await fetch(poll.url, {
method: "POST",
headers: poll.headers,
body: JSON.stringify({ operationName }),
});
if (!pollRes.ok) throw await vertexError(pollRes);
const pollData = await pollRes.json();
if ((pollData as { done?: unknown })?.done) {
const opError = (pollData as { error?: { message?: unknown } })?.error;
if (opError) throw new Error(String(opError.message || "Veo operation failed"));
const videos = (pollData as { response?: { videos?: unknown } })?.response?.videos;
const video = Array.isArray(videos) ? (videos[0] as Record<string, unknown>) : null;
if (video && typeof video.bytesBase64Encoded === "string") {
return { base64: video.bytesBase64Encoded, format: "mp4" };
}
if (video && typeof video.gcsUri === "string") {
return { url: video.gcsUri, format: "mp4" };
}
throw new Error("Veo operation completed but returned no video");
}
}
throw new Error("Vertex Veo video generation timed out");
}