◂ Back to the Universe

PYROS · CS-09 · Forge world

Animated backgrounds
that aren't a 40 MB GIF.

Generated video makes a very good website background and a very bad website. This is the method I actually use — composing the still first, animating from it, closing the loop, then shipping it so it doesn't wreck page weight or drain a phone. Driven from Claude Code through the Higgsfield MCP.

Why the obvious way fails

The obvious way is to ask a video model for "a cinematic abstract background" and use whatever comes back. It fails for three reasons, and all three are fixable.

  • You get a shot, not a background. Text-to-video models compose for a subject. A background has to be empty in the middle, because your headline goes there.
  • It won't loop. A four-second clip that snaps back to frame one is worse than a still image. Everyone sees the cut.
  • It's too heavy. A 1080p clip straight out of a model is routinely 15–40 MB. That is your entire performance budget, spent on decoration.

The fix for all three is the same: stop asking for video. Ask for a still you control, then animate that.

The method

  1. Compose the still first

    You can art-direct an image in a way you cannot art-direct a video — cheaper per attempt, faster to judge, and you can iterate on composition until the middle of the frame is genuinely empty. Generate at 21:9, because a hero background is wider than it is tall and cropping a 16:9 later throws away the part you framed.

    seedream_v4_5 is the one I reach for: it goes to 4K on quality: basic and roughly 6K on high, and it holds 21:9 properly. nano_banana_2 is the faster alternative.

  2. Animate from that exact frame

    Pass the still as start_image. The model now has your composition and only has to decide how it moves — which is the part it is good at. seedance_2_5 takes start_image, end_image and image_references, does 4–30 seconds, and outputs 480p/720p/1080p.

    Ask for slow. Drifting dust, a slow parallax push, light moving across a surface. Fast motion behind text is unreadable and looks cheap; it also compresses badly, which is where the file size comes from.

  3. Close the loop

    This is the step everyone skips. Pass the same image as both start_image and end_image. The model then has to return to where it began, and the clip loops without a visible cut.

    It will not be mathematically perfect. Add a short CSS cross-fade at the seam, or keep the motion slow enough that a one-frame discontinuity is invisible — slow motion is forgiving in a way fast motion never is.

  4. Ship it light

    Encode down hard. A background is behind text at low contrast; it does not need bitrate. Target under 2 MB, serve WebM with an MP4 fallback, always give it a poster so something is on screen instantly, and never let it block first paint.

The prompts

Written for Claude Code with the Higgsfield MCP connected. Each names its model explicitly, because letting the tool choose gets you a different model and a different bill than you expected.

The single biggest lever is film language. Models were trained on cinematography, so they respond to lens, stock, grade and blocking far better than to adjectives. "Cinematic" produces nothing. "85mm, shallow depth of field, key light raking from camera-left, cool shadows, fine grain" produces a shot.

1 · the anchor frame
Using the Higgsfield MCP: run models_explore first to confirm the schema,
then get_cost before spending anything.

Generate 3 variants with generate_image_batch, model seedream_v4_5,
aspect_ratio 21:9, quality high. Same prompt, independent requests:

"Anamorphic wide shot into a dark volumetric haze. Warm ember light rakes in
from camera-left at a low angle and falls off steeply into near-black.
Fine airborne particulate catches the light in the left third. The centre
and right of the frame are deliberately unlit negative space.
Shot on 40mm anamorphic, T2.8, shallow focus, subtle halation on the
highlights, 35mm grain, filmic highlight rolloff, muted contrast.
No subject, no horizon line, no text, no figures, no lens flare."

Show me all three with one show_generation_by_ids call. I am choosing on
which has the emptiest centre, not which is prettiest.

Three things in that prompt are doing the work. A named lens and stop gives consistent depth of field. A named light direction stops the model centring everything. An explicit negative-space instruction is the only reliable way to keep the middle of the frame clear for your headline.

2 · the scroll shot — scrubbed, not autoplayed
Animate the chosen frame with model seedance_2_5.

Pass its job_id as start_image (media value must be a job_id or media_id,
never a URL). duration 10, resolution 1080p, aspect_ratio 21:9,
generate_audio false, bitrate_mode high.

Prompt: "A single continuous push forward through the haze. The camera moves
at a constant slow rate, no acceleration and no easing. Particulate drifts
past the lens. The ember key light grows almost imperceptibly warmer and
closer as the move progresses. Locked horizon, no roll, no whip, no cut,
nothing enters or leaves frame."

get_cost first. This clip is going to be SCRUBBED by scroll position rather
than played, so constant-rate motion matters more than anything: any easing
the model adds will read as the page stuttering.

Constant rate is the whole trick. A scrubbed clip inherits its pacing from the scrollbar. If the model eases in and out, the viewer feels the page lagging rather than the shot breathing — so ask for constant motion and put the easing in your own scroll code, where you control it.

3 · chaining shots so sections feel like one film
For the next section, generate its anchor frame with seedream_v4_5 but pass
the LAST frame of the previous clip as image_references, so grade, grain and
haze density carry over.

Then animate it with seedance_2_5 using:
  start_image = the previous shot's final frame
  end_image   = this section's anchor frame

That makes section two begin exactly where section one ended. Scrolling from
one to the next becomes a continuous camera move rather than a cut between
two unrelated pieces of stock.

