UNPKG

whiskapi

Version:

Unofficial API for Whisk image generation.

695 lines (658 loc) 24.5 kB
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) }; } } }