Workflows

    Batch rendering video from a shell script

    A repeatable batch pattern: cost the run before dispatch, split dispatch from collection, respect the rate limit, and survive one bad model name.

    Versely Team10 min read

    The loop that dispatches forty video jobs is about ten lines. Everything that makes it survivable is the other forty: costing the run before you spend anything, splitting dispatch from collection so a stall does not block the queue, backing off against a rate limit you will hit at item twenty-one, and not letting one misspelled model name take the whole batch down with it.

    This is the pattern, in pieces, with the reasoning for each guardrail. It assumes you have already run versely auth login so a durable key exists on disk. The CLI's job in a batch is provisioning that credential, not running the jobs; dispatch itself is plain HTTP. If that setup is new, the CLI page covers the sign-in and where the key lands.

    Preamble: fail loudly and early

    #!/usr/bin/env bash
    set -euo pipefail
    
    export VERSELY_API_KEY="${VERSELY_API_KEY:-$(jq -r .apiKey ~/.versely/config.json)}"
    API="https://api.versely.studio"
    
    MODEL="VEO 3.1"
    DURATION="8"
    ASPECT="9:16"
    RESOLUTION="1080p"
    PROMPTS="prompts.txt"
    RUN_DIR="run-$(date +%Y%m%d-%H%M%S)"
    mkdir -p "$RUN_DIR"
    MANIFEST="$RUN_DIR/manifest.tsv"
    

    Two decisions in there matter more than they look. The environment variable takes precedence over the file the CLI wrote, which is exactly the override you want in CI, so read it first and fall back to disk. And every run gets its own directory with a manifest, because the actual output of a batch is not forty files. It is a table mapping each prompt to a request id, a status, and a URL. Without that table a partial failure is unrecoverable and you will rerun the whole thing.

    DURATION and RESOLUTION are set to values VEO 3.1 actually accepts. Both are per-model rather than global, so lifting this script onto a different model without checking its catalog page is the fastest way to make forty identical requests fail identically.

    prompts.txt is one prompt per line. Keep it in version control; it is the input that changes and the thing you will want to diff.

    Cost the run before you dispatch

    The pre-flight is two numbers: what you have, and what the run will take.

    CREDITS=$(curl -s "$API/api/v1/user/me" \
      -H "Authorization: Bearer $VERSELY_API_KEY" | jq -r '.credits')
    
    COUNT=$(grep -cve '^\s*$' "$PROMPTS")
    echo "prompts: $COUNT   balance: $CREDITS credits"
    

    There is no free allowance on any surface, so an empty balance is not a queued job, it is a 402 on every item. The failure mode that actually hurts is not running out at item one, which you notice immediately, but running out at item thirty-one, which leaves you with thirty finished clips, ten failures, and a manifest you have to reconcile by hand.

    For the per-item figure, each model publishes its credit cost and video is billed on what it actually renders, so length and resolution both move the number. VEO 3.1 publishes its figures on its catalog page, as does every other model in the catalog. If you would rather have the arithmetic done for you, the agent surface exposes an estimate call that prices a planned item list against your current balance using the same logic as the real charge. Estimating credit cost before you dispatch a batch covers that path.

    Gate the run on it, with a margin, and exit rather than warn:

    # Set PER_ITEM from the model's published credit cost at your chosen
    # length and resolution. No default — an invented number is worse than none.
    PER_ITEM="${PER_ITEM:?set to the model's published credit cost}"
    NEEDED=$(( COUNT * PER_ITEM ))
    if [ "$CREDITS" -lt "$NEEDED" ]; then
      echo "need ~$NEEDED credits, have $CREDITS — aborting" >&2
      exit 1
    fi
    

    The dispatch pass

    Submit everything, record the ids, do not wait for results. Generation is async, and interleaving a poll after each submit turns a two-minute dispatch into a forty-minute one for no benefit.

    while IFS= read -r PROMPT; do
      [ -z "$PROMPT" ] && continue
    
      RESP=$(curl -s -X POST "$API/api/v1/generate/video" \
        -H "Authorization: Bearer $VERSELY_API_KEY" \
        -H "Content-Type: application/json" \
        -d "$(jq -n \
              --arg m "$MODEL" --arg p "$PROMPT" --arg d "$DURATION" \
              --arg a "$ASPECT" --arg r "$RESOLUTION" \
              '{model:$m, prompt:$p, duration:$d, aspect_ratio:$a, resolution:$r}')")
    
      FAILED=$(echo "$RESP" | jq -r '.data.failCount // 1')
      if [ "$FAILED" != "0" ]; then
        printf '%s\t%s\t%s\t%s\n' "$PROMPT" "-" "dispatch_failed" \
          "$(echo "$RESP" | jq -c '.data.failed // .error')" >> "$MANIFEST"
        continue
      fi
    
      RID=$(echo "$RESP" | jq -r '.data.successful[0].data.requestId')
      printf '%s\t%s\t%s\t%s\n' "$PROMPT" "$RID" "dispatched" "-" >> "$MANIFEST"
    
      sleep 4
    done < "$PROMPTS"
    

    Three things in there are deliberate.

    Build the JSON with jq -n, never with string interpolation. A prompt containing an apostrophe or a double quote will otherwise produce malformed JSON, and you will spend an hour debugging a 400 that is entirely your own doing. Prompts contain apostrophes constantly.

    Check failCount, not the HTTP status. The generate endpoints return a batch envelope with successful, failed, successCount and failCount, and that envelope exists because model also accepts an array. The consequence for a single-item request is that a 200 does not by itself mean your job went out. Read the counter.

    sleep 4 is not superstition. Generation endpoints sit under a tighter per-minute ceiling than the rest of the API, and limits attach to the key rather than the endpoint, so a batch loop is the one thing guaranteed to find them. Four seconds between dispatches is about fifteen a minute, which stays conservative without any header parsing. If you want to be precise instead of conservative, every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, and those are the current numbers rather than the ones you remembered.

    The collect pass

    Read the manifest, poll each dispatched id, download what finished.

    while IFS=$'\t' read -r PROMPT RID STATE _; do
      [ "$STATE" != "dispatched" ] && continue
    
      for _ in $(seq 1 90); do
        S=$(curl -s "$API/api/v1/status/$RID" -H "Authorization: Bearer $VERSELY_API_KEY")
        case "$(echo "$S" | jq -r '.status')" in
          completed)
            URL=$(echo "$S" | jq -r '.result_url')
            curl -s -o "$RUN_DIR/$RID.mp4" "$URL"
            echo "ok   $RID" ; break ;;
          failed)
            echo "fail $RID" >&2 ; break ;;
        esac
        sleep 5
      done
    done < "$MANIFEST"
    

    GET /api/v1/status/:requestId is the only status view you should use. It is scoped to one request, so it cannot race you the way looking anywhere else can. Five seconds is a sensible interval per request id, and 90 attempts gives a ceiling generous enough for a long video job without letting a stuck one spin forever.

    The response also carries result_urls for multi-output jobs and type and model for sanity-checking that you polled the id you thought you did.

    Failing gracefully on a bad model name

    The specific disaster worth engineering against: you change MODEL to something that is not in the catalog, or spell it slightly wrong, and forty items dispatch into nothing. Model names are the display names from the catalog spelled exactly as they appear, capitalisation included, which is a real source of typos — and a name that is nearly right fails the same way a name that is nonsense does.

    Three defences, cheapest first.

    Canary the first item. Dispatch one prompt, poll it to completion, and only then run the rest. Costs one clip's credits and catches every class of configuration error, not just a bad name.

    Never abort the loop on a per-item failure. The continue in the dispatch pass writes dispatch_failed to the manifest and moves on. A batch where 38 of 40 succeeded is a good outcome; a batch that stopped at item three because item three was malformed is not.

    Make the rerun trivial. Because the manifest records state per prompt, a retry pass is a grep away:

    awk -F'\t' '$3=="dispatch_failed" {print $1}' "$MANIFEST" > retry.txt
    

    One failure mode deserves the opposite treatment. A 402 means the balance is gone, and every remaining item will also fail. That one should abort the run immediately rather than continue, because there is nothing to recover and the manifest will fill with forty identical errors. Treat 401 the same way: an invalid key does not become valid at item twelve.

    Response Loop behaviour
    failCount above zero on one item Record, continue
    429 Sleep to X-RateLimit-Reset, retry the item
    500 Retry once after ten seconds, then record and continue
    402 Abort the run
    401 or 403 Abort the run

    After the run

    Two habits that pay off immediately. Keep the manifest with the output rather than deleting it, because it is the only record of which prompt produced which file, and reconciling forty clips to forty prompts from filenames alone is miserable. And remember that Versely's default video frame rate is 25 fps, so if these clips are heading for a 24 fps timeline that conversion belongs at the edit stage rather than at export.

    Batch output also has a characteristic problem that is not technical: at volume it starts to look like batch output. Varying the prompt list along a real axis rather than reusing one template is the fix, and batch output that does not read as batch output covers the specifics. The general shape of running many generations as one coordinated job is in the batch generation glossary entry.

    FAQ

    Should I dispatch in parallel to go faster?

    Rarely worth it. Generation endpoints are rate limited per key, so parallel dispatch runs into the same ceiling a serial loop does — it just gets there faster and with less ordered, less debuggable output. The genuine speedup is separating dispatch from collection, which the pattern above already does: forty jobs render concurrently server-side while your script is doing nothing but polling.

    Can one request cover several models?

    Yes. model accepts an array, and passing several names fans the same prompt across all of them and returns a request id per model inside data.successful. That is the efficient way to run a model comparison across one prompt list, and it is also why the response envelope has the shape it does. Iterate data.successful[] rather than indexing [0] if you use it.

    How do I keep credits from disappearing on a bad run?

    Canary one item, gate the run on a balance check with margin, and abort on 402 rather than continuing. Beyond that, the variable that actually moves batch spend is attempts per usable clip rather than the batch size itself, so measuring your reroll rate on a small run before scaling it is the highest-value thing you can do. How credits work covers the unit.

    Is a shell script the right tool once this grows?

    Up to a few hundred items with a manifest and a retry pass, yes. Past that, the things you start wanting are concurrency control, persistent state and structured retries, which are all easier in a real language against the same endpoints. Nothing about the API changes; only the harness does. Batch generation for content teams covers what that looks like at daily volume, and the developer page has the full reference.