Build a Venice AI Shot Lab

Build a local app that quotes, generates, and plays Venice AI videos.

Introduction

30 Second Summary

A vivid scene can feel finished in your imagination long before it exists as something you can play. Paying to create it also deserves a clear price before you press go.

In this project, you will build a local Shot Lab that uses the Venice AI API to turn a cinematic prompt into a five-second video. The app checks the live price before it submits the paid job.

What You'll Build

At http://localhost:3000, your Shot Lab shows a live price before transforming a shot brief into a playable five-second clip with visible processing updates.

By the end of this project, you'll have:

  • A live quote gate that shows the current USD price before a paid generation begins.
  • A protected browser-to-server workflow that keeps your API key out of browser code.
  • A reload-safe job tracker that resumes the asynchronous queue after a page refresh until the completed MP4 is ready to play.
  • Secret Mission: Add a landscape or portrait selector that allows only Venice-supported aspect ratios.

Are there any prerequisites?

No prior API experience or local setup is required. You need a Mac plus access to a Venice account with spendable balance for the paid generation.

Before We Start

A clear shot concept gives your Shot Lab a purpose before you set up any tools or spend credits. Use this checkpoint to choose the five-second cinematic moment you want to generate and why someone would want to see it.

Prepare the Local Project and Venice Access

Your five-second shot now has a clear purpose. A local Node.js runtime needs to turn that idea into a working application.

The Venice AI API also needs funded access through an API key. This setup keeps that key in a server-side environment file.

In this step, get ready to:
  • Verify the local runtime plus desktop tools.
  • Create a capped Venice key inside a private environment file.
  • Start the Express route in Safari.
Verify your local tools

Node.js runs the local server that protects your Venice key. Visual Studio Code gives you one place to manage the project files.

You will use Safari to view the finished local page. Start by checking the runtime version already installed on your Mac.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal and press Enter to open it.
  • Check your installed Node.js version by running this command:
node --version

What does this check do?

The command asks Node.js to print its installed version. Your result determines whether the required v24.21.0 LTS runtime is ready.

✔️ I see v24.21.0 LTS

Your Node.js runtime matches the project version. That clears the first setup hurdle.

  • Leave Terminal open for the project commands later in this step.

ⓧ I see an older version

The installed runtime is older than the project version. Install the pinned release before continuing.

  • Visit the official Node.js release directory.
  • Download node-v24.21.0.pkg.
  • Open the downloaded package from your Downloads folder.
  • Complete the macOS installer prompts.
  • Close the current Terminal window after the installer finishes.
  • Open a fresh Terminal window through Spotlight.
  • Confirm the new runtime version by running this command:
node --version

What should the version check show?

A fresh Terminal window loads the updated executable path. You should now see v24.21.0 in the output.

ⓧ Command not found

Your Mac cannot find a Node.js installation. The official macOS package installs the required runtime.

  • Visit the official Node.js release directory.
  • Download node-v24.21.0.pkg.
  • Open the downloaded package from your Downloads folder.
  • Complete the macOS installer prompts.
  • Open a fresh Terminal window through Spotlight.
  • Confirm the installation by running this command:
node --version

What should the version check show?

The installer makes Node.js available to new Terminal windows. You should now see v24.21.0 in the output.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code and press Enter to open it.

Can't find Visual Studio Code?

  • Download the macOS installer from the official Visual Studio Code setup guide.
  • Open the downloaded .dmg file.
  • Drag Visual Studio Code.app into Applications.
  • Open Visual Studio Code through Spotlight.
  • Ask for help if the application still does not open: Help me install Visual Studio Code on my Mac.
  • Press Cmd+Shift+P in Visual Studio Code to open the Command Palette.
  • Type Shell Command: Install 'code' command in PATH.
  • Select the matching command from the Command Palette.
  • Press Cmd+Space to return to Spotlight.
  • Type Safari and press Enter to open it.

Your runtime and desktop tools are ready. You now have everything needed to create the local project.

Create the project and secure access

The project folder keeps your server code beside its configuration files. A private .env file separates the live key from files that are safe to share.

  • Switch back to the Terminal window from earlier.
  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The cd command changes Terminal's current location. Creating the project here makes the folder easy to find from your Desktop.

  • Create the venice-shot-lab folder by running these commands:
mkdir venice-shot-lab
cd venice-shot-lab

What do these commands do?

  • The first command creates the local project folder.
  • The second command moves Terminal into that folder.
  • Open the current project folder in Visual Studio Code by running this command:
code .

What does this command do?

The code command opens Visual Studio Code. The dot tells it to use the current venice-shot-lab folder.

Does Terminal reject the code command?

  • Return to Visual Studio Code.
  • Open the Command Palette with Cmd+Shift+P.
  • Run Shell Command: Install 'code' command in PATH.
  • Open a fresh Terminal window before trying the project command again.
  • Ask for help if the command remains unavailable: Help me make the code command available in macOS Terminal.
  • Click the New File icon in the Visual Studio Code Explorer sidebar.
  • Enter .env.example as the file name.
  • Add the safe environment template by pasting this line:
VENICE_API_KEY=your-api-key-here

Why create an environment template?

The template documents the required variable without containing a live credential. Someone can copy this format without gaining access to your Venice account.

  • Save .env.example with Cmd+S.
  • Click the New File icon in the Explorer sidebar.
  • Enter .gitignore as the file name.
  • Protect the live key plus installed packages by pasting these entries:
.env
node_modules/

What does this file protect?

  • The .env entry keeps the live API key out of source control.
  • The node_modules/ entry excludes downloaded packages that npm can recreate.
  • Save .gitignore with Cmd+S.
  • Confirm the Explorer sidebar lists .env.example plus .gitignore.

Set a spending boundary

Venice model requests require a spendable balance. A small key-level limit reduces the amount this project key can consume.

Use a limit of 1 USD for this project. That leaves room for the currently listed five-second clip price while keeping the cap conservative.

  • Return to Safari from earlier.
  • Go to the Venice API settings page.
  • Sign in to your Venice account.
  • Confirm the account shows a spendable balance for API requests.
  • Add funds through the on-screen account flow if no spendable balance is available.
  • Select Generate New API Key.
  • Choose Inference Only for API Key Type.
  • Set Epoch Consumption Limit to 1 USD.
  • Select Generate.
  • Copy the full key from its one-time display.

Your Inference Only key now has a conservative spending boundary. Store it immediately so the one-time value stays under your control.

  • Return to the venice-shot-lab window in Visual Studio Code.
  • Click the New File icon in the Explorer sidebar.
  • Enter .env as the file name.
  • Add the environment variable format by pasting this line:
