Generating images and videos
Images and videos are made by workflows. You start a run of a workflow with a request; it runs its steps on Voplica’s GPUs, or at the provider for a third-party model, and leaves its results in your account. This guide makes one image, step by step: find the workflow and the model, start the run, follow it, read what it made, and download the file. Every call here needs your key, except the ones that read the catalogue.
1. Find a workflow and a model
Section titled “1. Find a workflow and a model”A workflow has variants, and a run is always of a variant. WorkflowService.ListWorkflows lists the workflows and
WorkflowVariantService.ListWorkflowVariants the variants of one (give it parentWorkflowId). The studio’s own
variants also have stable keys:
| To make | The variant’s key | Its request goes in |
|---|---|---|
| Images, on Voplica’s models | workflow.image.gen.combined.v1 |
main, with upscale_size |
| Images, on third-party models | workflow.image.gen.enriched.v1 |
data |
| Videos | workflow.video.gen.enriched.v1 |
main (refinement optional) |
Today the third-party image models are Nano Banana Pro and Nano Banana 2. Fetch a variant by its key:
curl -X POST https://api.voplica.com/voplica.api.workflow.WorkflowVariantService/GetWorkflowVariant \ -H 'Content-Type: application/json' \ -d '{"idempotencyKey": "workflow.image.gen.combined.v1"}'The answer’s workflowVariant has the variantId to run, the inputsSpec (each input it reads and the message type it
expects), the outputs it names, and its pricing.
Models work the same way: ModelService.ListModels lists them, and a model’s defaultModelVariant.variantId is what a
run takes as its model. This guide uses Z-Image Turbo, fetched by its key:
curl -X POST https://api.voplica.com/voplica.api.ai.model.ModelVariantService/GetModelVariant \ -H 'Content-Type: application/json' \ -d '{"idempotencyKey": "model.checkpoint.base.zimage_turbo.v1"}'2. Start a run
Section titled “2. Start a run”WorkflowExecutionService.ExecuteWorkflow takes the variant’s id and the request, workflowInput. Save this as
run.json, with the two variantIds from step 1:
{ "workflowVariantId": "<the workflow variant's variantId>", "visibility": "PRIVATE", "contentPublishPolicy": { "publishStage": "STAGE_NONE", "defaultVisibility": "PRIVATE" }, "idempotencyKey": "lighthouse_1", "workflowInput": { "main": { "mapLiteral": { "map": { "prompt": { "literal": { "stringValue": "a lighthouse on a cliff at dawn, watercolor" } }, "width": { "literal": { "int32Value": 512 } }, "height": { "literal": { "int32Value": 512 } }, "generation_mode": { "literal": { "int32Value": 1 } }, "inference_mode": { "literal": { "int32Value": 2 } }, "base_model_id": { "literal": { "stringValue": "<the model variant's variantId>" } } } } }, "upscale_size": { "mapLiteral": { "map": { "factor": { "literal": { "int32Value": 1 } } } } } }}curl -X POST https://api.voplica.com/voplica.api.workflow.WorkflowExecutionService/ExecuteWorkflow \ -H "Authorization: Bearer $VOPLICA_API_KEY" \ -H 'Content-Type: application/json' \ -d @run.jsonThe answer is the run’s id: {"workflowExecutionId": "…"}.
How the request is written. workflowInput maps each input of the variant to a value, and every value is tagged:
{"literal": {"stringValue": …}} (or int32Value, floatValue, boolValue) for a single value,
{"mapLiteral": {"map": {…}}} for a message, and {"listLiteral": {"values": […]}} for a list. Whole numbers are
int32Value, and so are enums, by number. Here:
mainis the image request, aBaseImageGenerationInput: theprompt, the size in pixels (width,height),generation_mode1 (text to image),inference_modeand the model (base_model_id).upscale_sizeis required by this variant even when you don’t want a larger image: afactorof 1 keeps the size the image was made at. Without it the two later steps never run, and there is norefinedoutput.inference_modechooses speed or fidelity: 1 AUTO (Voplica chooses; also when it is left out), 2 FAST, 3 BALANCED (the model’s few-step recipe, where it has one), 4 ORIGINAL (slower, rarely better). A model without a FAST recipe runs another mode and the run says so in a warning (below).- Leave the steps and the guidance out: Voplica sets them from the model and the mode.
visibilityandcontentPublishPolicykeep the run and its results private.idempotencyKey(at most 30 letters, digits and underscores) makes a retry safe: the same key returns the run it already started — whatever the request now says. Give each new request its own key: sent again after you change the prompt,lighthouse_1returns your first run, not a new one.
To see what any variant accepts, read its inputsSpec, then ask TypesSpecService.GetMessageSpec for a message type’s
fields and GetEnumSpec for an enum’s values. Neither needs a key. The request map’s message types, such as
BaseImageGenerationInput, are not in the API reference; GetMessageSpec lists their fields:
curl -X POST https://api.voplica.com/voplica.api.workflow.TypesSpecService/GetMessageSpec \ -H 'Content-Type: application/json' \ -d '{"protoTypeUrl": "voplica.types.ai.image.BaseImageGenerationInput"}'Each entry of the answer’s messageSpec.entries is a field and its type, which names the tag to send: STRING →
stringValue, INT32 → int32Value, FLOAT → floatValue, BOOL → boolValue, ANY_ENUM → int32Value with a
number from GetEnumSpec, ANY_MESSAGE → mapLiteral, a listType → listLiteral. The answer’s
nestedMessagesSpecs describes each message type inside it the same way.
3. Follow the run
Section titled “3. Follow the run”A run takes from seconds to minutes. Ask for it every few seconds until its status is COMPLETED, FAILED or
CANCELLED (CANCELLING means a stop is still under way):
curl -X POST https://api.voplica.com/voplica.api.workflow.WorkflowExecutionService/GetWorkflowExecution \ -H "Authorization: Bearer $VOPLICA_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"workflowExecutionId": "<the run id>"}'The answer, abridged:
{ "workflowExecution": { "id": "…", "status": "COMPLETED", "creditsReservedMilli": "29600", "creditsChargedMilli": "46", "creditsReversedMilli": "0", "creditsNetChargedMilli": "46" }, "workflowOutput": { "base": { … }, "upscaled": { … }, "refined": { … } }, "messages": [ { "severity": "WARNING", "message": "Fast mode is not available for this request (no fast recipe for this model); it ran in balanced mode.", "code": "INFERENCE_MODE_FALLBACK" } ]}These are one run’s figures; the charge follows the GPU time used. To stop a run, call StopWorkflowExecution with its
id: no new step starts, and a step already running usually finishes.
4. Warnings
Section titled “4. Warnings”messages tells you what happened along the way. Each message has a severity (INFO, WARNING or ERROR), a
code and a message. A warning never fails a run: it reports something the run adjusted, such as
INFERENCE_MODE_FALLBACK above, where the model has no FAST recipe and the run used BALANCED. Branch on code when it
is set; show message to a person. When a run fails, an ERROR message says why, and its token may come at the start
of message instead, with code empty: CONTENT_POLICY_INPUT_TOO_LONG: A text in this request is longer than 8,000 characters. … (Content policy).
5. The results
Section titled “5. The results”The run’s workflowOutput holds the variant’s named outputs; for this variant, refined is the final image. Its
image_info carries the image’s file id:
"refined": { "mapLiteral": { "map": { "image_info": { "mapLiteral": { "map": { "id": { "literal": { "stringValue": "<the file id>" } }, …} } } } } }GetWorkflowOutput adds, step by step, what each step made and what it used: each entry of its
stepOutputs[].iterationOutputs[] has producedContentIds, the ids of the content items it made, and usage, with
gpuTimeMs and creditsCostMilli. The image is also a content item in your account, private here, which
ContentService.GetContent reads.
To download the image, ask for the file, then fetch its uri:
curl -X POST https://api.voplica.com/voplica.api.files.FilesService/GetFileInfo \ -H "Authorization: Bearer $VOPLICA_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"fileId": "<the file id>"}'The answer’s file has the mimeType (IMAGE_PNG), the sizeBytes and a uri: a short-lived link that downloads
the file without your key. Fetch it promptly, and never share it.
To remove what you don’t keep, call ContentService.DeleteContent with the content’s id (from producedContentIds)
and WorkflowExecutionService.DeleteWorkflowExecution with the run’s. Deleted content is no longer listed or returned
by the API. Its file is not deleted with it: GetFileInfo with your key still answers it.
The whole run in TypeScript
Section titled “The whole run in TypeScript”The same steps with @voplica/connect. fromJson comes from @bufbuild/protobuf, so install it too:
npm install @voplica/connect @bufbuild/protobuf @connectrpc/connect @connectrpc/connect-node.
import { fromJson, toJson } from "@bufbuild/protobuf";import { createClient, type Interceptor } from "@connectrpc/connect";import { createConnectTransport } from "@connectrpc/connect-node";import { ExecuteWorkflowRequestSchema, FilesService, JobStatus, ModelVariantService, NestedParamValueSchema, WorkflowExecutionService, WorkflowVariantService,} from "@voplica/connect";import { writeFile } from "node:fs/promises";
const apiKey = process.env.VOPLICA_API_KEY;if (!apiKey) { console.error("Set VOPLICA_API_KEY to your API key first."); process.exit(1);}
const withApiKey: Interceptor = (next) => (request) => { request.header.set("Authorization", `Bearer ${apiKey}`); return next(request);};
const transport = createConnectTransport({ baseUrl: "https://api.voplica.com", httpVersion: "1.1", useBinaryFormat: false, interceptors: [withApiKey],});const workflowVariants = createClient(WorkflowVariantService, transport);const modelVariants = createClient(ModelVariantService, transport);const runs = createClient(WorkflowExecutionService, transport);const files = createClient(FilesService, transport);
// 1. The workflow variant and the model.const { workflowVariant } = await workflowVariants.getWorkflowVariant({ attribute: { case: "idempotencyKey", value: "workflow.image.gen.combined.v1" },});const { modelVariant } = await modelVariants.getModelVariant({ attribute: { case: "idempotencyKey", value: "model.checkpoint.base.zimage_turbo.v1" },});
// 2. Start the run, with the same request as run.json.const { workflowExecutionId } = await runs.executeWorkflow( fromJson(ExecuteWorkflowRequestSchema, { workflowVariantId: workflowVariant!.variantId, visibility: "PRIVATE", contentPublishPolicy: { publishStage: "STAGE_NONE", defaultVisibility: "PRIVATE" }, // A new key for each new request: a key already used returns its run, whatever the request now says. idempotencyKey: "lighthouse_2", workflowInput: { main: { mapLiteral: { map: { prompt: { literal: { stringValue: "a lighthouse on a cliff at dawn, watercolor" } }, width: { literal: { int32Value: 512 } }, height: { literal: { int32Value: 512 } }, generation_mode: { literal: { int32Value: 1 } }, inference_mode: { literal: { int32Value: 2 } }, base_model_id: { literal: { stringValue: modelVariant!.variantId } }, }, }, }, upscale_size: { mapLiteral: { map: { factor: { literal: { int32Value: 1 } } } } }, }, }),);console.log("run:", workflowExecutionId);
// 3. Follow it until it ends.let answer = await runs.getWorkflowExecution({ workflowExecutionId });while (![JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED].includes(answer.workflowExecution!.status)) { await new Promise((resolve) => setTimeout(resolve, 5000)); answer = await runs.getWorkflowExecution({ workflowExecutionId });}const run = answer.workflowExecution!;console.log("status:", JobStatus[run.status], "| cost, in thousandths of a credit:", String(run.creditsNetChargedMilli));
// 4. Its warnings.for (const message of answer.messages) { console.log("message:", message.code, message.message);}if (run.status !== JobStatus.COMPLETED || !answer.workflowOutput["refined"]) { console.error("No image: the run ended", JobStatus[run.status]); process.exit(1);}
// 5. The final image: its file id, then the file through its short-lived link.const refined = toJson(NestedParamValueSchema, answer.workflowOutput["refined"]!) as any;const fileId: string = refined.mapLiteral.map.image_info.mapLiteral.map.id.literal.stringValue;const { file } = await files.getFileInfo({ fileId });const download = await fetch(file!.uri);await writeFile("lighthouse.png", Buffer.from(await download.arrayBuffer()));console.log("saved lighthouse.png");Save it in a project with "type": "module" and run it with your key set: node generate.ts (Node 22.18 or later). It
prints the run’s id, its status, its cost and its warnings, and saves lighthouse.png; if the run ends without an
image, it says so and exits. Before you run it again with a changed request, change its idempotencyKey: with the same
key, it returns the run it already started.
Videos
Section titled “Videos”A video takes the same steps with the variant workflow.video.gen.enriched.v1 and a video model. It reads main, a
BaseVideoGenerationInput (ask GetMessageSpec for every field), and an optional second input, refinement (a
VideoRefinementParams), which adds a refinement pass over the people in the video, at its own cost; leave it out for
the video as generated.
This request makes a video of about one second with LTX 2.5, a model on Voplica’s GPUs, at its cheapest settings. Fetch its model variant, Distilled fp8, by its key:
curl -X POST https://api.voplica.com/voplica.api.ai.model.ModelVariantService/GetModelVariant \ -H 'Content-Type: application/json' \ -d '{"idempotencyKey": "model.checkpoint.base.ltx_2_5.v2"}'Then save this as video.json, with the video variant’s variantId and the model variant’s, and send it to
ExecuteWorkflow as you sent run.json (-d @video.json):
{ "workflowVariantId": "<the video workflow variant's variantId>", "visibility": "PRIVATE", "contentPublishPolicy": { "publishStage": "STAGE_NONE", "defaultVisibility": "PRIVATE" }, "idempotencyKey": "paper_boat_1", "workflowInput": { "main": { "mapLiteral": { "map": { "prompt": { "literal": { "stringValue": "a paper boat drifting on a calm pond, soft morning light" } }, "width": { "literal": { "int32Value": 576 } }, "height": { "literal": { "int32Value": 320 } }, "num_frames": { "literal": { "int32Value": 33 } }, "generation_mode": { "literal": { "int32Value": 20 } }, "inference_mode": { "literal": { "int32Value": 2 } }, "base_model_id": { "literal": { "stringValue": "<the model variant's variantId>" } } } } } }}These seven fields are all the run needed. How a video request differs from an image’s:
-
generation_modeis 20 (text to video) or 21 (from a start image). -
The length is
num_frames, a number of frames (not seconds) on the model’s own frame grid. LTX 2.5’s grid is 8 × n + 1 frames, at 30 frames a second: the 33 frames here (n = 4) made a video of 1.1 seconds. -
LTX 2.5 makes the video at twice the size you ask for: this request’s 576 × 320 came out at 1152 × 640. The output’s
used_inference_inputshows what the model ran with: here that size, BALANCED and 8 steps. LTX 2.5 has no FAST recipe, so the run carried one warning, the image’s:INFERENCE_MODE_FALLBACK, “Fast mode is not available for this request (no fast recipe for this model); it ran in balanced mode.” -
The variant names two outputs:
data, the video as generated, andrefined, the refined video when you sendrefinement. Without it, the run ends after the generation step, and the file id of the video is atdata.inference_output.video.id:"data": { "mapLiteral": { "map": {"inference_output": { "mapLiteral": { "map": {"video": { "mapLiteral": { "map": {"id": { "literal": { "stringValue": "<the file id>" } },"mime_type": { "literal": { "stringValue": "VIDEO_MP4" } },…} } },"used_inference_input": { "mapLiteral": { "map": { … } } },…} } },…} } } -
GetFileInfoon that id answers the file’smimeType(VIDEO_MP4here), itssizeBytesand itsuriPartition, which, when it is set, says how to use theuri. Here it was unset (PARTITION_TYPE_UNSPECIFIED), as for the image, and theuriwas one link to the whole file, as withSINGLE_FILE. WithRANGED_FILE_PARTS, theuriis a JSON list of parts, each with itsurl,from_byteandto_byte: download them and join them in order. WithHLS_MANIFESTorDASH_MANIFEST, it is a streaming playlist (.m3u8or.mpd) for a video player. -
Videos cost more and reserve more than images: this variant reserves 56,300 thousandths of a credit, this guide’s image variant 29,600. One measured run of this request used 113,694 ms of GPU time, was charged 12,962 (net 12,962) and took about two minutes. Read the variant’s
pricingbefore you start. -
A start image rides in
references, a list of media references, each with itsfileand itsrole— 1 for a start image. Its shape, from the definitions, with the file id of an image one of your runs made:"references": { "listLiteral": { "values": [{ "mapLiteral": { "map": {"file": { "mapLiteral": { "map": { "file_id": { "literal": { "stringValue": "<your image's file id>" } } } } },"role": { "literal": { "int32Value": 1 } }} } }] } } -
Some models also follow an audio (role 50) or take a voice (51); MiniMax H3 also takes other sounds (52) and style and effect videos (43 and 44), and has a FAST recipe. Each role’s rules are in the API reference’s introduction.
Files you reference are your own files, such as your earlier runs’ outputs (the image this guide made) or files you
uploaded in the studio. An upload has three parts: FilesService.InitFileUpload starts it and answers the file’s id,
the bytes are sent, and FinalizeFileUpload completes it. The byte step has no route on api.voplica.com today;
uploading files through the public API is planned.
Delete a video you don’t keep as you delete an image, its content (from producedContentIds) and then the run. Deleted
content is no longer listed or returned by the API. Its file is not deleted with it: GetFileInfo with your key still
answers it.
What a run costs
Section titled “What a run costs”- When it starts, a run reserves credits: the variant’s
pricing.initialCreditsReservationMilli. API usage is paid with Anytime and Earned credits only — free credits and plan credits can’t be used for the API. When those don’t cover the reservation,ExecuteWorkflowanswersfailed_precondition, with this rule as its message, and starts nothing. The reservation is what a run needs to start, not its price: a long run, such as a training run, can cost more and keeps drawing on your Anytime and Earned credits; if they run out, the run fails with the same message. - While it runs, it is charged for what it uses: its GPU and CPU time and the data it reads and writes on Voplica’s models, and the provider’s bill on a third-party model. The mode does not change how a run is charged.
- When it ends, any unused part of the reservation comes back.
creditsChargedMilliis everything charged,creditsReversedMillithe charges returned for a failure on Voplica’s side, andcreditsNetChargedMilliwhat the run cost, all in thousandths of a credit.GetWorkflowOutputbreaks the cost down by step.
Billing and limits has the rules, including what a failed run costs.