@getvhs/vhscli
v0.1.21
Published
generate images, videos, and audio with ai
Readme
vhscli
vhscli is a small CLI for sending image, video, audio, and chat jobs to VHS.
The client handles auth, input uploads, task creation, polling, and writing output files. Model execution stays on the VHS service, so users do not need provider API keys on their machine.
Quick Start
Run without installing:
npx @getvhs/vhscli@latest login
npx @getvhs/vhscli@latest models
npx @getvhs/vhscli@latest generate seedream-5 "a corgi astronaut riding a bicycle on mars" -o corgi.jpgThe installed binary is vhscli:
vhscli login
vhscli models
vhscli generate gpt-image-2 "a clean app icon for a video tool" -o icon.pngUse - when the prompt should come from stdin:
cat prompt.txt | vhscli generate gpt-image-2 - -o art.png
cat question.txt | vhscli chat - -f paper.pdfRequirements
- Node.js
24or newer ffmpegonPATHwhen the requested output format differs from the source (images, video and audio all convert through it)ffprobeonPATHwhenever a local file is uploaded as a reference: every reference image and video is probed for its dimensions before upload (-i,-v,--first-frame,--last-frame, andchat's-i/-v), andseed-audio-1reference clips are probed for duration (≤30s)
vhscli bundles no binaries: both tools are resolved from PATH. (VHS Studio,
which embeds this CLI, puts its own copies first on the PATH of the process
it spawns, so a Studio install needs nothing installed separately.)
Auth
vhscli login
vhscli whoami
vhscli logoutlogin opens the browser, listens for the OAuth callback locally, writes the session to ~/.vhs/session.json, and initializes the user's billing account on first login.
whoami prints the session email, falling back to the user id. It reads the
saved session locally and decodes it — no server round trip, no token refresh —
so it answers in milliseconds and is safe to call at startup. That also means it
reports the identity in a saved token that may since have expired; whether a
request will go through is what running the request tells you.
logout deletes the local session file.
Commands that need auth will start login if no valid session exists.
Where a task lives
Nowhere on your machine. A generation is a row in the VHS backend, and the task id is the only handle to it — the CLI writes no database, no sidecar files, and no config beyond the session token.
That is what makes a task portable: submit on one machine, resume on
another, days later, with only the id passed between them. It also means the
CLI has no notion of a "project" or working directory. Every path you give it
means what it would mean to any other command run from the same place.
Two consequences worth knowing:
- Keep the id
submitprints. Losing it strands a task you have paid for. Use--task-idto choose the id yourself when you need to record it before submitting (see below). resumeneeds-oas well as the id. The task knows which model ran and what was asked; only you know where the file should go. (Same forsave, which isresumewithout the waiting.)
Commands
vhscli models
Prints the model aliases known to the CLI.
vhscli generate
generate is a command group. Use help to list model subcommands:
vhscli generate --helpAsk a model subcommand for its exact flags:
vhscli generate gpt-image-2 --helpgenerate submits the task, waits for it, and saves the output to -o
(required). It prints the task id first, so an interrupted run can be
finished later with vhscli resume <task_id> -o <path>.
vhscli submit <model> [options]
Same models and options as generate, but it prints the task id
(task_id: <uuid>) and exits without waiting. -o is validated but not
written — nothing lands until you resume.
vhscli submit seedance-2 "a robot dancing in tokyo at night" -o clip.mp4
# prints "task_id: <uuid>" and exits
vhscli resume <task_id> -o clip.mp4
# waits for the task to finish and writes clip.mp4--task-id <uuid> submits under an id you choose instead of a fresh one, so
you can record it before the submit returns. If the command dies before
printing anything, re-run it unchanged: the backend insert and the submit are
both idempotent by id, so the re-run joins the same task rather than paying
for a second one.
vhscli resume <task_id> -o <path>
Finishes a submitted generation. It waits if the task is still running, then
writes the output to -o. Which model ran and what was asked are read back
from the task itself; the id is the only thing you have to keep.
vhscli resume 7d3c1b2a-... -o clip.mp4Safe to re-run: a finished task is re-read from the server, never resubmitted.
The server job keeps running after the local process exits. resume attaches
the CLI back to that task and writes the result when it is ready.
resume is poll + save, and it is the right command when you are driving
one generation by hand. To run several at once, use those two directly.
vhscli poll -
Reads task ids from stdin, one per line, and prints exactly one — the first
to finish — then exits. It writes no files and takes no -o.
# watch a batch: poll, save what it gives you, poll again with the rest
left=("${ids[@]}")
while [ ${#left[@]} -gt 0 ]; do
id="$(printf '%s\n' "${left[@]}" | vhscli poll -)"
vhscli save "$id" -o "$id.png"
left=($(printf '%s\n' "${left[@]}" | grep -vFx "$id"))
donestdout is that one id and nothing else, so it pipes straight into the next
command; the human-readable state goes to stderr. With --json it is a single
{"event":"task","task_id":"...","state":"done"|"error"|"missing"} line
instead.
Ids may keep arriving while poll runs, and each starts being watched as it lands. That is the point of the interface: a long-running program writes a line each time it submits something, so a task fired a second ago is watched without waiting for the batch already in flight.
One result ends the process. So a driver keeps its own list of what is outstanding, drops the id it was just handed, and polls again with the rest — re-sending anything it never saw reported, because an id written to a poll that is already exiting goes into a pipe nobody will read. The driver's list is the record; this process is only a conduit. It also means the driver runs zero or one poll at a time, never one parked on nothing.
Polling again immediately is cheap. Each id is checked against its task before
any waiting, so one that finished while the last poll was on its way out — or
that names no task at all — is answered off that check with no waiting.
missing means there is no such task (a typo, or an id that was never
submitted); it counts as finished, so a bad id cannot hang the watch.
A poll that dies of a network failure exits without printing anything, which is the same situation: the tasks are still running server-side, and the driver polls again from its list. Nothing is lost by that, which is why there is no retry in here.
vhscli save <task_id> -o <path>
Writes an already-finished task's output to -o. It never waits: a task still
running is an error here, and poll is what waits for it.
vhscli save 7d3c1b2a-... -o cat.pngSafe to re-run — the outcome is read back off the task, never regenerated.
save is what turns a task id into a correct file, and is worth preferring
over fetching the result URL yourself: the result envelope differs per model,
the download lands atomically (a partial download is never left at -o), and
the file is converted when the provider's format differs from your extension.
A task that failed is an error, with the task's error as the message.
vhscli chat <prompt>
Runs one chat request against seed-2.0 and writes the answer to stdout. It does not create an output file.
vhscli chat "explain how to make sourdough in 5 steps"
vhscli chat "describe this image as json" -i photo.jpg
vhscli chat "summarize this paper in 5 bullets; include page numbers" -f paper.pdf
vhscli chat "list key events with HH:mm:ss timestamps" -v clip.mp4 --fps 2Options:
-i <path>attaches an image. Repeat for multiple images.-f <path>attaches a PDF. Repeat for multiple files.-v <path>attaches one video.--fps <n>samples video from0.2to5frames per second. Default is1.
Image Models
The output format is the provider's choice, so writing to any supported
extension (.png, .jpg/.jpeg, .webp) works — the CLI converts the
download locally when it has to. The -o extension selects the local format:
png, jpg, jpeg, or webp.
--size
Every image model takes one flag for the frame: --size <width>x<height> in
pixels. Default is 1920x1080.
vhscli generate gpt-image-2 "..." -o wide.png --size 3840x2160
vhscli generate nano-banana-2 "..." -o phone.png --size 1080x1920
vhscli generate seedream-5 "..." -o square.png --size 1920x1920One value carries the aspect ratio and the resolution together, and it means
the same frame on every model — which is the point, because the providers
underneath agree on nothing. OpenAI takes exact pixels inside one window;
Seedream takes exact pixels inside a much higher one; Nano Banana takes no
pixel count at all, only an aspect ratio and a 1K/2K/4K tier.
The backend fits your size to whichever provider runs it, so a frame is never refused for being unrenderable — but what comes back may differ:
| model | what it does with a size |
|---|---|
| gpt-image-2 | snaps both edges to a multiple of 16, inside 655360–8294400 pixels, max edge 3840, ratio at most 3:1 |
| seedream-5, seedream-5-pro | scales into 3686400–10404496 pixels — its floor is above 2K, so a small frame is scaled up |
| nano-banana-2, nano-banana-pro | rounds to its nearest aspect ratio and nearest 1K/2K/4K tier |
The shape is preserved in every case (a ratio past a model's limit is the one
exception). The CLI itself only checks the form and that each edge is between
64 and 4096; the fitting happens server-side.
seedream-5
Seedream 5.0 Lite image generation and editing (BytePlus).
vhscli generate seedream-5 "a girl in a yellow raincoat walking under a parasol, monet oil painting style" -o girl.png
vhscli generate seedream-5 "remove her hat, keep everything else" -i photo.jpg -o edit.pngOptions:
-o, --output <path>sets the output path.-i <path>adds a reference image. Maximum14; repeat for more.--size <size>as above. Renders nothing below ~2K:960x540comes back at roughly2560x1440.
seedream-5-pro
Seedream 5.0 Pro image generation and editing (BytePlus). The pro tier is slower than seedream-5 — roughly 2 minutes per image — but stronger on prompt adherence and fine detail. Same frame limits.
vhscli generate seedream-5-pro "a lone lighthouse on a cliff at dusk, long exposure, crashing surf" -o lighthouse.png
vhscli generate seedream-5-pro "add a flock of birds across the sky, keep the style" -i scene.png -o birds.pngOptions:
-o, --output <path>sets the output path.-i <path>adds a reference image. Maximum14; repeat for more.--size <size>as above.
nano-banana-2
Nano Banana 2 image generation and editing (Google, via token360).
vhscli generate nano-banana-2 "remove the man from the photo, keep everything else" -i photo.jpg -o clean.png
vhscli generate nano-banana-2 "a glossy candle in a bell jar on a marble counter, soft light" -o candle.pngOptions:
-o, --output <path>sets the output path.-i <path>adds a reference image. Maximum14; repeat for more.--size <size>as above. Served as the nearest of21:9,16:9,3:2,4:3,5:4,1:1,4:5,3:4,2:3,9:16at the nearest of1K/2K/4K.
nano-banana-pro
Nano Banana Pro image generation and editing (Google, via token360). Slower than nano-banana-2, stronger on fine detail and rendered text. Same ratios and tiers.
vhscli generate nano-banana-pro "a glossy face moisturizer jar on warm studio backdrop" -o jar.png
vhscli generate nano-banana-pro "a sun-drenched minimalist living room with a 3d armchair from this sketch" -i sketch.jpg -o room.pngOptions:
-o, --output <path>sets the output path.-i <path>adds a reference image. Maximum14; repeat for more.--size <size>as above.
gpt-image-2
OpenAI gpt-image-2 image generation and editing (via token360).
vhscli generate gpt-image-2 "a children's book drawing of a veterinarian examining a cat" -o vet.png
vhscli generate gpt-image-2 "replace the background with a starry night" -i photo.jpg -o night.pngOptions:
-o, --output <path>sets the output path.-i <path>adds a reference image. Repeat for more.--size <size>as above.
Video Models
seedance-2
Seedance 2.0 video generation.
vhscli generate seedance-2 "a woman in a red dress walks through a rainy neon-lit alley, slow tracking shot" -o alley.mp4
vhscli generate seedance-2 "animate this photo: gentle pan to the right" --first-frame photo.jpg -o pan.mp4
vhscli generate seedance-2 "match the camera move from this clip in a cyberpunk street" -v ref.mp4 -o cyber.mp4Options:
-o, --output <path>sets the output path (.mp4,.webm, or.mov; prefer.mp4).--first-frame <image>uses an image as the first frame.--last-frame <image>uses an image as the last frame. It requires--first-frame.-i <path>adds a reference image. Maximum9. Conflicts with--first-frame.-v <path>adds a reference video. Maximum3; repeat for more. Conflicts with--first-frame.-a <path>adds a reference audio file. Maximum3. Requires at least one-ior-v; conflicts with--first-frame.--ratio <ratio>accepts21:9,16:9,4:3,1:1,3:4, or9:16. Default is16:9. There is no "adaptive" — the output frame is always one you picked.--resolution <res>accepts480p,720p,1080p, or4k. Default is720p. Lowercase only —4Kand1080Pare rejected.--duration <n>accepts4to15. Default is5.--audio/--no-audiotoggles the audio track. Default is--audio(audio on); pass--no-audiofor a silent video.
The command polls until the result is ready and prints progress. To avoid blocking, use vhscli submit seedance-2 ... (same flags) to detach immediately, then vhscli resume <task_id> -o clip.mp4 later. An interrupted generate is finished the same way, with the id it printed at submit.
seedance-2.5
Seedance 2.5 video generation — the same model as seedance-2, except
resolution tops out at 1080p, duration stretches to 30 seconds, and
reference caps rise to 30 images / 10 videos / 10 audios. For 4k, use
seedance-2 instead.
vhscli generate seedance-2.5 "a woman in a red dress walks through a rainy neon-lit alley, slow tracking shot" -o alley.mp4Same options as seedance-2, with these differences:
--resolution <res>accepts480p,720p, or1080p— no4k. Default is720p.--duration <n>accepts4to30. Default is5.-i <path>maximum30(vs9onseedance-2).-v <path>maximum10(vs3onseedance-2).-a <path>maximum10(vs3onseedance-2).
minimax-h3
MiniMax H3 video generation (via the fal.ai aggregator). The output always
carries an audio track — H3 renders its own; there is no --no-audio. Cite
references in the prompt by modality and list order: "Image 1", "Video 1",
"Audio 1".
vhscli generate minimax-h3 "a woman in a red dress walks through a rainy neon-lit alley, slow tracking shot" -o alley.mp4
vhscli generate minimax-h3 "animate this photo: gentle pan to the right" --first-frame photo.jpg -o pan.mp4
vhscli generate minimax-h3 "Image 1 is the singer. She sings along to Audio 1 on a rooftop at dusk" -i singer.jpg -a song.mp3 -o rooftop.mp4Options:
-o, --output <path>sets the output path (.mp4,.webm, or.mov; prefer.mp4).--first-frame <image>uses an image as the first frame. The output frame follows this image.--last-frame <image>uses an image as the last frame. It requires--first-frame.-i <path>adds a reference image. Maximum9. Conflicts with--first-frame.-v <path>adds a reference video (2-15seconds each, at most15seconds combined). Maximum3. Conflicts with--first-frame.-a <path>adds a reference audio file (2-15seconds each, at most15seconds combined). Maximum3. Requires at least one-ior-v; conflicts with--first-frame.- Reference files are capped at
12in total across-i/-v/-a. --ratio <ratio>accepts21:9,16:9,4:3,1:1,3:4, or9:16. Default is16:9. Conflicts with--first-frame(the output follows the image).--resolution <res>accepts768por2k. Default is768p. Lowercase only.--duration <n>accepts5to15. Default is5.
Audio Model
seed-audio-1
Seed Audio 1.0 text-to-speech (BytePlus). Output is always .mp3.
vhscli generate seed-audio-1 "Welcome to VHS." -o welcome.mp3
vhscli generate seed-audio-1 "Read this in the reference voice." -i voice.mp3 -o out.mp3
vhscli generate seed-audio-1 "Blend these voices." -i v1.mp3 -i v2.mp3 -o blend.mp3Options:
-o, --output <path>sets the output path (must be.mp3).-i <path>adds a reference voice clip for cloning or blending. Maximum3; repeat for more. Each clip should be ≤30s and ≤10MB.
Files And Output
-o is required; it sets the output path, interpreted relative to your cwd. The CLI writes exactly there — it does not re-home or de-conflict. A caller that must not overwrite should check the path (or pick a free name) before calling.
Downloads are written through a temporary file and renamed into place. If the requested extension does not match the downloaded media type, conversion happens locally through ffmpeg — images included (-update 1 -frames:v 1 takes the first frame of a multi-frame source).
Local inputs are uploaded via the s3tmpfile edge function: it answers with a presigned PUT URL, the bytes go straight to the bucket, and the resulting public URL is what the provider fetches. There is no dedupe: the same file passed twice is uploaded twice.
The CLI never deletes an upload. It can't know when the file has served its purpose — the provider fetches the URL on its own schedule, vhscli submit exits while it is still doing so, and the seedance privacy fallback re-reads the same URLs after a rejection. Cleanup is the server's job: the bucket carries a 1-day lifecycle expiry. This is why uploading is a plain fire-and-forget call with no lifecycle to track.
Image type is detected from file content. JPEG and PNG are uploaded as-is; other image formats are converted to JPEG first.
Oversized inputs are shrunk with ffmpeg before upload — providers reject very large uploads and downsample high-res inputs server-side anyway. Reference images over 2048px on an edge or 10MB are scaled to fit 2048px (PNG stays PNG, so transparency survives). Reference videos over 1920px on an edge or 50MB are re-encoded to fit 1920px (h264/aac). Audio and PDF inputs are uploaded as-is.
Smoke Tests
smoke/ holds end-to-end tests that hit the live backend with a real
session (they cost money and take minutes — the seedance tests render
video). Each script generates an output, asserts the file is non-empty,
and asks chat to describe it. Run them all with:
npm run smoke # logs + summary under smoke/out/logs/<timestamp>/Project Structure
src/
main.ts # command tree, login/logout/whoami/models, top-level error handler
task.ts # submit/generate/resume/poll/save orchestration + failure-tolerant polling
version.ts
models/
index.ts # names the models, registers them, dispatches save by name
types.ts # Mode / Opts / Payload — the shared vocabulary, nothing more
image.ts # one function per image model (seedream, nano banana, gpt-image)
# + the shared --size (WxH) parser they all use
video.ts # seedance-2, seedance-2.5, and their shared real-face rejection check + byteplus asset rewrite
audio.ts # seed-audio-1, and its reference-clip rules
cmd/
chat.ts
resume.ts
lib/
auth.ts # fresh_session — a refreshed access token per request
backend.ts # generic invoke (rpc over http) + named helpers for shared endpoints + task2 rows
error.ts # Fail + fail() (thrown; main.ts is the only exit point), message_of
flags.ts # shared commander parsers (collect) + the --task-id handoff
media.ts # save_media (streaming download + convert), detect_mime, Kind, output resolve + stat/hash
process.ts # run a local tool (ffmpeg/ffprobe) to completion
prompt.ts # the prompt argument, or stdin when the user passed `-`
session.ts # interactive login (ensure_login, login)
storage.ts # uploads via s3tmpfile presign + direct s3 PUT (upload_images, upload_videos, upload_files)A model is a plain function. There is no model config anywhere. Each one —
seedream_5, seedance_2, seed_audio_1 — writes its own command end to end:
its description, its -o hint, its flags, its --size parser, its reference
limits, its help text, its endpoint, and its run body. Every limit is a literal
in the code that enforces it (if (images.length > 14) fail(...)), not a field
some generic builder reads. You read a model by reading its function, top to
bottom; nothing else has to be consulted.
Every model's action has the same three sections, in this order:
- check — the output path and the flags, everything knowable without a network call. This runs before login, so a typo costs nothing. Then log in and read the prompt.
- upload — send the local inputs, assemble the provider payload.
- generate — hand off to
run_generation: submit → wait → save.
They are grouped by output kind (image.ts, video.ts, audio.ts). Models of
the same kind share only real code, taking values rather than settings — every
image provider sends the same request and answers with one image_url, so
image.ts has one image_request(prompt, size, images) and one save_image().
All five image models share one --size parser too, because --size is now
one form (WxH) for all of them: the per-provider frame limits live on the
server, where the frame is fitted to whichever model actually runs.
models/index.ts names the models and dispatches by name, with a switch:
| | |
|---|---|
| register_models | calls each model's function under generate and submit |
| save_result | the provider result → the output file |
Save is reached by name rather than by closure because vhscli resume
picks a task up in a new process, holding only a task id — the model name is
recovered from the task's endpoint via from_endpoint. save_result is
exhaustive over the name union, so a new model does not compile until it is
handled.
Adding a model is one function, one name, one call in register_models —
and the compiler then points at save_result.
lib/backend.ts owns all server communication: named, validated helpers for
the endpoints every command shares (submit2, poll2, the task2 rows),
plus a generic invoke for model-specific endpoints (the privacy fallback hits
kyc/create_asset this way). Callers never reach raw fetch or PostgREST URLs.
Core Flow
Generation and chat share the same server handoff:
- Parse and validate CLI inputs (each model's action checks flags before login).
- Upload local media via
upload_images/upload_videos/upload_files(s3tmpfile presign + direct s3 PUT), which returns a public URL per file. Oversized images and videos are shrunk withffmpegfirst (see Media Handling). - Assemble the provider payload inline in the model's action.
- Call
create_and_submit(endpoint, payload, ...), which inserts atask2row (id,user_id,endpoint,payload) and kicks off provider work viabackend.submit2()(POST to/functions/v1/main2/submit2); the call returns immediately. - Long-poll for the result via
wait_for_task, which callsbackend.poll2()— the server holds the request open until a realtime broadcast fires, then returnsresultanderrinline. This is the single poll path for every endpoint; how the server completes an async task (a byteplus callback, or driving an upstream poller in the background) is its business, not the CLI's. - Hand the result to
save_result(name, result, output), which dispatches by model name to check the response fields and write the output.
The CLI never calls model providers directly. The task2 row is the durable server-side job record; backend.submit2 is the only server entry point for model execution.
The task2 row is the only record of a task, so vhscli submit can stop the moment the backend has it. vhscli resume <task_id> -o <path> re-attaches via the same wait_for_task helper, reads the row through backend.get_task — which also tells it which model ran, via from_endpoint(row.endpoint) — and saves the output through save_result.
Design Decisions
The runtime is stock Node and TypeScript. There is no Bun dependency and no local workspace package assumption.
The package is ESM-only and targets modern Node. That keeps the runtime model simple and matches current npm behavior.
commander owns command parsing, help text, and option validation.
There is no schema library. commander already validates flags at the edge, and each save helper checks the few response fields it actually reads (save_result fails with a precise message if one is missing), so a validation layer over both would only restate what those two already enforce. Payloads are plain objects; fields we don't set are omitted rather than sent as null, since 3rd-party providers treat a missing key and a null key differently. HTTP is plain fetch with AbortSignal.timeout (downloads keep a headers-only timer, so a long stream is never cut off mid-body).
The CLI is intentionally thin: upload bytes, create a task row, call /submit, wait, and save the result. Provider-specific execution remains server-side.
The CLI is driven by AI agents (Claude Code, Codex) and by VHS Studio. There are no automatic retries: a retry decision is the agent's to make, and it makes a better one when handed a precise error than the CLI could make blindly. So every failure exits 1 with a message that names the operation, the endpoint or file, the http status, and the response body — enough for an agent to distinguish "retry this" from "fix the input" from "give up".
What the CLI does own is correctness under restart:
- Every request fetches a fresh access token (
auth.fresh_session), so a token expiring mid-run never kills a long generation. - The
task2row is inserted beforesubmit2, so an aborted run is always resumable withvhscli resume <task_id> -o <path>— including a wait that died from a network blip; the server-side job keeps running. Pass--task-idif you need the id recorded before the submit round-trips. - Re-runs converge instead of erroring:
task2inserts are keyed by task id and treat a duplicate-key409as success. - Downloads stream to a unique temp file next to the destination and rename into place; partial downloads and failed conversions are cleaned up, never left as the output. That temp file is dot-prefixed (
.cat.png.4821-0.tmp.png), so a destination directory watched by a file indexer never sees a half-written download as content.
Errors are not hidden. Expected failures throw Fail, which prints a short lowercase message and exits with status 1. Unexpected errors keep their stack — that is the bug report. main.ts is the single exit point (it sets process.exitCode rather than calling process.exit, so piped stdout always flushes), and library code never exits the process.
Auth state stays in ~/.vhs/session.json so this package can share existing VHS sessions.