VENICE_API_KEY=your-api-key-here

How does the private environment file work?

Node.js loads VENICE_API_KEY from this file when the server starts. The browser never needs the live value.

  • Replace your-api-key-here with the full key you copied.
  • Save .env with Cmd+S.
  • Keep the live key hidden whenever you share your screen.

Did you lose the one-time key?

Generate a new Inference Only key from the Venice API settings page. Copy the complete value before leaving its one-time display.

Ask for help without sharing any key value: Help me safely replace a Venice API key that I did not save.

Install Express and start the server

npm records the package needed by your application. Express provides the local route that Safari can request.

  • Switch back to the Terminal window inside venice-shot-lab.
  • Initialize the package plus install Express 5.2.1 by running these commands:
npm init -y
npm install express@5.2.1 --save-exact

What do these commands create?

  • The first command creates package.json without opening a questionnaire.
  • The second command installs the exact Express version used by this project.
  • npm also creates package-lock.json plus the node_modules/ directory.
  • Confirm the Visual Studio Code Explorer now lists package.json plus package-lock.json.

The dependency is installed. Your project now has the package metadata needed to recreate the same server setup.

Did the Express installation fail?

  • Confirm Terminal is inside the venice-shot-lab folder.
  • Check that the Node.js version command from earlier still prints v24.21.0.
  • Retry the installation after restoring your network connection.
  • Ask for help with the output you received: Help me diagnose my Express installation error.
  • Select package.json in the Visual Studio Code Explorer sidebar.
  • Replace its generated contents with this project definition:
{"name":"venice-shot-lab","version":"1.0.0","private":true,"dependencies":{"express":"5.2.1"}}

What does this package file define?

  • The name plus version fields identify the local project.
  • The private setting prevents accidental publication as an npm package.
  • The dependency pin keeps Express fixed at 5.2.1.
  • Save package.json with Cmd+S.
  • Click the New File icon in the Explorer sidebar.
  • Enter server.js as the file name.
  • Create the starter route by pasting this server code:
const express = require("express");
const app = express();
const port = 3000;

app.get("/", (req, res) => {
  res.send("Shot Lab setup ready");
});

app.listen(port, () => {
  console.log(`Shot Lab running at http://localhost:${port}`);
});

What does this server do?

  • The first three lines load Express plus create the application on port 3000.
  • The / route sends a short setup message to the browser.
  • The listening callback prints the local URL after the server starts successfully.
  • Save server.js with Cmd+S.

✔️ Awesome, I've got everything!

Your starter files are saved. The live credential remains only in .env.

ⓧ I'd like to double check the full code

Compare each safe project file with the matching reference below. Keep your live .env value private.

VENICE_API_KEY=your-api-key-here
.env
node_modules/
{"name":"venice-shot-lab","version":"1.0.0","private":true,"dependencies":{"express":"5.2.1"}}
const express = require("express");
const app = express();
const port = 3000;

app.get("/", (req, res) => {
  res.send("Shot Lab setup ready");
});

app.listen(port, () => {
  console.log(`Shot Lab running at http://localhost:${port}`);
});

Before you start the server, what message do you expect Safari to show? Hold that prediction for the check.

  • Start the server with its private environment file by running this command:
node --env-file=.env server.js

What does this command do?

Node.js loads the values from .env before it runs server.js. Express keeps running in this Terminal window so Safari can reach port 3000.

You should see Shot Lab running at http://localhost:3000 in Terminal. That confirms Express is listening for local requests.

  • Return to Safari from earlier.
  • Enter http://localhost:3000 in the address bar.

Safari should display Shot Lab setup ready. Your local server is now reachable from the browser.

Does the starter route fail to load?

  • Confirm the Terminal window still shows the running local URL.
  • Check that Safari uses port 3000 in the address bar.
  • Compare server.js with the full-code reference for missing brackets or quotation marks.
  • Ask for help without sharing your key: Help me debug why my Shot Lab starter route does not load.

Your local environment can now serve the Shot Lab without exposing its Venice key. Next, you will replace the starter message with the visible video interface.

Build the Visible Shot Lab

Your starter Express server proves that your local project can answer browser requests. It still shows a setup message instead of the video product you want to build.

A visible interface makes the destination tangible before API behavior adds complexity. In this step, you will turn that setup message into a Shot Lab that runs in Safari.

In this step, get ready to:
  • Build the prompt form with quote controls and a hidden video player.
  • Style the interface as a compact card that adapts to a narrow window.
  • Connect the browser script and configure Express to serve the interface.
Create the Shot Lab structure

The HTML defines everything the learner can see or use. Your first version includes the full interface while keeping generation disabled until a quote is available.

  • In VS Code, use the new-folder control in the left sidebar to create public inside venice-shot-lab.
  • Use the new-file control beside public to create index.html.
  • Add the Shot Lab page structure by pasting this code into public/index.html:
<!doctype html>
<html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>Venice AI Shot Lab</title><link rel="stylesheet" href="styles.css" /></head><body><main class="card"><p class="eyebrow">Venice AI Video</p><h1>Shot Lab</h1><p class="intro">Describe one cinematic five-second shot, check its live price, then generate it.</p><form id="shot-form"><label for="prompt">Shot brief</label><textarea id="prompt" rows="5" required placeholder="A paper boat sailing through a neon city puddle at night, slow tracking shot"></textarea><div class="actions"><button id="quote-button" type="button">Get live quote</button><button id="generate-button" type="submit" disabled>Generate video</button></div></form><section class="result-grid" aria-live="polite"><div><span class="label">Current quote</span><strong id="quote-output">Not quoted</strong></div><div><span class="label">Job status</span><strong id="status-output">Waiting for a prompt</strong></div></section><video id="video-output" controls hidden></video></main><script src="app.js"></script></body></html>

What does this page contain?

  • The #shot-form form collects a cinematic prompt from the #prompt textarea.
  • The #quote-button starts the price check in the next step.
  • The disabled #generate-button prevents generation before a quote is ready.
  • The status fields and hidden #video-output player reserve space for later results.
  • Save public/index.html.
  • Confirm the left sidebar shows index.html inside public.

Cannot find the page file?

  • Check that index.html sits inside the public folder.
  • Check that the file name ends with .html.
  • Use Help me place public/index.html correctly in my Venice Shot Lab project..

✔️ Awesome, I've got everything!

Your Shot Lab structure is now stored in public/index.html.

