whiskapi
Version:
Unofficial API for Whisk image generation.
695 lines (658 loc) • 24.5 kB
JavaScript
import { request } from "./utils/request.js";
import { writeFileSync } from "fs";
export default class Whisk {
credentials;
constructor(credentials) {
if (!credentials.cookie || credentials.cookie == "INVALID_COOKIE") {
throw new Error("Cookie is missing or invalid.");
}
// FIXED: Using shallow copy instead of structuredClone
this.credentials = { ...credentials };
}
/**
* Helper method to format the cookie string for HTTP headers.
* Parses the stored JSON cookie string into a 'name=value; name2=value2' format.
* @returns {string} The formatted cookie string.
* @private
*/
#getFormattedCookieString() {
if (!this.credentials.cookie) {
throw new Error("Cookie is not available in credentials for formatting.");
}
try {
const cookiesArray = JSON.parse(this.credentials.cookie);
// Map each cookie object to "name=value" and URL-encode the value
return cookiesArray.map(c => `${c.name}=${encodeURIComponent(c.value)}`).join('; ');
} catch (error) {
throw new Error("Failed to parse cookie JSON or format cookie string: " + error.message);
}
}
/**
* Checks if credentials (especially authorizationKey) are set.
* Fetches authorization token if missing.
* @private
*/
async #checkCredentials() {
if (!this.credentials.cookie) {
throw new Error("Credentials are not set. Please provide a valid cookie.");
}
if (!this.credentials.authorizationKey) {
const resp = await this.getAuthorizationToken();
if (resp.Err || !resp.Ok) {
throw new Error("Failed to get authorization token: " + resp.Err);
}
this.credentials.authorizationKey = resp.Ok;
}
}
/**
* Check if `Whisk` is available in your region.
* This un-availability can be easily bypassed by
* generating authorization token from a region where
* its available. Use VPN with US regions.
*/
async isAvailable() {
const req = {
body: "{}",
method: "POST",
url: "https://aisandbox-pa.googleapis.com/v1:checkAppAvailability",
headers: new Headers({
"Content-Type": "text/plain;charset=UTF-8",
"X-Goog-Api-Key": "AIzaSyBtrm0o5ab1c-Ec8ZuLcGt3oJAA5VWt3pY",
}),
};
const response = await request(req);
if (response.Err || !response.Ok) {
return { Err: response.Err };
}
try {
const responseBody = JSON.parse(response.Ok);
return { Ok: responseBody.availabilityState === "AVAILABLE" };
}
catch (err) {
return { Err: new Error("Failed to parse response: " + response.Ok) };
}
}
/**
* Generates the authorization token for the user.
* This generated token is required to make *most* of API calls.
*/
async getAuthorizationToken() {
if (!this.credentials.cookie) {
return { Err: new Error("Empty or invalid cookies.") };
}
// FIXED: Use the formatted cookie string
const cookieString = this.#getFormattedCookieString();
const req = {
method: "GET",
url: "https://labs.google/fx/api/auth/session",
headers: new Headers({ "Cookie": cookieString }),
};
const resp = await request(req);
if (resp.Err || !resp.Ok) {
return { Err: resp.Err };
}
try {
const parsedResp = JSON.parse(resp.Ok);
const token = parsedResp?.access_token;
if (!token) {
return { Err: new Error("Failed to get session token: " + resp.Ok) };
}
return { Ok: String(token) };
}
catch (err) {
return { Err: new Error("Failed to parse response: " + resp.Ok) };
}
}
/**
* Get the current credit status of the user. This is for `veo` only and not `whisk`.
*/
async getCreditStatus() {
await this.#checkCredentials();
const req = {
method: "POST",
body: JSON.stringify({ "tool": "BACKBONE", "videoModel": "VEO_2_1_I2V" }), // Unknown of other models
url: "https://aisandbox-pa.googleapis.com/v1:GetUserVideoCreditStatusAction",
headers: new Headers({ "Authorization": String(this.credentials.authorizationKey) }),
};
const response = await request(req);
if (response.Err || !response.Ok) {
return { Err: response.Err };
}
try {
const responseBody = JSON.parse(response.Ok);
// Other properties don't seem to be useful
return { Ok: Number(responseBody.credits) };
}
catch (err) {
return { Err: new Error("Failed to parse response: " + response.Ok) };
}
}
/**
* Generates a new project ID (a unique identifier for each project) for the
* given title so that you can start generating images in that specific project.
*
* @param projectTitle The name you want to give to the project.
*/
async getNewProjectId(projectTitle) {
await this.#checkCredentials();
// FIXED: Use the formatted cookie string
const cookieString = this.#getFormattedCookieString();
const reqJson = {
"json": {
"clientContext": {
"tool": "BACKBONE",
"sessionId": ";1748266079775"
},
"workflowMetadata": { "workflowName": projectTitle }
}
};
const req = {
method: "POST",
body: JSON.stringify(reqJson),
url: "https://labs.google/fx/api/trpc/media.createOrUpdateWorkflow",
headers: new Headers({ "Cookie": cookieString }),
};
const resp = await request(req);
if (resp.Err || !resp.Ok) {
return { Err: resp.Err };
}
try {
const parsedResp = JSON.parse(resp.Ok);
const workflowID = parsedResp?.result?.data?.json?.result?.workflowId;
return workflowID ? { Ok: String(workflowID) } : { Err: new Error("Failed to create new library" + resp.Ok) };
}
catch (err) {
return { Err: new Error("Failed to parse response: " + resp.Ok) };
}
}
/**
* Get all of your project history or library.
*
* @param limitCount The number of projects you want to fetch.
*/
async getProjectHistory(limitCount) {
await this.#checkCredentials();
// FIXED: Use the formatted cookie string
const cookieString = this.#getFormattedCookieString();
const reqJson = {
"json": {
"rawQuery": "",
"type": "BACKBONE",
"subtype": "PROJECT",
"limit": limitCount,
"cursor": null
},
"meta": { "values": { "cursor": ["undefined"] } }
};
const req = {
method: "GET",
headers: new Headers({
"Content-Type": "application/json",
"Cookie": cookieString,
}),
url: `https://labs.google/fx/api/trpc/media.fetchUserHistory?input=` + JSON.stringify(reqJson),
};
const resp = await request(req);
if (resp.Err || !resp.Ok) {
return { Err: resp.Err };
}
try {
const parsedResp = JSON.parse(resp.Ok);
const workflowList = parsedResp?.result?.data?.json?.result?.userWorkflows;
if (workflowList && Array.isArray(workflowList)) {
return { Ok: workflowList };
}
return { Err: new Error("Failed to get project history: " + resp.Ok) };
}
catch (err) {
return { Err: new Error("Failed to parse response: " + resp.Ok) };
}
}
/**
* Get the image history of the user.
*
* @param limitCount The number of images you want to fetch.
*/
async getImageHistory(limitCount) {
await this.#checkCredentials();
if (limitCount <= 0) {
return { Err: new Error("Limit count must be between 1 and 100.") };
}
// FIXED: Use the formatted cookie string
const cookieString = this.#getFormattedCookieString();
const reqJson = {
"json": {
"rawQuery": "",
"type": "BACKBONE",
"subtype": "IMAGE",
"limit": limitCount,
"cursor": null
},
"meta": { "values": { "cursor": ["undefined"] } }
};
const req = {
method: "GET",
headers: new Headers({
"Content-Type": "application/json",
"Cookie": cookieString,
}),
url: `https://labs.google/fx/api/trpc/media.fetchUserHistory?input=` + JSON.stringify(reqJson),
};
const resp = await request(req);
if (resp.Err || !resp.Ok)
return { Err: resp.Err };
try {
const parsedResp = JSON.parse(resp.Ok);
const mediaList = parsedResp?.result?.data?.json?.result?.userWorkflows;
if (mediaList && Array.isArray(mediaList)) {
return { Ok: mediaList };
}
return { Err: new Error("Failed to get image history: " + resp.Ok) };
}
catch (err) {
return { Err: new Error("Failed to parse response: " + resp.Ok) };
}
}
/**
* Fetches the content of a project by its ID.
*
* @param projectId The ID of the project you want to fetch content from.
*/
async getProjectContent(projectId) {
await this.#checkCredentials();
if (!projectId) {
return { Err: new Error("Project ID is required to fetch project content.") };
}
// FIXED: Use the formatted cookie string
const cookieString = this.#getFormattedCookieString();
const reqJson = { "json": { "workflowId": projectId } };
const req = {
method: "GET",
headers: new Headers({
"Content-Type": "application/json",
"Cookie": cookieString,
}),
url: `https://labs.google/fx/api/trpc/media.getProjectWorkflow?input=` + JSON.stringify(reqJson),
};
const resp = await request(req);
if (resp.Err || !resp.Ok) {
return { Err: resp.Err };
}
try {
const parsedResp = JSON.parse(resp.Ok);
const mediaList = parsedResp?.result?.data?.json?.result?.media;
if (!mediaList || !Array.isArray(mediaList)) {
return { Err: new Error("Failed to get project content: " + resp.Ok) };
}
return { Ok: mediaList };
}
catch (err) {
return { Err: new Error("Failed to parse response: " + resp.Ok) };
}
}
/**
* Rename a project title.
*
* @param newName New name for your project
* @param projectId Identifier for project that you need to rename
*/
async renameProject(newName, projectId) {
if (!this.credentials.cookie) {
return { Err: new Error("Cookie field is empty") };
}
// FIXED: Use the formatted cookie string
const cookieString = this.#getFormattedCookieString();
const reqJson = {
"json": {
"workflowId": projectId,
"clientContext": {
"sessionId": ";1748333296243",
"tool": "BACKBONE",
"workflowId": projectId
},
"workflowMetadata": { "workflowName": newName }
}
};
const req = {
method: "POST",
body: JSON.stringify(reqJson),
headers: new Headers({
"Content-Type": "application/json",
"Cookie": cookieString,
}),
url: "https://labs.google/fx/api/trpc/media.createOrUpdateWorkflow",
};
const resp = await request(req);
if (resp.Err || !resp.Ok) {
return { Err: resp.Err };
}
try {
const parsedBody = JSON.parse(resp.Ok);
const workflowId = parsedBody?.result?.data?.json?.result?.workflowId;
if (parsedBody.error || !workflowId) {
return { Err: new Error("Failed to rename project: " + resp.Ok) };
}
return { Ok: String(workflowId) };
}
catch (err) {
return { Err: new Error("Failed to parse JSON: " + resp.Ok) };
}
}
/**
* Delete project(s) from libary
*
* @param projectIds Array of project id that you need to delete.
*/
async deleteProjects(projectIds) {
if (!this.credentials.cookie) {
return { Err: new Error("Cookie field is empty") };
}
// FIXED: Use the formatted cookie string
const cookieString = this.#getFormattedCookieString();
const reqJson = {
"json": {
"parent": "userProject/",
"names": projectIds,
}
};
const req = {
method: "POST",
body: JSON.stringify(reqJson),
url: "https://labs.google/fx/api/trpc/media.deleteMedia",
headers: new Headers({
"Content-Type": "application/json",
"Cookie": cookieString,
})
};
const resp = await request(req);
if (resp.Err || !resp.Ok) {
return { Err: resp.Err };
}
try {
const parsedResp = JSON.parse(resp.Ok);
if (parsedResp.error) {
return { Err: new Error("Failed to delete media: " + resp.Ok) };
}
return { Ok: true };
}
catch (err) {
return { Err: new Error("Failed to parse JSON: " + resp.Ok) };
}
}
/**
* Fetches the base64 encoded image from its media key (name).
* Media key can be obtained by calling: `getImageHistory()[0...N].name`
*
* @param mediaKey The media key of the image you want to fetch.
*/
async getMedia(mediaKey) {
await this.#checkCredentials();
if (!mediaKey) {
return { Err: new Error("Media key is required to fetch the image.") };
}
// FIXED: Use the formatted cookie string
const cookieString = this.#getFormattedCookieString();
const reqJson = { "json": { "mediaKey": mediaKey } };
const req = {
method: "GET",
headers: new Headers({
"Content-Type": "application/json",
"Cookie": cookieString,
}),
url: `https://labs.google/fx/api/trpc/media.fetchMedia?input=` + JSON.stringify(reqJson),
};
const resp = await request(req);
if (resp.Err || !resp.Ok) {
return { Err: resp.Err };
}
try {
const parsedResp = JSON.parse(resp.Ok);
const image = parsedResp?.result?.data?.json?.result;
if (!image) {
return { Err: new Error("Failed to get media: " + resp.Ok) };
}
return { Ok: image };
}
catch (err) {
return { Err: new Error("Failed to parse response: " + resp.Ok) };
}
}
/**
* Generates an image based on the provided prompt.
*
* @param prompt The prompt containing the details for image generation.
*/
async generateImage(prompt) {
await this.#checkCredentials();
if (!prompt || !prompt.prompt) {
return { Err: new Error("Invalid prompt. Please provide a valid prompt and projectId") };
}
// You missed the projectId, so let's create a new one
if (!prompt.projectId) {
// This calls getNewProjectId, which now uses the formatted cookie string.
const id = await this.getNewProjectId("New Project");
if (id.Err || !id.Ok)
return { Err: id.Err };
prompt.projectId = id.Ok;
}
// Because seed can be zero
if (prompt.seed == undefined) {
prompt.seed = 0;
}
if (!prompt.imageModel) {
prompt.imageModel = "IMAGEN_3_5";
}
if (!prompt.aspectRatio) {
prompt.aspectRatio = "IMAGE_ASPECT_RATIO_LANDSCAPE"; // Default in frontend
}
const reqJson = {
"clientContext": {
"workflowId": prompt.projectId,
"tool": "BACKBONE",
"sessionId": ";1748281496093"
},
"imageModelSettings": {
"imageModel": prompt.imageModel,
"aspectRatio": prompt.aspectRatio,
},
"seed": prompt.seed,
"prompt": prompt.prompt,
"mediaCategory": "MEDIA_CATEGORY_BOARD"
};
const req = {
method: "POST",
body: JSON.stringify(reqJson),
url: "https://aisandbox-pa.googleapis.com/v1:whisk:generateImage",
headers: new Headers({
"Content-Type": "application/json",
"Authorization": `Bearer ${String(this.credentials.authorizationKey)}`, // Uses Authorization, not Cookie
}),
};
const resp = await request(req);
if (resp.Err || !resp.Ok) {
return { Err: resp.Err };
}
try {
const parsedResp = JSON.parse(resp.Ok);
if (parsedResp.error) {
return { Err: new Error("Failed to generate image: " + resp.Ok) };
}
return { Ok: parsedResp };
}
catch (err) {
return { Err: new Error("Failed to parse response:" + resp.Ok) };
}
}
/**
* Refine a generated image.
*
* Refination actually happens in the followin way:
* 1. Client provides an image (base64 encoded) to refine with new prompt eg: "xyz".
* 2. Server responds with *a new prompt describing your image* eg: AI-Mix("pqr", "xyz")
* Where `pqr` - Description of original image
* 3. Client requests image re-generation as: AI-Mix("pqr", "xyz")
* 4. Server responds with new base64 encoded image
*/
async refineImage(ref) {
await this.#checkCredentials();
if (ref.seed == undefined) {
ref.seed = 0;
}
if (!ref.aspectRatio) {
ref.aspectRatio = "IMAGE_ASPECT_RATIO_LANDSCAPE"; // Default in frontend
}
if (!ref.imageModel) {
ref.imageModel = "IMAGEN_3_5"; // Default in frontend (This is actually Imagen 4)
}
if (!ref.count) {
ref.count = 1; // Default in frontend
}
// FIXED: Use the formatted cookie string for the first request
const cookieString = this.#getFormattedCookieString();
const reqJson = {
"json": {
"existingPrompt": ref.existingPrompt,
"textInput": ref.newRefinement,
"editingImage": {
"imageId": ref.imageId,
"base64Image": ref.base64image,
"category": "STORYBOARD",
"prompt": ref.existingPrompt,
"mediaKey": ref.imageId,
"isLoading": false,
"isFavorite": null,
"isActive": true,
"isPreset": false,
"isSelected": false,
"index": 0,
"imageObjectUrl": "blob:https://labs.google/1c612ac4-ecdf-4f77-9898-82ac488ad77f",
"recipeInput": {
"mediaInputs": [],
"userInput": {
"userInstructions": ref.existingPrompt
}
},
"currentImageAction": "REFINING",
"seed": ref.seed
},
"sessionId": ";1748338835952" // doesn't matter
},
"meta": {
"values": {
"editingImage.isFavorite": [
"undefined"
]
}
}
};
const req = {
method: "POST",
body: JSON.stringify(reqJson),
url: "https://labs.google/fx/api/trpc/backbone.generateRewrittenPrompt",
headers: new Headers({
"Content-Type": "application/json",
"Cookie": cookieString, // FIXED HERE
}),
};
const resp = await request(req);
if (resp.Err || !resp.Ok) {
return { Err: resp.Err };
}
let parsedResp;
try {
parsedResp = JSON.parse(resp.Ok);
if (parsedResp.error) {
return { Err: new Error("Failed to refine image: " + resp.Ok) };
}
}
catch (err) {
return { Err: new Error("Failed to parse response: " + resp.Ok) };
}
const newPrompt = parsedResp?.result?.data?.json;
if (!newPrompt) {
return { Err: new Error("Failed to get new prompt from response: " + resp.Ok) };
}
const reqJson2 = {
"userInput": {
"candidatesCount": ref.count,
"seed": ref.seed,
"prompts": [newPrompt],
"mediaCategory": "MEDIA_CATEGORY_BOARD",
"recipeInput": {
"userInput": {
"userInstructions": newPrompt,
},
"mediaInputs": []
}
},
"clientContext": {
"sessionId": ";1748338835952", // can be anything
"tool": "BACKBONE",
"workflowId": ref.projectId,
},
"modelInput": {
"modelNameType": ref.imageModel
},
"aspectRatio": ref.aspectRatio
};
const req2 = {
method: "POST",
body: JSON.stringify(reqJson2),
url: "https://aisandbox-pa.googleapis.com/v1:runBackboneImageGeneration",
headers: new Headers({
"Content-Type": "text/plain;charset=UTF-8", // Yes
"Authorization": `Bearer ${String(this.credentials.authorizationKey)}`, // Uses Authorization, not Cookie
}),
};
const resp2 = await request(req2);
if (resp2.Err || !resp2.Ok) {
return { Err: resp2.Err };
}
try {
const parsedResp2 = JSON.parse(resp2.Ok);
if (parsedResp2.error) {
return { Err: new Error("Failed to refine image: " + resp2.Ok) };
}
return { Ok: parsedResp2 };
}
catch (err) {
return { Err: new Error("Failed to parse response: " + resp2.Ok) };
}
}
/**
* Save image to a file with the given name.
*
* @param image The base64 encoded image string.
* @param fileName The name of the file where the image will be saved.
*/
saveImage(image, fileName) {
try {
writeFileSync(fileName, image, { encoding: 'base64' });
return null;
}
catch (err) {
return new Error("Failed to save image: " + err);
}
}
/**
* Save image from its id directly
*
* @param imageId The ID of the image you want to save.
* @param fileName The name of the file where the image will be saved.
*/
async saveImageDirect(imageId, fileName) {
const image = await this.getMedia(imageId);
if (image.Err || !image.Ok) {
return { Err: image.Err };
}
try {
writeFileSync(fileName, image.Ok.image.encodedImage, { encoding: 'base64' });
return { Ok: true };
}
catch (err) {
return {
Err: new Error("Failed to save image: " + err)
};
}
}
}