Skip to content

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.

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:

Terminal window
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:

Terminal window
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"}'

WorkflowExecutionService.ExecuteWorkflow takes the variant’s id and the request, workflowInput. Save this as run.json, with the two variantIds from step 1:

run.json
{
"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 } } } } }
}
}
Terminal window
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.json

The 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:

  • main is the image request, a BaseImageGenerationInput: the prompt, the size in pixels (width, height), generation_mode 1 (text to image), inference_mode and the model (base_model_id).
  • upscale_size is required by this variant even when you don’t want a larger image: a factor of 1 keeps the size the image was made at. Without it the two later steps never run, and there is no refined output.
  • inference_mode chooses 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.
  • visibility and contentPublishPolicy keep 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_1 returns 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:

Terminal window
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.

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):

Terminal window
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.

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).

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:

Terminal window
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 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.

generate.ts
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.

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:

Terminal window
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):

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_mode is 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_input shows 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, and refined, the refined video when you send refinement. Without it, the run ends after the generation step, and the file id of the video is at data.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": { … } } },
    …
    } } },
    …
    } } }
  • GetFileInfo on that id answers the file’s mimeType (VIDEO_MP4 here), its sizeBytes and its uriPartition, which, when it is set, says how to use the uri. Here it was unset (PARTITION_TYPE_UNSPECIFIED), as for the image, and the uri was one link to the whole file, as with SINGLE_FILE. With RANGED_FILE_PARTS, the uri is a JSON list of parts, each with its url, from_byte and to_byte: download them and join them in order. With HLS_MANIFEST or DASH_MANIFEST, it is a streaming playlist (.m3u8 or .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 pricing before you start.

  • A start image rides in references, a list of media references, each with its file and its role — 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.

  • 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, ExecuteWorkflow answers failed_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. creditsChargedMilli is everything charged, creditsReversedMilli the charges returned for a failure on Voplica’s side, and creditsNetChargedMilli what the run cost, all in thousandths of a credit. GetWorkflowOutput breaks the cost down by step.

Billing and limits has the rules, including what a failed run costs.