ⓧ I'd like to double check the full code

  • Compare your complete public/index.html file with this reference:
<!doctype html>
<html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>Venice AI Shot Lab</title><link rel="stylesheet" href="styles.css" /></head><body><main class="card"><p class="eyebrow">Venice AI Video</p><h1>Shot Lab</h1><p class="intro">Describe one cinematic five-second shot, check its live price, then generate it.</p><form id="shot-form"><label for="prompt">Shot brief</label><textarea id="prompt" rows="5" required placeholder="A paper boat sailing through a neon city puddle at night, slow tracking shot"></textarea><div class="actions"><button id="quote-button" type="button">Get live quote</button><button id="generate-button" type="submit" disabled>Generate video</button></div></form><section class="result-grid" aria-live="polite"><div><span class="label">Current quote</span><strong id="quote-output">Not quoted</strong></div><div><span class="label">Job status</span><strong id="status-output">Waiting for a prompt</strong></div></section><video id="video-output" controls hidden></video></main><script src="app.js"></script></body></html>

This reference contains the complete page structure for this step.

Style the Shot Lab

The page structure gives each control a purpose. CSS now turns those controls into a readable card with clear visual states.

  • Use the new-file control beside public to create styles.css.
  • Add the Shot Lab visual system by pasting this code into public/styles.css:
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#090b13;color:#f6f7fb}*{box-sizing:border-box}body{min-height:100vh;margin:0;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at top,#24305e 0,#090b13 55%)}.card{width:min(720px,100%);padding:32px;border:1px solid #39416b;border-radius:22px;background:rgba(15,18,32,.94);box-shadow:0 24px 80px rgba(0,0,0,.4)}.eyebrow,.label{color:#9ea9ef;font-size:.78rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}h1{margin:4px 0 8px;font-size:clamp(2rem,8vw,4rem)}.intro{color:#c7cbe1;line-height:1.6}label{display:block;margin:24px 0 8px;font-weight:700}textarea,select{width:100%;border:1px solid #4a527b;border-radius:12px;padding:14px;background:#0d1020;color:#fff;font:inherit}textarea:focus,select:focus{outline:2px solid #8b9cff;outline-offset:2px}.actions{display:flex;gap:12px;margin-top:16px;flex-wrap:wrap}button{border:0;border-radius:999px;padding:12px 18px;background:#8b9cff;color:#0a0c14;font:inherit;font-weight:800;cursor:pointer}button:last-child{background:#70e1b1}button:disabled{cursor:not-allowed;opacity:.42}.result-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:24px}.result-grid div{min-height:88px;padding:16px;border-radius:14px;background:#15192d}.result-grid strong,.result-grid span{display:block}.result-grid strong{margin-top:8px;overflow-wrap:anywhere}.error{color:#ff9aa8}video{width:100%;margin-top:24px;border-radius:16px;background:#000}@media(max-width:560px){.card{padding:22px}.result-grid{grid-template-columns:1fr}}

What does this styling do?

  • The page background and .card styles create the centered dark interface.
  • The button styles make the quote and generation actions visually distinct.
  • The disabled button style makes the unavailable generation action obvious.
  • The media query changes the results into one column on narrow windows.
  • Save public/styles.css.
  • Confirm the left sidebar shows styles.css beside index.html.

Styles missing from the file?

  • Check that the file is named styles.css.
  • Check that styles.css sits inside the same public folder as index.html.
  • Use Help me check why public/styles.css is missing or not saved in my Venice Shot Lab project..

✔️ Awesome, I've got everything!

Your Shot Lab now has a complete visual system in public/styles.css.

ⓧ I'd like to double check the full code

  • Compare your complete public/styles.css file with this reference:
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#090b13;color:#f6f7fb}*{box-sizing:border-box}body{min-height:100vh;margin:0;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at top,#24305e 0,#090b13 55%)}.card{width:min(720px,100%);padding:32px;border:1px solid #39416b;border-radius:22px;background:rgba(15,18,32,.94);box-shadow:0 24px 80px rgba(0,0,0,.4)}.eyebrow,.label{color:#9ea9ef;font-size:.78rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}h1{margin:4px 0 8px;font-size:clamp(2rem,8vw,4rem)}.intro{color:#c7cbe1;line-height:1.6}label{display:block;margin:24px 0 8px;font-weight:700}textarea,select{width:100%;border:1px solid #4a527b;border-radius:12px;padding:14px;background:#0d1020;color:#fff;font:inherit}textarea:focus,select:focus{outline:2px solid #8b9cff;outline-offset:2px}.actions{display:flex;gap:12px;margin-top:16px;flex-wrap:wrap}button{border:0;border-radius:999px;padding:12px 18px;background:#8b9cff;color:#0a0c14;font:inherit;font-weight:800;cursor:pointer}button:last-child{background:#70e1b1}button:disabled{cursor:not-allowed;opacity:.42}.result-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:24px}.result-grid div{min-height:88px;padding:16px;border-radius:14px;background:#15192d}.result-grid strong,.result-grid span{display:block}.result-grid strong{margin-top:8px;overflow-wrap:anywhere}.error{color:#ff9aa8}video{width:100%;margin-top:24px;border-radius:16px;background:#000}@media(max-width:560px){.card{padding:22px}.result-grid{grid-template-columns:1fr}}

This reference contains the complete stylesheet for this step.

Connect the browser script and server

The browser script needs stable references to every interactive element. The server also needs to replace its temporary text route with static file delivery.

  • Use the new-file control beside public to create app.js.
  • Add the initial browser logic by pasting this code into public/app.js:
const form=document.querySelector("#shot-form");
const promptInput=document.querySelector("#prompt");
const quoteButton=document.querySelector("#quote-button");
const generateButton=document.querySelector("#generate-button");
const quoteOutput=document.querySelector("#quote-output");
const statusOutput=document.querySelector("#status-output");
const videoOutput=document.querySelector("#video-output");
let activeVideoUrl=null;
function setStatus(message,isError=false){statusOutput.textContent=message;statusOutput.classList.toggle("error",isError);}

What does this script prepare?

  • Each document.querySelector() call stores one interface element for later use.
  • The activeVideoUrl variable provides one place to track the current browser video URL.
  • The setStatus() function updates the job message.
  • The same function toggles the error class when a request fails.
  • Save public/app.js.
  • Confirm the left sidebar shows all three files inside public.

Missing a browser element reference?

  • Check that every selector begins with #.
  • Check that each selector matches an ID in public/index.html exactly.
  • Use Help me compare the selectors in public/app.js with the IDs in public/index.html..