Do this for each section in turn. Consistency comes from image_references,
continuity comes from start_image/end_image. They are different jobs.
4 · the reactive layer
Generate one short reaction clip per interactive moment, model seedance_2_5,
duration 4, resolution 720p, generate_audio false, using the section's anchor
frame as start_image AND end_image so it returns to rest:

"The ember light pulses once, brightening over roughly half a second and
settling back. The particulate is briefly pushed outward from centre by the
change in pressure, then resettles. Camera locked, no move."

This plays on hover or on a section entering view, over the top of the
scrubbed background at low opacity. It is what makes the page feel like it
responded to you rather than merely moved.

Driving it with scroll

A scrubbed video is not an autoplaying one. You pause it and set currentTime from scroll position, so the shot advances exactly as fast as the reader scrolls and stops when they stop.

scroll scrubbing
const v = document.querySelector('.bg');
v.pause();                       // never autoplay a scrubbed clip

let target = 0, current = 0, ticking = false;

addEventListener('scroll', () => {
  const h = document.documentElement.scrollHeight - innerHeight;
  target = h > 0 ? scrollY / h : 0;
  if (!ticking) { ticking = true; requestAnimationFrame(step); }
}, { passive: true });

function step () {
  // ease toward the target so the shot glides instead of snapping
  current += (target - current) * 0.08;
  if (v.duration) v.currentTime = current * v.duration;
  if (Math.abs(target - current) > 0.0005) requestAnimationFrame(step);
  else ticking = false;
}
  • Encode for seeking, not for size. Normal video only has a keyframe every few seconds, so scrubbing lands on the nearest one and judders. Re-encode with a keyframe on every frame — ffmpeg -i in.mp4 -g 1 -crf 30 -an out.mp4 — which seeks instantly at the cost of a larger file. For a ten-second 1080p clip that is a fair trade; for a minute it is not.
  • Ease in your code, not in the model. The 0.08 lerp above is what makes it feel expensive. The model supplies constant motion; your easing supplies the weight.
  • Never scrub on the scroll event itself. Setting currentTime synchronously on every scroll event forces a decode per event and is exactly how these pages end up janky. Coalesce into one requestAnimationFrame, as above.
  • Give reduced-motion a real page. Scroll-hijacked video is the single worst thing to serve someone with vestibular sensitivity. Show the poster and let the page scroll normally.

On mobile, don't. A phone decoding 1080p while compositing your layout is where battery and frame rate go. Serve the poster frame under 720px and spend the budget on the content instead.

Which model for what

Verified against the live catalogue, not memory. Parameters change — run models_explore before you rely on any of this.

ModelOutputUse it for
seedream_v4_54K, ~6K on highThe still. Holds 21:9 and takes direction well.
nano_banana_21k / 2k / 4kFaster stills when you are still exploring composition.
seedance_2_5480p–1080p, 4–30sThe workhorse. start_image + end_image is what makes loops possible.
minimax_h32K, 4–15sWhen you need more resolution than 1080p.
grok_video_v15480p–1080p, 2–15sAlternative when Seedance misreads the brief.
hf_mult_motion_control480p–1080pTransferring motion from a video you already like onto your own frame.

Putting it on the page

The markup is the easy part, but three details separate a background that feels expensive from one that feels broken.

the markup
<video class="bg" autoplay muted loop playsinline preload="metadata"
       poster="/assets/hero-poster.jpg">
  <source src="/assets/hero.webm" type="video/webm">
  <source src="/assets/hero.mp4"  type="video/mp4">
</video>

.bg{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;z-index:-1}

/* Anyone who asked for less motion gets the poster frame instead. */
@media (prefers-reduced-motion: reduce){
  .bg{display:none}
  body{background:#120a06 url(/assets/hero-poster.jpg) center/cover}
}
  • muted and playsinline are not optional. Without both, iOS refuses to autoplay and opens it fullscreen instead.
  • The poster does the work. It paints immediately while the video is still arriving, so the page never shows a black rectangle. If the video fails entirely, nobody finds out.
  • Honour prefers-reduced-motion. A permanently moving background is exactly what that setting exists for, and falling back to the poster costs one media query.

Spending discipline

Generation costs credits per attempt, and video costs considerably more than stills. Two habits keep it sensible.

  • Preflight with get_cost: true. It returns the price without submitting a job. Iterate on cheap stills; only spend on video once the composition is settled.
  • Batch independent requests. generate_image_batch for different prompts, count only for variants of the same one. Poll with jobs_wait and collect with a single show_generation_by_ids rather than one call per job.

Worth knowing: on a free tier, grounded search is quota-limited separately from generation. I hit HTTP 429 on a search-grounded call while plain generation returned 200 on the same key. If something fails, check whether it is the feature and not the key.

The honest limitation

This produces a good abstract background. It does not produce a brand. A generated ember field is atmosphere — it says nothing specific about what you do, and if the competition uses the same models, it says nothing distinguishing either.

Use it where atmosphere is the job: a hero, a section break, a login screen. Where the page has to make an argument, a real screenshot of the thing working beats any amount of generated motion. That is why the product worlds in this universe show live demos of the actual algorithms rather than video of them.