Install
openclaw skills install @ofoxai/ofox-video-coreRequires OFOX_API_KEY — create one at https://app.ofox.ai. Shared execution layer for the Ofox video generation API (api.ofox.ai) — creates a video job, polls it to completion, downloads the finished mp4 from a persistent CDN URL, and reports the real cost. This is a library skill, not a standalone user-facing one — it is invoked by scenario skills such as seedance-short-drama, seedance-ad-creative, and seedance-product-video, which build model/prompt/resolution choices for a specific use case and then call into this skill's script rather than re-implementing the API calls. Load this skill directly only when a user explicitly names the Ofox video API, asks to call it with specific low-level parameters, or asks to debug/resume a stuck or failed Ofox video job by job id — for a plain scenario request ("make me a short drama scene", "generate a cinematic ad clip"), use the relevant scenario skill instead, which itself depends on this one.
openclaw skills install @ofoxai/ofox-video-coreWraps the Ofox video generation API (https://api.ofox.ai/v1/videos) behind
one script: submit a job, poll it to a terminal state, download the result,
report the exact cost. Scenario skills (seedance-short-drama,
seedance-ad-creative) call into this rather than duplicating API logic.
references/ofox-video.sh resolves
OFOX_API_KEY from the shell environment only, and the key is never
hardcoded in a script or a skill file..env at the repo root is the usual spot), then
set -a; . <path>; set +a in the shell you'll call the script from. Sourcing
a dotenv pulls in every variable in the file, not just the key —
OFOX_API_BASE_URL is one this script reads, and it silently redirects every
API call — so read the file before you load it. Never echo the value.references/ofox-video.sh never uses curl -v/--trace for exactly this
reason (those would print the Authorization header). If you need to
show that a key is configured, say "OFOX_API_KEY is set" — never the value.bash references/ofox-video.sh models lists every video model Ofox serves with
its real duration range, resolutions, modes and base per-second price. It needs
no API key — GET /v1/models is public — so it is safe to run before the
user has signed up, and it costs nothing.
Worth knowing before quoting a price: at 720p text-to-video the ladder runs
seedance-2.0-mini 4 cents/s → wan-2.7 10 cents/s → seedance-2.5 24 cents/s. When
a user is going to generate several takes and keep one, drafting on a cheap
model and rendering the keeper on seedance-2.5 costs a fraction of drafting
everything on 2.5. Say so when it's relevant — but don't switch models on
someone's behalf, since the model changes the look, not just the price.
Parameter limits differ per model and the script enforces the real ones
(wan-* is 2-15s and 720p/1080p only; seedance-2.5 is 4-30s and the only one
with 21:9/4:3/3:4). The limits come from the live model list, cached for
24 hours, falling back to a bundled snapshot when offline — a fallback is
always announced on stderr, never silent.
chain)One job is one 4-30 second clip, and that clip can hold several hard-cut
shots marked with timestamps — verified on this path, with the job ids and
the untested range in references/prompt-structure.md, "Several shots in one
job". So a multi-shot sequence does not automatically mean several jobs.
chain is for continuity across jobs: when a sequence runs past one
job's duration ceiling, or when each shot needs its own approval, seed or
resolution. Separate jobs share nothing, so left alone their set, lighting
and framing drift apart; chain carries each shot's closing frame into the
next one as its opening frame. The two mechanisms combine rather than compete
— every job in a chain can itself contain several timestamped shots:
bash references/ofox-video.sh chain \
--shot "a white cup on a dark table, steam rising, static camera" \
--shot "the camera pushes in slowly toward the same cup" \
--duration 4 --resolution 480p
Or --shots-file shots.txt, one prompt per line (blank lines and #
comments ignored). Capped at 10 shots per run.
Verified behavior: shot 2 opens on very nearly the exact frame it was fed — cup position and scale, window frame, table grain, light direction all carried over — and then follows its own prompt from there. This is real continuity, not just matched framing. Brightness can shift slightly across a seam.
What it does:
usage.video_cost.--no-concat to skip),
re-encoding only if the clips' codecs differ. Fails open: no join, never a
lost shot.--aspect-ratio. Shots 2+ are image-to-video, which
Seedance 2.5 requires to be adaptive, so they inherit framing from the fed
frame — which is what keeps the sequence dimensionally consistent.chain needs ffmpeg, and checks for it before submitting anything, so
a missing dependency never costs a paid shot.
Seedance 2.5 image-to-video rejects reference frames containing a real
person — HTTP 400 / input_moderation_failed, "may contain real person".
Nothing is generated and nothing is billed, but the chain stops there.
So chaining works for products, landscapes, illustration and anime, and
does not work for live-action human sequences on this model. That is why
seedance-anime-drama can open each of its shots on a generated frame of
the character while a short-drama sequence cannot. --real-person true exists
for authorized real-person references and Ofox documents it for
bytedance/seedance-2.0; whether it lifts the restriction on 2.5 is untested
here — don't promise it.
bash references/ofox-video.sh last-frame clip.mp4 [--out-dir DIR]
No API call, no key, no cost. Grabs a frame just before the end (the literal
final frame is often a fade), for feeding into a later generate by hand.
batch)Video generation is a slot machine: you generate several, keep one. batch
makes that one command, and — the part nobody else does — tells you what it
actually cost.
bash references/ofox-video.sh batch --prompt "..." --takes 3 [OPTIONS]
Every option generate takes works here. What batch adds:
usage.video_cost — never from the estimate.TAKE 3 <job-id> seed=1852049 <cost> <path>).
This is what makes "take 3 was the good one" actionable: the takes differ
only by seed, so re-running the same prompt with that seed on a better
model or a higher resolution reproduces that take rather than rolling a new
one. Without the seed there is no way back to a specific take, only a
reroll. same prompt is load-bearing — see "Reproducing a shot" for how
little of it can change.BATCH_COST_TOTAL is the number to quote, not BATCH_COST_PER_TAKE.
Gacha means most takes go in the bin: if one take in three is usable, that
clip cost you the whole total, because you paid for the two you threw away.
BATCH_COST_PER_TAKE is just the total divided by the count — useful for
sanity-checking the bill, misleading as a cost-per-usable-clip figure. The
total is what compares meaningfully across models and settings.--no-contact-sheet to skip): three frames from each
take, tiled one row per take, so a human can pick a winner from one image
instead of opening N files. Needs ffmpeg; without it the sheet is skipped
with a reason and the videos are untouched.--seed, since a fixed seed means you may be
paying N times for N identical clips.Takes are created one at a time and waited for concurrently, each as its
own job through the same path a single generate uses. The sequential half is
what keeps the money guard exact; the concurrent half is where the ten minutes
per clip used to be multiplied by the take count. --concurrency caps the
waiting (default 4); the reasoning behind that number is under "Waiting in
parallel".
--takes is capped at 10 per run.
Draft cheap, render the keeper expensive:
# 5 drafts at 480p on the cheapest model — about 40 cents
bash references/ofox-video.sh batch --prompt "..." --takes 5 \
--model bytedance/seedance-2.0-mini --resolution 480p --duration 4
# then the winner, on the good model
bash references/ofox-video.sh generate --prompt "<the one that worked>" \
--model bytedance/seedance-2.5 --resolution 1080p --duration 4
The same five drafts on seedance-2.5 at 720p would be $4.80. Offer the
ladder — but let the user choose the model, since a different model is a
different look, not just a different price. All five drafts are waited for at
once, so the ladder costs one clip's wall clock for its first rung rather than
five.
bash references/ofox-video.sh contact-sheet clip1.mp4 clip2.mp4 [--out-dir DIR]
No API call, no key, no cost. Useful for comparing takes from separate runs, or rebuilding a sheet you skipped.
generate waits for the job: it polls until the video is ready, up to
--max-wait seconds (default 540, i.e. nine minutes). A 4-second 480p clip
usually lands in one to three minutes; longer and higher-resolution jobs take
longer.
That is longer than most agent tool calls allow by default. Claude Code's
Bash tool defaults to a 120-second timeout and caps at 600. If your tool call
dies while generate is still polling, you land in the one genuinely bad
state: the job was created and is billable, and its id was never printed, so
you cannot poll for it and cannot tell the user where their video went.
Two ways to stay out of that, in order of preference:
1. Submit and wait separately. create does the submit and returns
immediately — seconds, not minutes — printing the job id. Then poll in
however many short calls it takes:
bash references/ofox-video.sh create --prompt "..." --duration 15 --out-dir ./out
# -> STATUS submitted
# JOB_ID 7b41f0c9-...
# POLLING_URL https://api.ofox.ai/v1/videos/7b41f0c9-...
bash references/ofox-video.sh poll 7b41f0c9-... --out-dir ./out
The job id exists on disk in your transcript the moment it is created, so no timeout can strand it. This is the right shape whenever you cannot raise your own tool timeout.
2. Raise the timeout for the call. If your harness lets you set a
per-call timeout, give generate at least --max-wait plus a margin.
batch still needs this attention most, even though its waiting is now
concurrent. Four takes finish in about the time of one, but that one is still
minutes — a 15-second 720p job measured over 600 seconds of wall clock on
2026-09-05, which is more than any single Bash tool call can be given. Either
lower --max-wait (a 4-second draft rarely needs 540s; 240 is generous), or
create each take and poll them yourself. Tell the user roughly how long it
will take before starting.
The wall clock is the binding constraint on a multi-clip session, and it is
not the API's fault. The account this was measured on allows 100 requests
per minute, while a polling job issues one request per --poll-interval —
ten a minute at the default. Nothing about the rate limit required waiting for
clips one at a time; the old serial batch simply did.
N independent clips are N create calls (seconds each) and then one poll
over every job id:
# submit all three; each returns in seconds with its own job id
bash references/ofox-video.sh create --prompt "<shot A>" --duration 15 \
--resolution 720p --name "kitchen argument" --out-dir ./out
bash references/ofox-video.sh create --prompt "<shot B>" --duration 15 \
--resolution 720p --name "she walks out" --out-dir ./out
bash references/ofox-video.sh create --prompt "<shot C>" --duration 15 \
--resolution 720p --name "the station" --out-dir ./out
# then wait for all three at once, not one after another
bash references/ofox-video.sh poll 7b41f0c9-... 2c8e5511-... 9ad0b7e3-... \
--out-dir ./out
Each job's own output is replayed under a === JOB i/N <id> === delimiter in
the order the ids were given, whatever order they finished in, so
VIDEO_PATH and VIDEO_COST read exactly as they do for a single poll.
POLL_COST_TOTAL sums the real bills. Exit is 0 when all completed, 3 if any
job failed (a failure needs a new prompt), 4 if any is merely still running (a
timeout needs another poll).
This primitive is not new — create and poll have been separate subcommands
for as long as they have existed, and until 2026-09-05 no agent used them this
way. Three 15-second clips serially is half an hour; the same three this way
is ten minutes.
Image-to-video has a hard dependency in the middle of it: the first frame has
to exist before the video job can be submitted. seedance-anime-drama's
two phases, and any --frame-first-image flow, are image → then video, and
that ordering is the whole mechanism. chain is the same shape — each shot
opens on the previous shot's closing frame, so shot 2 cannot be submitted
until shot 1 has been downloaded and its last frame extracted.
So that stretch of wall clock is irreducible. What is parallelisable is everything across those pipelines: four clips that each need an image can have all four images generated, then all four videos created, then all four polled together.
Video generation is a slot machine, and the reason to pull the lever more than
once is not variety — it is that a take can quietly fail to render what the
prompt asked for. Measured on 2026-09-05: job
60fbea52-b14b-4796-80bf-03afe0aa4fa0 came back technically clean with its
written climax missing — a drop that was supposed to fall never detached from
the surface. Nothing was wrong with the prompt, the parameters or the bill.
Serially, the answer to that was a re-run and another ten minutes. Concurrently it costs the same wall clock as the first attempt:
bash references/ofox-video.sh batch --prompt "..." --takes 3 \
--duration 15 --resolution 720p --out-dir ./out
batch now creates one take at a time and waits for all of them at once.
The two halves are deliberately different, and the difference is the money
guard: a create answers in seconds, so serialising it is nearly free and it is
the only ordering in which "take 2 was rejected, so takes 3..N were never
sent" can be true. Firing N creates at once would spend N times to learn the
first one was going to fail.
What follows from the split:
TAKE 2 <id> seed=<n> FAILED exit=3, keeping its take number, id and seed, and the rest
complete normally. A job that ends failed carries no usage and is not
billed.STATUS batch_partial replaces batch_completed whenever the run is
not what was asked for, alongside explicit TAKES_SUBMITTED,
TAKES_COMPLETED, TAKES_FAILED, TAKES_RUNNING and
TAKES_NOT_SUBMITTED counts. Don't read a partial run as a complete one.--concurrency: the number, and where it comes from--concurrency N (also OFOX_POLL_CONCURRENCY) caps how many jobs are
waited for at once, on both batch and a multi-id poll. Default 4,
ceiling 10.
Both numbers are requests per minute, not a guess at what feels safe. One
polling job issues at most one GET per --poll-interval, so at the default 6s
it spends 10 of the account's 100 RPM. Ten concurrent polls is therefore
exactly the whole limit, which is why 10 is the ceiling and never the default.
Four is 40%, and the other 60% is not spare: a batch's creates go out first, a
429 or a 5xx retries at the same cadence, and an agent commonly has another
Ofox call in flight in the same session.
Lowering --poll-interval multiplies the rate — four polls a second apart is
240 RPM — so the script computes the implied rate and says so when the
combination crowds the limit. It warns rather than blocks: rate limiting costs
a poll cycle per job, which is time, not money, and each job backs off further
on each consecutive 429 until a request gets through.
Concurrency does not make a batch cheaper. It makes N bills arrive at once,
which is exactly why the cost table has to be on screen before it starts, not
after. batch --dry-run prints one Estimated cost: line for the whole batch
plus a CONCURRENCY line; that total is what
references/approval-gate.md requires you to
quote, itemised, with the takes as rows — never BATCH_COST_PER_TAKE.
--takes is still capped at 10 for the same reason it always was: a cap on how
much one command can spend.
Never submit a paid job until the user has seen a cost table and said yes.
The rule and the table format are shared by every Ofox skill in this repo and
written down once, in
references/approval-gate.md — required
columns, where the numbers must come from, how to itemise a batch, what to do
when no estimate is possible, and how a two-phase (image then video) flow
splits into two approvals. This section is the mechanics that feed it.
generate, batch and chain all take --dry-run: they parse arguments,
validate every parameter against the chosen model, resolve the upstream, build
the payload and print the cost estimate — then stop. No request is sent and
nothing is billed.
That flow is the one to follow whenever real money is involved:
# 1. price it
bash references/ofox-video.sh generate --dry-run --prompt "..." --duration 15 --resolution 720p
# -> Estimated cost: ~$3.60 (15s x 24 cents/s)...
# -> DRY RUN — nothing was submitted and nothing was billed.
# 2. tell the user the number, get a yes
# 3. run the identical command without --dry-run
Do not skip step 2. The estimate a real run prints appears microseconds
before the request goes out — by the time you could relay it, the job exists
and is billable. --dry-run is what makes quoting-then-confirming possible.
A dry run also catches a bad parameter for free, so an invalid combination costs a message instead of a job.
Every run prints exactly one Estimated cost: line, including when it cannot
compute one — it says why (no --duration, or no verified rate for that
model/resolution). Relay whichever line you get; never substitute a number of
your own, and never present an estimate as the bill.
Seedance is served by two upstreams, and by default Ofox picks one by weight —
its docs say outright that "which provider serves any single request is not
predictable". They moderate differently, so an unpinned job that comes back
output_moderation_failed may just have landed on the stricter one, with
nothing for the user to point at.
This skill pins Seedance to byteplus. No flag needed, no network call to
decide it.
volcengine | byteplus (default) | |
|---|---|---|
| Platform | Volcengine Ark, mainland China | BytePlus, markets outside mainland China |
| Moderation | Standard | More permissive |
| Price | identical | identical |
Pricing is identical across the two — this is a region and moderation choice, never a cost one. Say so if a user asks which is cheaper.
bash references/ofox-video.sh generate --provider volcengine ... # mainland
bash references/ofox-video.sh generate --provider auto ... # let Ofox route
export OFOX_VIDEO_PROVIDER=volcengine # persistent default
bash references/ofox-video.sh providers # see a model's upstreams
Models with only one upstream (alibaba/* today) get no pin — routing there
is already deterministic.
If a job fails output_moderation_failed, retrying on the other upstream is a
real fix and is worth offering: the rejected job was never billed, and a retry
is a new request, not a resubmission.
models, providers and any --dry-run all work with OFOX_API_KEY unset.
They hit public endpoints or make no request at all, so someone who has not
signed up can price a job, compare resolutions, and cost out a batch before
deciding whether to register.
When a user has no key, quote first and point at signup second. Opening with "go get an API key" sends someone to a form before they know whether the thing is worth 44 cents or $7.20. Run the dry run, show them the number, then point at app.ofox.ai if they want to proceed.
Before the first call in a session, verify the environment:
bash references/ofox-video.sh check
This checks curl, jq, and OFOX_API_KEY and exits 0 only if all three
are present — it makes no network call. Handle each failure mode plainly:
curl missing (rare — usually preinstalled): brew install curl
(macOS) or sudo apt-get install curl (Debian/Ubuntu), else
https://curl.se/download.html.jq missing: brew install jq (macOS) or sudo apt-get install jq
(Debian/Ubuntu), else https://jqlang.org/download/.OFOX_API_KEY missing: two paths, and the second one is the one
agents forget. If the user has no key, they get one at
https://app.ofox.ai (log in → Settings → API Keys → Create New Key,
shown once) and export OFOX_API_KEY=... in their shell. If they say the
key already lives in a file, don't send them back to the terminal to
re-type it — load it yourself with set -a; . <path>; set +a in the shell
you'll call the script from (see the safety contract: authorization first,
read the file before sourcing it, never echo the value). Offer this once,
plainly, and move on — don't repeat the pitch on every message.bash references/ofox-video.sh models
bash references/ofox-video.sh generate --dry-run --prompt "..." [OPTIONS]
bash references/ofox-video.sh generate --prompt "..." [OPTIONS]
bash references/ofox-video.sh poll JOB_ID [--out-dir DIR] [--name TEXT]
bash references/ofox-video.sh poll JOB_ID JOB_ID JOB_ID [--concurrency N]
generate builds the request, validates parameters client-side against the
chosen model's real limits (rejecting a bad resolution/aspect_ratio/
duration before any network call), submits it, polls to a terminal state,
downloads the video into --out-dir (default: the current directory), and
prints:
STATUS completed
JOB_ID <id>
VIDEO_PATH <path/to/file.mp4>
SIDECAR_PATH <path/to/file.json>
VIDEO_SECONDS <billed seconds>
VIDEO_COST <exact cost from usage.video_cost>
Pass --name with a short description of what the clip actually is:
bash references/ofox-video.sh generate --prompt "..." --name "convenience store breakup"
The file lands as convenience-store-breakup-d12c2787.mp4 instead of a bare
job id, with a convenience-store-breakup-d12c2787.json sidecar beside it.
Without --name the slug is derived from the prompt, so a poll JOB_ID run
in a fresh shell still produces something readable. That fallback is a
consolation prize, not the goal: prompts open on setting and lighting, so the
derived name usually describes the room rather than the scene. A scenario
skill always knows the better name — pass it.
The 8-hex suffix keeps two runs of the same prompt from overwriting each
other. It is not a way back to the job: the API has no list endpoint, so a
short id cannot be expanded. The sidecar carries the full job_id, the
prompt, the real cost, and the request as submitted — the only place
resolution, aspect_ratio and seed are recorded, since the poll response
echoes none of them. Full field table in
references/api-params.md.
Every job now has a seed: without --seed the script rolls one, sends it,
and prints it as SEED <n>. Before, the server picked a seed and reported it
nowhere, so nothing could be regenerated — only re-rolled.
That seed and the rest of the request land in the sidecar, so re-rendering the same shot at a higher resolution is a matter of reading it back:
jq -r '.request | "--prompt \(.prompt|@sh) --seed \(.seed) --aspect-ratio \(.aspect_ratio)"' out/my-shot-3e830902.json
create + poll keeps this intact across the two processes by leaving the
payload in <out-dir>/.ofox-request-<job id>.json for the poll to pick up
and clean away. Poll into a different --out-dir and the handoff is simply
not found — the sidecar omits request, nothing fails.
The seed reproduces a take only when the prompt is byte-identical, and
"byte-identical" is not a figure of speech. Measured on 2026-09-05: two jobs
ran the same seed 642303335 at the same duration, resolution, aspect ratio
and model, differing only in the wording of one paragraph in the middle of
the prompt — and they came back with visibly different subjects, not merely
different takes on one subject (a wide steel collar on a short body versus a
narrow collar on a longer body, with the one asymmetric feature pointing the
other way). Jobs 1cf5ac46-058f-4615-a47b-067743f76f8c and
50f623b2-c54a-4d9d-9646-31dd06e2a926.
So the claim above stands and this is its boundary: the seed is the handle for
"that one was good, render it properly at 1080p", where nothing but
--resolution (or --model) moves. It is not a handle for "that one was
good, now fix the third shot" — editing the prompt and keeping the seed does
not preserve the parts you liked. Read the prompt back out of the sidecar
rather than retyping it, which is what the jq line above is for; a retyped
prompt is a new prompt.
A sidecar that cannot be written is a warning, never a failed download — the video is what the user paid for.
Download source: mirror_urls (CDN-signed, persistent) when present,
falling back to unsigned_urls (documented as temporary, may expire within
24h) when mirror_urls is absent or empty — this is not just a theoretical
fallback, real completed jobs have been observed with no mirror_urls field
at all. Once downloaded, the result is a local file either way, so a video
saved from unsigned_urls should be treated identically to one saved from
mirror_urls from this point on — the expiry window no longer matters once
the file is on disk.
Key flags: --model (default bytedance/seedance-2.5), --duration,
--resolution, --aspect-ratio (includes adaptive — see below), --size,
--generate-audio true|false, --seed, --frame-first-image URL|PATH,
--frame-last-image URL|PATH, --real-person true|false, --callback-url,
--extra-json '<json>' (advanced fields not covered by a flag, e.g.
input_references, provider), --max-wait SECONDS (default 540),
--poll-interval SECONDS (default 6). Full parameter reference:
references/api-params.md. Pricing and the cost-estimate formula:
references/pricing.md.
adaptive aspect ratio--frame-first-image/--frame-last-image accept either a remote
http:///https:// URL (used as-is) or a local, readable file path — the
script auto-detects a local file and base64-encodes it into a
data:image/<ext>;base64,... URI before building the request (content-type
inferred from the file extension, image/jpeg as the safe default when the
extension isn't recognized). Prefer a local file when one is available:
real testing found at least one otherwise-valid, publicly reachable image
URL rejected by the upstream provider with a download-failure-shaped error,
while the same image worked reliably once base64-encoded — likely
bot/hotlink protection on some hosts, not something under our control.
Local files of any realistic size are supported: the base64 data URI is
built into the request body via a temp file and jq --rawfile/--slurpfile
and posted to the API via curl --data-binary @file, never via a jq --arg/--argjson or curl -d command-line value. An earlier version
of this script did the latter and broke on any real photo whose base64
encoding exceeded the OS's ARG_MAX (roughly any real photo over ~750KB) —
verified with a real 885KB PNG (1,179,996-byte base64 encoding) failing
with jq: Argument list too long before any network call was made. Fixed
2026-08-29; see .trellis/spec/skills/external-api-integration.md for the
general lesson.
bytedance/seedance-2.5 (the default model) requires aspect_ratio: "adaptive" for any image-to-video request — every other value fails,
verified across multiple real attempts. When --frame-first-image or
--frame-last-image is set and the effective model is
bytedance/seedance-2.5 (including the default, if --model wasn't
passed), the script forces aspect_ratio to adaptive regardless of
what --aspect-ratio was passed or left unset, and always prints a
one-line NOTE: to stderr explaining the override — it never does this
silently. This does not apply to other models (e.g.
bytedance/seedance-2.0 works with image-to-video without this
requirement) — don't assume the requirement generalizes beyond
bytedance/seedance-2.5 without separately verifying it.
Report results honestly, the same way cloudflare-drop reports its mode:
state which model/resolution/duration/aspect ratio were actually used
(not just requested) and the actual VIDEO_COST — never invent a cost
number or claim a video is ready without a real VIDEO_PATH from the
script.
Always state the full VIDEO_PATH as its own standalone line in your
reply to the user — not folded into a sentence or buried mid-paragraph.
The script always resolves --out-dir to an absolute path before printing
VIDEO_PATH, so relay that absolute path exactly as printed; the file's
location is the actual deliverable here, and the user should be able to
find it without re-deriving your working directory.
Never re-run generate for the same logical request just because it's
taking a while or a tool call timed out. A create call that gets any HTTP
response has already either made the job (billable) or been rejected
(not billable) — running generate again for "the same video" risks a
second, separately billed job.
If generate's own poll loop hits its time budget (job still
pending/queued/in_progress), it exits with the job id and prints
exactly this instruction — follow it:
bash references/ofox-video.sh poll JOB_ID
If a Claude Code tool call itself times out while generate is still
running (the script hasn't printed a result yet), the job may still be
mid-flight upstream — you won't have the job id from stdout in that case.
Don't guess or re-submit; tell the user the request may still be
processing and that checking https://app.ofox.ai for recent jobs is the
safe way to find its id and resume with poll.
batch prints its own resume command the moment its takes are submitted,
before the waiting starts — a single poll over every take's job id, with the
out-dir already filled in. That line exists so a tool-call timeout during the
concurrent wait never costs you the job ids of takes you have already paid
for. Use it; never re-run batch.
If the create call itself gets an ambiguous network failure (curl exits
nonzero with no HTTP response at all — exit code 5), the script
explicitly refuses to guess whether a job was created. Don't auto-retry;
tell the user to check https://app.ofox.ai first.
Until 2026-09-05 every clause above was a rule with no live test behind it — reasoned from the API's shape, never exercised by an actual broken connection. Two jobs that day dropped their TLS connection mid-poll, and a third did the same on 2026-09-06:
curl: (35) LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to api.ofox.ai:443
WARN: poll request failed (curl exit 35). Retrying the POLL (not create) in 6s...
All three recovered on the retry and completed normally, videos
downloaded, costs reported. Jobs 1cf5ac46-058f-4615-a47b-067743f76f8c,
50f623b2-c54a-4d9d-9646-31dd06e2a926 and
8efeb556-bf38-45ec-940b-a792ef74bfcf, 2.88 USD each.
Two things that confirms. The poll retry is the right response to a transport fault — the job was running the whole time and the connection, not the job, was what broke. And the parenthetical in that warning line ("not create") is doing real work: a resubmit at any of those moments would have created a second billable job and doubled a 2.88 USD spend, on a fault that cleared by itself in six seconds. A dropped connection while polling is never evidence about the job's state — three for three now, on separate days.
The script maps every documented error.code to a fixed, actionable
message and a distinct exit code. error.message (the upstream's own free
text) is not a stable contract to branch logic on, but it can carry a
specific, useful detail the generic mapped explanation doesn't (e.g. a
minimum reference-image width) — so the script always prints it too,
labeled Upstream message: ..., alongside the mapped explanation, not only
when error.code itself is absent or unrecognized.
| Exit | Meaning |
|---|---|
0 | Success — video downloaded, cost printed. |
1 | Usage/parameter validation error — no network call was made. Fix the flag and retry generate freely. |
2 | Environment error — curl/jq/OFOX_API_KEY missing. Fix the environment, no job was attempted. |
3 | The API rejected the request, or the job ended failed/cancelled/expired. The mapped message explains why (see references/api-params.md for the full error-code table). Includes output_moderation_failed — a post-generation failure (the job ran, its output failed a content check afterward), not billed (no usage field on the response), safe to fix by submitting a new generate call with a different prompt/reference — that's a new request, not a resubmission of the failed one. |
4 | Timed out waiting for a terminal state. The job is still running — poll JOB_ID, do not generate again. |
5 | Ambiguous network failure on create — no HTTP response received. Do not auto-retry; check the dashboard first. |
6 | --out-dir could not be created or entered (bad path, permissions) — a local filesystem problem, not an API problem. If this happened during generate, the job itself is unaffected (already created or still running server-side); do not re-run generate. Fix --out-dir and re-run poll JOB_ID --out-dir <a writable directory>. |
(That 6 row used to sit below a paragraph about batch, which broke the
table; the paragraph is now where it belongs, underneath.)
batch and a multi-id poll collapse several jobs' outcomes into one exit
code, and pick the most severe actionable one. 3 if anything failed —
a rejected submission, or a take that ended failed after being submitted —
because a failure needs a different prompt. 4 if nothing failed but something
is still running, because that needs another poll and nothing else. 0 only
when every requested take is on disk. Either way the takes that did complete
are downloaded and listed with their real cost, and the counts
(TAKES_COMPLETED, TAKES_FAILED, TAKES_RUNNING, TAKES_NOT_SUBMITTED)
say which case you are in — a partial run is reported honestly rather than
discarded.
seedance-short-drama, seedance-ad-creative, and seedance-product-video
invoke this skill's script (typically
../ofox-video-core/references/ofox-video.sh relative to their own
directory) rather than duplicating any of the request-building, polling,
error-mapping, or download logic above. They own the scenario-specific
prompt template and recommended parameter defaults; this skill owns the
mechanics of talking to the API correctly and safely, and the numbers that
go in front of the user.
Don't restate the approval rules in a scenario skill. Link
references/approval-gate.md and add only what
is scenario-specific (which command to dry-run, which parameters matter). The
four scenario skills each used to carry their own prose version of "quote it,
get a yes" and they had already begun to diverge — the shared file exists so
that stops happening.
Load references/prompt-structure.md
before writing a prompt. It is the prompt-structure reference shared by
every Seedance scenario skill: the vendor's own formula, the header-manifest
-> timeline -> closing-block skeleton, when and how to timestamp segments,
how many shots a clip of a given length actually carries in the gallery,
transition and camera vocabularies, why a camera move needs its waypoint
frames and not just a verb, pacing, consistency locks
and negative lists, dialogue density by tier, the two meanings of an attached
image (frame lock vs. identity reference, and the --extra-json form for the
latter), and endings — every item tagged with the gallery cases it was
observed in, or with the Ofox job ids it was measured on. A
scenario skill keeps only its own template and links that file for the rest,
exactly as it links approval-gate.md for the spend rule. This SKILL.md
does not restate it: the script runs whatever --prompt it is given, and the
shape of that text is the scenario layer's job.
Load references/creative-brief.md before
asking the user anything. It is the shared rule for the pre-generation
brief: the three tiers of axis, one AskUserQuestion round of at most four
questions, the shape of a question, the "Let the AI decide" discipline, the
generic skip rows, how the brief recap rides along with the cost table, the
fallback when the runtime has no AskUserQuestion, and the anti-patterns. A
scenario skill keeps only its own question set, its scenario-specific
inference rows, its answer-to-prompt map and a worked recap. Three shared
files, three jobs: what to ask (creative-brief.md), how to write
(prompt-structure.md), what to show before spending
(approval-gate.md).
This skill is an execution layer, not a director layer — it does not compete
with skills that specialize in writing Seedance prompts, it just runs
whatever prompt it's given. If the user already has a well-crafted prompt
from a "director" skill such as
LeoYeAI/seedance-skills or
liyue-aigc/seedance-2-5-video-director,
pass that prompt straight to ofox-video.sh generate — there's no need to
run this repo's own scenario-specific prompt-crafting steps (the ones
seedance-short-drama/seedance-ad-creative/seedance-product-video/
seedance-anime-drama each document) on top of a prompt that's already been
written. Those scenario skills exist for users who don't already have a
prompt and want one built for them; treat an existing director-skill output
as a ready-to-use prompt, not raw material to rewrite.