✔️ Awesome, I've got everything!

Your browser-side foundation now owns every Shot Lab element it needs.

ⓧ I'd like to double check the full code

  • Compare your complete public/app.js file with this reference:
const form=document.querySelector("#shot-form");
const promptInput=document.querySelector("#prompt");
const quoteButton=document.querySelector("#quote-button");
const generateButton=document.querySelector("#generate-button");
const quoteOutput=document.querySelector("#quote-output");
const statusOutput=document.querySelector("#status-output");
const videoOutput=document.querySelector("#video-output");
let activeVideoUrl=null;
function setStatus(message,isError=false){statusOutput.textContent=message;statusOutput.classList.toggle("error",isError);}

This reference contains the complete browser script for this step.

  • Switch back to server.js in the editor.
  • Replace the starter server code with this static-file server:
const express = require("express");
const path = require("node:path");
const app = express();
const port = 3000;
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
app.listen(port, () => console.log(`Shot Lab running at http://localhost:${port}`));

How does the server change?

  • The node:path module builds the path to the public folder.
  • The express.json() middleware prepares the server to read JSON request bodies.
  • The express.static() middleware serves index.html and its linked files.
  • The server continues listening on port 3000.
  • Save server.js.

✔️ Awesome, I've got everything!

Your server is ready to serve the complete public folder.

ⓧ I'd like to double check the full code

  • Compare your complete server.js file with this reference:
const express = require("express");
const path = require("node:path");
const app = express();
const port = 3000;
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
app.listen(port, () => console.log(`Shot Lab running at http://localhost:${port}`));

This reference contains the complete server for this step.

  • Return to the terminal panel that is running the starter server.
  • Stop the starter server by pressing Control+C.

Before you restart the server, what do you expect to replace the setup message in Safari?

  • Start the updated server by running this command:
node --env-file=.env server.js

The terminal prints Shot Lab running at http://localhost:3000. This confirms the updated server is listening.

  • Return to the Safari tab from earlier.
  • Refresh http://localhost:3000.
  • Narrow the Safari window to confirm the two result panels change into one column.

You should see the Shot Lab title above a prompt box. You should also see the quote button, the disabled generate button, the quote panel, and the job status panel.

That is your first visible product slice. The hidden video player is ready for a completed clip without leaving an empty box on the page.

Still seeing the setup message?

  • Confirm that you stopped the earlier server before starting the updated one.
  • Confirm that public/index.html sits inside the venice-shot-lab folder.
  • Confirm that the express.static() line in server.js points to public.
  • Use Help me debug why my Express server still shows the old Shot Lab setup message..

Your visible Shot Lab is running locally with its controls and status areas in place. Next, you will connect the quote button to a live price check before submitting one paid video job.

Quote and Queue a Paid Video Job

Your Shot Lab already has a working interface in Safari at http://localhost:3000. Its buttons still need a secure path to Venice.

Paid generation needs a live price gate plus a server-side secret. In this step, Express sends fixed settings to the Venice AI API before recording acceptance through an asynchronous queue.

In this step, get ready to:
  • Connect the server to Venice with fixed video settings.
  • Display a live USD quote in the browser.
  • Submit one paid job whose queue receipt survives a reload.
Add the Venice server routes

The browser must never receive your Venice API key. Your Node.js server reads the key from process.env.VENICE_API_KEY before making authenticated requests on the browser's behalf.

  • In Visual Studio Code, select server.js from the Explorer sidebar.
  • Replace the contents of server.js by pasting this code:
const express=require("express");const path=require("node:path");const app=express();const port=3000;const baseUrl="https://api.venice.ai/api/v1";const model="wan-2.5-preview-text-to-video";const videoSettings={model,duration:"5s",resolution:"480p",aspect_ratio:"16:9"};const apiKey=process.env.VENICE_API_KEY;if(!apiKey)throw new Error("Missing VENICE_API_KEY. Add it to .env before starting the server.");const veniceHeaders={Authorization:`Bearer ${apiKey}`,"Content-Type":"application/json"};app.use(express.json());app.use(express.static(path.join(__dirname,"public")));function getErrorMessage(data){if(typeof data?.error==="string")return data.error;if(typeof data?.error?.message==="string")return data.error.message;return "Venice request failed.";}async function postVeniceJson(endpoint,body){const response=await fetch(`${baseUrl}${endpoint}`,{method:"POST",headers:veniceHeaders,body:JSON.stringify(body)});const data=await response.json();if(!response.ok)throw new Error(getErrorMessage(data));return data;}app.post("/api/quote",async(req,res)=>{try{const data=await postVeniceJson("/video/quote",videoSettings);res.json({quote:data.quote});}catch(error){res.status(500).json({error:error.message});}});app.post("/api/generate",async(req,res)=>{const prompt=typeof req.body.prompt==="string"?req.body.prompt.trim():"";if(!prompt)return res.status(400).json({error:"Enter a prompt before generating."});try{const data=await postVeniceJson("/video/queue",{...videoSettings,prompt});res.json({model:data.model,queue_id:data.queue_id});}catch(error){res.status(500).json({error:error.message});}});app.listen(port,error=>{if(error)throw error;console.log(`Shot Lab running at http://localhost:${port}`);});

What does this server code do?

  • The baseUrl value points every Venice request at the documented API base URL.
  • The model value selects wan-2.5-preview-text-to-video for each request.
  • The videoSettings object fixes the duration at 5s.
  • The same object fixes the resolution at 480p.
  • The same object fixes the aspect ratio at 16:9.
  • The veniceHeaders object adds the server-only Bearer credential to Venice requests.
  • The postVeniceJson() helper sends JSON requests to Venice.
  • The getErrorMessage() helper turns a returned Venice error into a message the browser can display.
  • The POST /api/quote route returns the current numeric quote.
  • The POST /api/generate route validates the prompt before submitting a queue request.
  • Save server.js.
  • Return to the Terminal panel running your local server.
  • Press Control+C to stop the current server process.
  • Restart the server with the updated routes by running this command:
node --env-file=.env server.js

What does this command do?

Node.js loads the values from .env into process.env before starting server.js. This keeps your real Venice credential inside the server process.

You should see Shot Lab running at http://localhost:3000 in Terminal. This confirms the updated server started with access to your environment file.

Server does not restart?

  • Confirm your real .env file contains the Venice key you saved earlier.
  • Confirm Terminal is still inside the venice-shot-lab folder.
  • Check that your Venice key still has a small spending allowance available.
  • Ask for help with the server startup error.

✔️ Awesome, I've got everything!

Your updated server is running. Double check that you saved server.js before continuing.

ⓧ I'd like to double check the full code

Compare your complete server.js file with this reference.

const express=require("express");const path=require("node:path");const app=express();const port=3000;const baseUrl="https://api.venice.ai/api/v1";const model="wan-2.5-preview-text-to-video";const videoSettings={model,duration:"5s",resolution:"480p",aspect_ratio:"16:9"};const apiKey=process.env.VENICE_API_KEY;if(!apiKey)throw new Error("Missing VENICE_API_KEY. Add it to .env before starting the server.");const veniceHeaders={Authorization:`Bearer ${apiKey}`,"Content-Type":"application/json"};app.use(express.json());app.use(express.static(path.join(__dirname,"public")));function getErrorMessage(data){if(typeof data?.error==="string")return data.error;if(typeof data?.error?.message==="string")return data.error.message;return "Venice request failed.";}async function postVeniceJson(endpoint,body){const response=await fetch(`${baseUrl}${endpoint}`,{method:"POST",headers:veniceHeaders,body:JSON.stringify(body)});const data=await response.json();if(!response.ok)throw new Error(getErrorMessage(data));return data;}app.post("/api/quote",async(req,res)=>{try{const data=await postVeniceJson("/video/quote",videoSettings);res.json({quote:data.quote});}catch(error){res.status(500).json({error:error.message});}});app.post("/api/generate",async(req,res)=>{const prompt=typeof req.body.prompt==="string"?req.body.prompt.trim():"";if(!prompt)return res.status(400).json({error:"Enter a prompt before generating."});try{const data=await postVeniceJson("/video/queue",{...videoSettings,prompt});res.json({model:data.model,queue_id:data.queue_id});}catch(error){res.status(500).json({error:error.message});}});app.listen(port,error=>{if(error)throw error;console.log(`Shot Lab running at http://localhost:${port}`);});

How is this file organized?

The first section configures Venice access without sending the API key to the browser. The route section exposes local quote and generation endpoints for your interface.

Connect the browser controls

The local routes now exist. Your browser needs request handlers that update the quote area before enabling paid generation.

  • In Visual Studio Code, select public/app.js from the Explorer sidebar.
  • Replace the contents of public/app.js by pasting this code:
const form=document.querySelector("#shot-form"),promptInput=document.querySelector("#prompt"),quoteButton=document.querySelector("#quote-button"),generateButton=document.querySelector("#generate-button"),quoteOutput=document.querySelector("#quote-output"),statusOutput=document.querySelector("#status-output"),videoOutput=document.querySelector("#video-output");let activeVideoUrl=null;function setStatus(message,isError=false){statusOutput.textContent=message;statusOutput.classList.toggle("error",isError)}async function fetchJson(url,options){const response=await fetch(url,options),data=await response.json();if(!response.ok)throw new Error(data.error||"Request failed.");return data}async function quoteVideo(){quoteButton.disabled=true;generateButton.disabled=true;setStatus("Checking the current Venice price...");try{const data=await fetchJson("/api/quote",{method:"POST"});quoteOutput.textContent=`$${data.quote.toFixed(2)} USD`;generateButton.disabled=false;setStatus("Quote ready. Generation has not started.")}catch(error){setStatus(error.message,true)}finally{quoteButton.disabled=false}}quoteButton.addEventListener("click",quoteVideo);form.addEventListener("submit",async event=>{event.preventDefault();const prompt=promptInput.value.trim();if(!prompt)return setStatus("Enter a shot brief first.",true);generateButton.disabled=true;setStatus("Submitting the paid generation job...");try{const job=await fetchJson("/api/generate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({prompt})});sessionStorage.setItem("veniceJob",JSON.stringify(job));setStatus(`Queued: ${job.queue_id}`)}catch(error){setStatus(error.message,true);generateButton.disabled=false}});

What does this browser code do?

  • The fetchJson() helper parses each local server response as JSON.
  • The helper throws returned errors so setStatus() can display them in the page.
  • The quoteVideo() function disables generation while the price request runs.
  • A successful quote appears in #quote-output as a two-decimal USD amount.
  • A successful quote enables #generate-button.
  • The submit handler sends the trimmed prompt to /api/generate.
  • The returned job is stored under veniceJob in sessionStorage so it survives a page reload.
  • Save public/app.js.
  • Return to the Safari tab from earlier.
  • Refresh http://localhost:3000.

Before you request the price, which button do you expect to become available when the quote succeeds?

  • Click Get live quote.

You should see a numeric amount formatted as USD under Current quote. The Generate video button becomes available.

You have the spending gate working. The page now shows the current price before any video generation begins.

Quote does not appear?

  • Confirm the updated server is still running in Terminal.
  • Confirm Safari loaded the refreshed public/app.js file.
  • Review the message shown under Job status for an authentication issue.
  • Review the same message for an account balance issue.
  • Ask for help with the failed Venice quote.

✔️ Awesome, I've got everything!

Your live quote appears in Safari. Double check that you saved public/app.js.

ⓧ I'd like to double check the full code

Compare your complete public/app.js file with this reference.

const form=document.querySelector("#shot-form"),promptInput=document.querySelector("#prompt"),quoteButton=document.querySelector("#quote-button"),generateButton=document.querySelector("#generate-button"),quoteOutput=document.querySelector("#quote-output"),statusOutput=document.querySelector("#status-output"),videoOutput=document.querySelector("#video-output");let activeVideoUrl=null;function setStatus(message,isError=false){statusOutput.textContent=message;statusOutput.classList.toggle("error",isError)}async function fetchJson(url,options){const response=await fetch(url,options),data=await response.json();if(!response.ok)throw new Error(data.error||"Request failed.");return data}async function quoteVideo(){quoteButton.disabled=true;generateButton.disabled=true;setStatus("Checking the current Venice price...");try{const data=await fetchJson("/api/quote",{method:"POST"});quoteOutput.textContent=`$${data.quote.toFixed(2)} USD`;generateButton.disabled=false;setStatus("Quote ready. Generation has not started.")}catch(error){setStatus(error.message,true)}finally{quoteButton.disabled=false}}quoteButton.addEventListener("click",quoteVideo);form.addEventListener("submit",async event=>{event.preventDefault();const prompt=promptInput.value.trim();if(!prompt)return setStatus("Enter a shot brief first.",true);generateButton.disabled=true;setStatus("Submitting the paid generation job...");try{const job=await fetchJson("/api/generate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({prompt})});sessionStorage.setItem("veniceJob",JSON.stringify(job));setStatus(`Queued: ${job.queue_id}`)}catch(error){setStatus(error.message,true);generateButton.disabled=false}});

How is this file organized?

The first section keeps references to the existing page controls. The request handlers turn those controls into a quote gate plus a saved queue receipt.

Submit one paid job and inspect the receipt

A queue receipt identifies the work Venice accepted. The returned queue_id gives your app a specific job to track.

Know what spends money

The quote request does not generate a clip. The next click submits the paid queue request.

Submit this job once. Your saved receipt lets the next step continue with the same paid request.

Before you submit the request, do you think queue acceptance will fill the video player straight away?

  • Enter A paper boat sailing through a neon city puddle at night, slow tracking shot in the Shot brief field.
  • Click Generate video once.

You should see Queued: followed by a real queue_id under Job status. Your numeric USD quote remains visible.

No completed clip appears in the video area. This intended shortfall proves that queue acceptance gives you a receipt before the media is ready.

Job does not enter the queue?

  • Confirm the prompt field contains a shot description.
  • Confirm you requested a live quote after the latest page refresh.
  • Review the returned status message for a key spending-limit issue.
  • Retry later if the returned message mentions temporary capacity.
  • Ask for help with the failed queue submission.

Your quote gate and paid queue submission now work. Next up, you will reuse this saved job while polling Venice for the finished MP4.

Poll, Play, and Clean Up the Video

Your live quote works. Your paid request is safely stored as a queue receipt from the Venice AI API. The player in Safari is still empty.

That empty player exposes the gap in an asynchronous queue. Queue acceptance confirms that processing started. In this step, polling closes the gap between the receipt and the finished video.

In this step, get ready to:
  • Build a server route for retrieving the queued job.
  • Handle processing JSON or completed MP4 bytes.
  • Resume the saved browser job until its video is playable.
Add the retrieve route

The Express server needs a route that accepts the saved job identifiers. While Venice is still working, the route passes its processing JSON back to the browser.

  • Return to server.js in the VS Code Explorer sidebar.
  • Find the app.listen block at the bottom of server.js.
  • Add the retrieve route immediately above app.listen by pasting this code:
app.post("/api/retrieve", async (req, res) => {
  const { model: queuedModel, queue_id } = req.body;

  if (typeof queuedModel !== "string" || typeof queue_id !== "string") {
    return res.status(400).json({ error: "A saved model and queue_id are required." });
  }

  try {
    const response = await fetch(`${baseUrl}/video/retrieve`, {
      method: "POST",
      headers: veniceHeaders,
      body: JSON.stringify({
        model: queuedModel,
        queue_id,
        delete_media_on_completion: true,
      }),
    });
    const contentType = response.headers.get("content-type") || "";

    const data = await response.json();

    if (!response.ok) {
      return res.status(response.status).json({ error: getErrorMessage(data) });
    }

    res.json(data);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

What does this route do?

  • The route reads model as queuedModel. This avoids replacing the fixed server-side model value.
  • The validation rejects requests without string values for queuedModel or queue_id.
  • The request sends delete_media_on_completion: true. This asks Venice to delete its stored media after the completed response is retrieved.
  • The final lines pass processing data through to the browser. This gives the page the elapsed execution time for its status display.
  • Save server.js.
  • Switch back to the Terminal panel from earlier.
  • Stop the running server with Control+C.
  • Restart the updated server by running this command:
node --env-file=.env server.js

You should see Shot Lab running at http://localhost:3000. This confirms that the new route has valid JavaScript syntax.

Server not starting?

  • Confirm that the retrieve route sits above the existing app.listen block.
  • Check that every opening brace in the new route has a matching closing brace.
  • Confirm that the command runs inside the existing venice-shot-lab folder.
  • Help me debug the new retrieve route.
Return the completed MP4

The same endpoint eventually changes response type. Processing checks return JSON. A completed job returns binary MP4 bytes.

  • In server.js, find the line that reads const data = await response.json();.
  • Add the completed-video branch directly above that line by pasting this code:
    if (contentType.includes("video/mp4")) {
      if (!response.ok) {
        return res.status(response.status).json({ error: "Video retrieval failed." });
      }

      const videoBuffer = Buffer.from(await response.arrayBuffer());
      res.set("Content-Type", "video/mp4");
      return res.send(videoBuffer);
    }

How does the binary response work?

  • The content-type check separates a completed video from a processing update.
  • A completed response becomes a Buffer inside Node.js. The Buffer keeps the MP4 bytes intact.
  • The response sets Content-Type to video/mp4. This tells Safari how to interpret the body.
  • The early return prevents the server from trying to parse the binary video as JSON.
  • Save server.js.
  • Switch back to the Terminal panel.
  • Stop the current server with Control+C.
  • Restart the complete retrieve route by running:
node --env-file=.env server.js

You should see the local URL again. Your server can now return either the latest processing state or the completed video bytes.

Seeing a retrieval problem?

  • Check that the MP4 branch appears before response.json().
  • Confirm that Buffer.from receives the result of response.arrayBuffer().
  • Keep the original queue job saved. Avoid submitting another paid generation while fixing retrieval.
  • Help me debug the MP4 response.

✔️ Awesome, I've got everything!

Great. Double check that you saved server.js after adding the complete retrieve route.

ⓧ I'd like to double check the full code

const express = require("express");
const path = require("node:path");

const app = express();
const port = 3000;
const baseUrl = "https://api.venice.ai/api/v1";
const model = "wan-2.5-preview-text-to-video";
const videoSettings = {
  model,
  duration: "5s",
  resolution: "480p",
  aspect_ratio: "16:9",
};
const apiKey = process.env.VENICE_API_KEY;

if (!apiKey) {
  throw new Error("Missing VENICE_API_KEY. Add it to .env before starting the server.");
}

const veniceHeaders = {
  Authorization: `Bearer ${apiKey}`,
  "Content-Type": "application/json",
};

app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));

function getErrorMessage(data) {
  if (typeof data?.error === "string") return data.error;
  if (typeof data?.error?.message === "string") return data.error.message;
  return "Venice request failed.";
}

async function postVeniceJson(endpoint, body) {
  const response = await fetch(`${baseUrl}${endpoint}`, {
    method: "POST",
    headers: veniceHeaders,
    body: JSON.stringify(body),
  });
  const data = await response.json();

  if (!response.ok) {
    throw new Error(getErrorMessage(data));
  }

  return data;
}

app.post("/api/quote", async (req, res) => {
  try {
    const data = await postVeniceJson("/video/quote", videoSettings);
    res.json({ quote: data.quote });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.post("/api/generate", async (req, res) => {
  const prompt = typeof req.body.prompt === "string" ? req.body.prompt.trim() : "";

  if (!prompt) {
    return res.status(400).json({ error: "Enter a prompt before generating." });
  }

  try {
    const data = await postVeniceJson("/video/queue", {
      ...videoSettings,
      prompt,
    });
    res.json({ model: data.model, queue_id: data.queue_id });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.post("/api/retrieve", async (req, res) => {
  const { model: queuedModel, queue_id } = req.body;

  if (typeof queuedModel !== "string" || typeof queue_id !== "string") {
    return res.status(400).json({ error: "A saved model and queue_id are required." });
  }

  try {
    const response = await fetch(`${baseUrl}/video/retrieve`, {
      method: "POST",
      headers: veniceHeaders,
      body: JSON.stringify({
        model: queuedModel,
        queue_id,
        delete_media_on_completion: true,
      }),
    });
    const contentType = response.headers.get("content-type") || "";

    if (contentType.includes("video/mp4")) {
      if (!response.ok) {
        return res.status(response.status).json({ error: "Video retrieval failed." });
      }

      const videoBuffer = Buffer.from(await response.arrayBuffer());
      res.set("Content-Type", "video/mp4");
      return res.send(videoBuffer);
    }

    const data = await response.json();

    if (!response.ok) {
      return res.status(response.status).json({ error: getErrorMessage(data) });
    }

    res.json(data);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.listen(port, (error) => {
  if (error) throw error;
  console.log(`Shot Lab running at http://localhost:${port}`);
});
Resume the saved job

The browser now needs to check the retrieve route repeatedly. When the MP4 arrives, a Blob preserves the binary response. A browser object URL then gives the video player a temporary source.

  • Return to public/app.js in the VS Code Explorer sidebar.
  • Find the closing brace of fetchJson.
  • Add the polling delay helper directly below fetchJson by pasting this code:
function wait(milliseconds) {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

Why add a delay helper?

The wait function turns a timer into a Promise. The polling loop can pause for five seconds without freezing the page.

  • Find the closing brace of quoteVideo.
  • Add the processing loop directly below quoteVideo by pasting this code:
async function pollForVideo(job) {
  quoteButton.disabled = true;
  generateButton.disabled = true;
  try {
    while (true) {
      await wait(5000);
      const response = await fetch("/api/retrieve", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(job),
      });
      const contentType = response.headers.get("content-type") || "";
      if (!response.ok) {
        const data = await response.json();
        throw new Error(data.error || "Video retrieval failed.");
      }
      const data = await response.json();
      const elapsedSeconds = Math.round((data.execution_duration || 0) / 1000);
      setStatus(`Processing... ${elapsedSeconds}s elapsed`);
    }
  } finally {
    quoteButton.disabled = false;
    generateButton.disabled = false;
  }
}

How does polling track progress?

  • The loop disables both action buttons. This prevents another submission while the saved job is active.
  • Each cycle waits 5000 milliseconds before requesting /api/retrieve.
  • A processing response includes execution_duration in milliseconds. The browser converts it into rounded seconds for the status panel.
  • The finally block restores both buttons when polling ends.
  • Inside pollForVideo, find the line that reads const data = await response.json();.
  • Add the completed-video branch directly above that line by pasting this code:
      if (contentType.includes("video/mp4")) {
        const videoBlob = await response.blob();

        if (activeVideoUrl) {
          URL.revokeObjectURL(activeVideoUrl);
        }

        activeVideoUrl = URL.createObjectURL(videoBlob);
        videoOutput.src = activeVideoUrl;
        videoOutput.hidden = false;
        sessionStorage.removeItem("veniceJob");
        setStatus("Complete. Your video is ready to play.");
        return;
      }

How does the video reach the player?

  • The completed response becomes a Blob that retains its media bytes.
  • The browser revokes an earlier object URL when one exists. This releases the old temporary reference.
  • The new object URL becomes videoOutput.src. Setting videoOutput.hidden to false reveals the player.
  • The saved veniceJob entry is removed after completion. A later refresh cannot resume a finished job.
  • Find setStatus(`Queued: ${job.queue_id}`); inside the form submission handler.
  • Add this line directly beneath the queued status line:
    await pollForVideo(job);

Why connect polling here?

A newly submitted job starts polling as soon as its queue receipt is saved. The same paid request moves from queue receipt to completed video.

  • Move to the bottom of public/app.js.
  • Add the reload recovery function after the form submission handler by pasting this code:
function resumeSavedJob() {
  const savedJob = sessionStorage.getItem("veniceJob");
  if (!savedJob) return;

  try {
    const job = JSON.parse(savedJob);
    setStatus(`Resuming queued job: ${job.queue_id}`);
    pollForVideo(job).catch((error) => setStatus(error.message, true));
  } catch {
    sessionStorage.removeItem("veniceJob");
  }
}

resumeSavedJob();

How does reload recovery work?

  • The function reads veniceJob from sessionStorage. That storage survives a page reload in the same tab.
  • Valid saved JSON restarts pollForVideo with the original model and queue_id.
  • Invalid saved JSON is removed. This prevents broken state from triggering repeated parsing failures.
  • The final function call performs the recovery whenever the page script loads.
  • Save public/app.js.

✔️ Awesome, I've got everything!

Great. Double check that you saved public/app.js before refreshing Safari.

ⓧ I'd like to double check the full code

const form = document.querySelector("#shot-form");
const promptInput = document.querySelector("#prompt");
const quoteButton = document.querySelector("#quote-button");
const generateButton = document.querySelector("#generate-button");
const quoteOutput = document.querySelector("#quote-output");
const statusOutput = document.querySelector("#status-output");
const videoOutput = document.querySelector("#video-output");

let activeVideoUrl = null;

function setStatus(message, isError = false) {
  statusOutput.textContent = message;
  statusOutput.classList.toggle("error", isError);
}

async function fetchJson(url, options) {
  const response = await fetch(url, options);
  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || "Request failed.");
  }

  return data;
}

function wait(milliseconds) {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

async function quoteVideo() {
  quoteButton.disabled = true;
  generateButton.disabled = true;
  setStatus("Checking the current Venice price...");

  try {
    const data = await fetchJson("/api/quote", { method: "POST" });
    quoteOutput.textContent = `$${data.quote.toFixed(2)} USD`;
    generateButton.disabled = false;
    setStatus("Quote ready. Generation has not started.");
  } catch (error) {
    setStatus(error.message, true);
  } finally {
    quoteButton.disabled = false;
  }
}

async function pollForVideo(job) {
  quoteButton.disabled = true;
  generateButton.disabled = true;

  try {
    while (true) {
      await wait(5000);

      const response = await fetch("/api/retrieve", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(job),
      });
      const contentType = response.headers.get("content-type") || "";

      if (!response.ok) {
        const data = await response.json();
        throw new Error(data.error || "Video retrieval failed.");
      }

      if (contentType.includes("video/mp4")) {
        const videoBlob = await response.blob();

        if (activeVideoUrl) {
          URL.revokeObjectURL(activeVideoUrl);
        }

        activeVideoUrl = URL.createObjectURL(videoBlob);
        videoOutput.src = activeVideoUrl;
        videoOutput.hidden = false;
        sessionStorage.removeItem("veniceJob");
        setStatus("Complete. Your video is ready to play.");
        return;
      }

      const data = await response.json();
      const elapsedSeconds = Math.round((data.execution_duration || 0) / 1000);
      setStatus(`Processing... ${elapsedSeconds}s elapsed`);
    }
  } finally {
    quoteButton.disabled = false;
    generateButton.disabled = false;
  }
}

quoteButton.addEventListener("click", quoteVideo);

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const prompt = promptInput.value.trim();

  if (!prompt) {
    setStatus("Enter a shot brief first.", true);
    return;
  }

  generateButton.disabled = true;
  setStatus("Submitting the paid generation job...");

  try {
    const job = await fetchJson("/api/generate", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ prompt }),
    });

    sessionStorage.setItem("veniceJob", JSON.stringify(job));
    setStatus(`Queued: ${job.queue_id}`);
    await pollForVideo(job);
  } catch (error) {
    setStatus(error.message, true);
    generateButton.disabled = false;
  }
});

function resumeSavedJob() {
  const savedJob = sessionStorage.getItem("veniceJob");
  if (!savedJob) return;

  try {
    const job = JSON.parse(savedJob);
    setStatus(`Resuming queued job: ${job.queue_id}`);
    pollForVideo(job).catch((error) => setStatus(error.message, true));
  } catch {
    sessionStorage.removeItem("veniceJob");
  }
}

resumeSavedJob();

Refreshing the page reuses the saved queue job. It does not submit another paid generation.

Before you refresh, predict which identifier the page will use to resume processing.

  • Refresh the existing http://localhost:3000 tab in Safari.

You should first see a status containing the saved queue_id. During processing, the elapsed time updates after checks spaced about five seconds apart.

When retrieval completes, the status changes to Complete. Your video is ready to play. The previously hidden player becomes visible with playback controls.

  • Click Play in the video controls.

You should see your original five-second shot play inside the page. The app reaches this result without submitting a second paid job.

What gets cleaned up?

The retrieve request asks Venice to delete its hosted media after completion. The browser also removes veniceJob from session storage.

A previous object URL is revoked before a replacement is created. These cleanup points prevent completed jobs from lingering in the app workflow.

Video still not appearing?

  • Refresh the same Safari tab that contains the saved veniceJob entry.
  • Keep the local server running while the page polls the retrieve route.
  • Retry later if Venice repeatedly reports a capacity problem. Avoid submitting another paid generation while the original queue job remains saved.
  • Help me trace the saved job from queue to video.

You did it. Your original paid queue job now resumes after a reload. Its completed MP4 plays directly in the Shot Lab.

Secret mission

Add Portrait Video Quotes

Your Shot Lab currently frames every clip as a landscape video. Extend it with a portrait option that requests a fresh quote, stays synchronized with generation, and blocks unsupported aspect ratios from reaching Venice.

Clean Up Your Resources

Clean Up Your Resources

Your completed generation has already incurred its quoted cost. Your choices are to keep the app running, stop it for later, or remove the project folder and API key.

Resources you used:

  • The venice-shot-lab folder, including .env, node_modules/, package-lock.json, and the source files.
  • The local Node.js server process running in macOS Terminal.
  • The persistent Inference Only key for the Venice AI API.
  • The Safari tab containing the completed video and its browser session.

What cleans itself up?

The retrieve request uses delete_media_on_completion: true to ask Venice to remove its hosted media after retrieval.

The browser Blob URL lasts only for the page session. Closing the Safari tab releases it.

Keep everything running

No action is required. Choose this option if you are still testing landscape and portrait quotes or plan to extend the Shot Lab.

  • Keep the venice-shot-lab folder in its current location.
  • Leave the server running only while you are actively using the Shot Lab.
  • Keep the Safari tab open if you want the current in-browser video to remain available.
  • Keep the key's Epoch Consumption Limit conservative for future experiments.

Pause - I'll come back to this later

Stop the local activity while keeping the code and server-side key ready for another session.

  • Press Control+C in the macOS Terminal window where the server is running.
  • Close the Safari tab to release the in-browser Blob URL.
  • Keep the venice-shot-lab folder in its current location.
  • Reuse the server startup command from Step 1 when you return.

Your Shot Lab is safely paused. Its files and server-side key remain ready for your next session.

Delete - I don't want to use this again

Deleting the local folder is permanent, so save anything you want to keep first. Your Venice account and remaining balance stay intact.

  • Press Control+C in the macOS Terminal window where the server is running.
  • Return to the Venice API settings page in Safari.
  • Select the Inference Only key you created for this project.
  • Use the delete control for that key.

The key should no longer appear in your API settings. This confirms that it cannot authorize future requests.

  • Close the Safari tab.
  • Click Finder in the Dock.
  • Search for venice-shot-lab.
  • Move the selected venice-shot-lab folder to Trash.
  • Empty Trash.
  • Search for venice-shot-lab again.

You should see no result named venice-shot-lab. Your cleanup is complete.

Nice Work!

Nice Work!

Mission complete! Your local Shot Lab now turns a cinematic brief into a playable five-second clip through the Venice AI API.

What you learned:

  • Create a live quote-before-spend control that shows the current USD price before the paid queue request.
  • Keep the Venice API key on the Node.js server so browser code never exposes the secret.
  • Resume an asynchronous queue across a page reload with sessionStorage. Five-second polling retrieves the binary MP4 for in-page playback. Venice removes its stored media after retrieval.
  • Complete the optional Secret Mission by adding a landscape or portrait setting. The verified value controls pricing. It also controls generation. A server-side allowlist accepts only 16:9 or 9:16.

Ready to quiz yourself?