Skip to main content

Documentation

No results found.
Features

AI Video Generation

WebProCMS lets editors generate short video clips from a text prompt — or animate a library image — directly inside the Media Library. A clip renders server-side via Google (Gemini Omni Flash) or fal.ai, lands as a reusable video item with...

Bring-your-own API key. Operators configure their own provider key (Google or fal.ai) on the API Keys settings page and pick a video provider + model on the AI settings page; per-clip generation costs are billed by the provider, not by WebProCMS. No membership required.

WebProCMS lets editors generate short video clips from a text prompt — or animate a library image — directly inside the Media Library. A clip renders server-side via Google (Gemini Omni Flash) or fal.ai, lands as a reusable video item with an auto-generated poster frame, and is then picked into any self-hosted x-dl.video spot through the normal media picker — the same way an uploaded clip would be. Crucially, the whole thing works without a queue worker, so it runs on the same shared hosting every other WebProCMS install targets.


The problem

Video generation is fundamentally different from image generation in two ways that break the existing AI-image pattern:

  1. It's slow and asynchronous. Every video API (Gemini Omni, Veo, Kling, etc.) is a long-running operation: you submit a prompt, get back an operation handle, and the clip renders for ~30–120 seconds before you can download it. The synchronous "request → bytes in the same response" flow the image generator uses simply doesn't apply.
  2. Most installs have no queue worker. WebProCMS is built to run on ordinary hosting where QUEUE_CONNECTION=sync and there's no php artisan queue:work daemon. A naive "dispatch a job that polls for two minutes" approach either blocks the web request (timing out PHP-FPM) or never runs at all.

The off-the-shelf alternative — generate in a third-party tool, download, re-upload, fill in a poster by hand — crosses three tools and leaves the editor managing files manually.

The fix

WebProCMS models video generation as a chunked poll state machine driven entirely by short web requests — no worker, no long-held connection:

  1. Submit. The editor enters a prompt; the server POSTs it to the provider, gets an operation handle, and stores a processing row. The request returns in a second or two.
  2. Poll. A conditional wire:poll on the Media Library page advances the oldest in-flight generation once every few seconds. Each tick is a sub-second request — "still rendering?" — so nothing ever blocks.
  3. Finalize. The moment the provider reports done, that same tick streams the finished clip to disk, creates a video MediaItem, and triggers client-side poster extraction. The clip appears in the grid.

Because progress lives in the database and advances across normal requests, the feature needs zero infrastructure beyond the web server. The editor can close the modal and keep working — the clip still lands when ready.


Two providers, one workflow

The active provider is configured at Dashboard → Settings → AI → Video Generation (ai.video_provider), and its key on the API Keys page. The Media Library UI is identical regardless of which provider is active; only the request-side details change.

Provider Default model Notes
Google gemini-omni-1.1-flash Google's own video model, on the Gemini API host with the same key as Gemini image generation. Runs on the Interactions API in background mode — see "Google: Gemini Omni in the background" below. Paid-tier keys only.
fal.ai google/gemini-omni-flash/v1.1/text-to-video One key, many models. Submits to queue.fal.run, polls the status URL, fetches the result URL. Already the same key used for fal image generation. Also offers Kling 2.5 Turbo Pro and Seedance 1.0 Pro.

Prices checked on 2026-10-11, per second of 720p output: Gemini Omni Flash 1.1 ≈ $0.10 on either provider; Veo 3.1 on Google $0.40 (Standard), $0.10 (Fast), $0.05 (Lite); Veo 3.1 on fal $0.40 with audio; Kling 2.5 Turbo Pro $0.35 for 5 s plus $0.07 a second; Seedance 1.0 Pro ~$0.62 for 5 s at 1080p.

Veo 3.1 shuts down on 2026-10-22, on both providers. Google retires veo-3.1-generate-preview, veo-3.1-fast-generate-preview and veo-3.1-lite-generate-preview that day and names gemini-omni-1.1-flash as the replacement for all three; fal flags fal-ai/veo3.1 and everything under fal-ai/veo3.1/. Until then the Veo options stay selectable, labelled with the date, but are no longer the default. A Veo id an install has stored keeps running as itself until 2026-10-22 and as Gemini Omni Flash 1.1 from that day on — swapped at call time by AiVideoModels::resolve(), starting 00:00 UTC (hours early is harmless; the replacement is already live). From that day the settings page shows Gemini Omni in its place and drops the Veo options, so the next Save replaces the retired id with a blank (the default). On Google, someone who chose Veo 3.1 Lite pays about twice as much per second after the swap ($0.05 → $0.10); there is no cheaper Google model left.

Why not AiModelCatalog::SUCCESSORS? That map moves a model at once (Veo has to keep working until its shutdown), it needs both ids priced in AiRateCard (video is self-keyed and never metered, so it isn't priced there), and its chains must end on a curated text or image model. The video swap lives in AiVideoModels::RETIREMENTS instead.

"Or newer" can't hold a retired model. Each video model select carries the same Or newer checkbox as every model picker (App\Support\Ai\ModelTracking): it decides whether the setting follows newer versions of its model or runs exactly the one shown. A retirement is not a version you can decline — AiVideoModels::configured() applies the choice first and the retirement swap after it, so a Veo id with its box unticked still runs as Gemini Omni Flash 1.1 from its shutdown day, because the provider no longer serves it.

Why only these two. Of the six AI keys WebProCMS supports, four can technically do video, but only Google and fal are worth shipping. OpenAI's Sora API was announced for shutdown (Sept 2026), and Stability's video model is weak (~2-second image-to-video only). The provider abstraction leaves room to add others behind the same poll loop later.

Provider switching requires no code changes and no migration — the poll loop reads Setting::get('ai.video_provider') at request time and dispatches to the matching method. A model ID kept from an earlier release still shows in its dropdown as the current choice, even after the shipped list drops it. A model setting that only restates the shipped default is stored blank, so a future default reaches the install without anyone pressing Save — every install that saved the page before this release had veo-3.1-generate-preview stored explicitly, which is why the shutdown swap above is needed at all.

What each model is sent

Every video model takes its own input shape, and a shape meant for another model is a 422. One table, AiVideoModels::SPECS, decides each request — so a new model is a new row, not a conditional. Checked against each provider's live schema on 2026-10-11:

Model duration Aspect ratios Start frame goes to
Google gemini-omni-1.1-flash response_format.duration, a google-duration: "8s" (3–10) 16:9, 9:16 the same interaction, as an image input with video_config.task: image_to_video
Google veo-3.1-* (until 2026-10-22) durationSeconds: 4, 6, 8 16:9, 9:16 instances[].image
fal Gemini Omni Flash 1.1 integer 8 (3–10) 16:9, 9:16 google/gemini-omni-flash/v1.1/image-to-video
fal Kling 2.5 Turbo Pro string "5" or "10" 16:9, 9:16, 1:1 …/v2.5-turbo/pro/image-to-video — which takes no aspect ratio; the clip follows the image
fal Seedance 1.0 Pro string "2"–"12" 21:9, 16:9, 4:3, 1:1, 3:4, 9:16 …/seedance/v1/pro/image-to-video
fal Veo 3.1 / Veo 3.1 Fast (until 2026-10-22) "4s", "6s", "8s" 16:9, 9:16 fal-ai/veo3.1/image-to-video / fal-ai/veo3.1/fast/image-to-video

On fal the start frame is sent as a data-URI image_url.

Two rules turn the Media Library's choices into what the model accepts:

  • Duration is rounded to the nearest length the model makes; a tie goes to the shorter, cheaper one. Kling turns 4 or 6 seconds into 5 and 8 into 10; Veo turns 5 into 4. The generation row records the length actually requested.
  • An aspect ratio the model can't make is refused, never guessed. Square has no "nearest" landscape-or-portrait answer (it is equidistant from both), and a wrong-shaped clip is still billed per second — so the generator throws "Gemini Omni Flash 1.1 can't make 1:1 videos — it makes 16:9, 9:16…" before anything is sent. The modal only offers the ratios the configured model makes, so this is a backstop for stale form state, not the normal path.

A model id the table doesn't know (set by hand or by an import) is sent conservatively: on fal the prompt and aspect ratio only, with no duration (fal's models share no duration format, and the model's own default beats a 422) and any start frame as image_url on the same endpoint; on Google, a veo-… id goes to :predictLongRunning and anything else to the Interactions API, with the choices passed through.

fal's 422 carries detail as a list of {loc, msg} objects; the generator turns that into one readable line ("fal.ai: duration: Input should be '5' or '10'") instead of the TypeError it used to raise.

Google: Gemini Omni in the background

Gemini Omni runs on the Interactions API, not on Veo's :predictLongRunning. A plain call there is synchronous — the request stays open for the whole render — which a web request can't afford. So the create sets background: true (with store: true, which background execution needs) and returns an interaction id at once:

  1. Submit — POST https://generativelanguage.googleapis.com/v1beta/interactions with model, input (the prompt, or [image, text] for a start frame), response_format: {type: "video", aspect_ratio, duration, resolution: "720p", delivery: "inline"} and background: true. The handle stored on the row is interactions/{id}.
  2. Poll — GET /v1beta/interactions/{id}?include_input=false reports in_progress / queued until the clip is ready, then completed, or failed / cancelled / incomplete with the reason in errors[].
  3. The clip comes back inline. Google returns the video as base64 in steps[].content[].data on a GET, whatever delivery the create asked for. Every poll is therefore sinked to a temp file and read with the same streaming helpers the image path uses (ReadsSinkedJson): the status is a needle scan, and the clip is piped through convert.base64-decode straight to disk. Each inline data value is decoded in turn and the first whose bytes are a video container wins, so an echoed start frame or a different key order can't hand back the wrong file.
  4. Download — the media library's download() call moves the clip the poll already decoded; it is not fetched twice. A caller that polled on another instance gets a second fetch and decode instead. If an interaction ever answers with a uri rather than inline bytes, that file is downloaded with the key, held to Google's own domains like any other Google file.

The id is format-checked ([A-Za-z0-9_-]) and only ever placed in the path of the fixed Gemini host, and the API key only goes to googleapis.com with redirects off. Requests send Api-Revision: 2026-05-20, the steps schema the parser reads (Google ignores the header since the legacy schema went on 2026-06-08).

Residual risk. Google documents background execution for the Interactions API as a whole, and the Omni guide lists background=false as the opt-out for synchronous generation, but it has no Omni-specific background sample. If Google refuses background: true for Omni, the create fails with Google's own message in the modal and nothing is billed; choose fal as the provider until that's resolved. The first real Google generation should be smoke-tested with a paid key before 2026-10-22.

Generating a clip

The Generate Video button appears in the Media Library toolbar (next to Generate Image) only when a video provider is configured and its key is set. It opens a modal with:

  • Prompt — free text describing the clip.
  • Aspect ratio — Landscape 16:9 / Portrait 9:16 / Square 1:1, narrowed to the ones the configured model makes (Gemini Omni and Veo make no square clips). A ratio left over from another model snaps to the first allowed one when the modal opens.
  • Duration — 4 / 6 / 8 seconds, rounded to the nearest length the model makes (see "What each model is sent").
  • A cost-and-time note — "Rendering takes ~30–120s and is billed per second of output by your provider" — so editors aren't surprised by either the wait or the bill.

On submit, the modal shows a live "Generating your video…" state. The clip is saved to the currently-selected media category (or the default category), and the editor sees a "Video saved to your library" confirmation. From there it's a normal video MediaItem — pick it into a self-hosted x-dl.video field, a section background video, or anywhere the media picker is used.

The no-worker async loop in detail

State for each generation lives in the ai_video_generations table — one row per clip, queryable, with full history. The loop:

Step What happens Where
Submit AiVideoGenerator::submit() POSTs the prompt, returns {provider, model, handle, aspect, duration} — the model that actually ran and what it was actually asked for. A processing row is inserted. submitVideoGeneration() on the Media Library component
Poll A wire:poll.5s — rendered on the page root only while a generation is processing — calls pollVideoGeneration(), which polls the oldest in-flight row. AiVideoGenerator::poll($provider, $handle)
Finalize On done, the clip is streamed to the media disk under YYYY/MM/, a video MediaItem is created, the row is marked done with its media_item_id, and a media-video-generated browser event fires. pollVideoGeneration() → MediaItem::createForStoragePath()

The poll attribute disappears the moment nothing is processing, so there's no idle polling and no background churn. A generation stuck past 15 minutes is defensively marked failed so the loop and UI never spin forever.

This is a deliberate departure from the defer() pattern WebProCMS uses for CMS updates and backups. Those are single indivisible operations (git pull, composer install) that have to run to completion in one shot. Video generation is naturally chunkable — submit, then poll, then download — so splitting it across short requests avoids FPM timeouts entirely and works identically on sync, database, or a real queue driver.

Memory-safe download

fal and Veo return a download URL. The clip — potentially tens of megabytes — is written to disk in capped chunks (CappedDownload::toFile()), never buffered in PHP memory: a fetch that carries the API key (Google's files) is held to the provider's own domains with redirects off, and a fetch from the provider's CDN (fal's video.url) goes through OutboundHttp::guarded() with a byte cap. Gemini Omni returns the clip inline as base64 instead, so its polls are sinked to a temp file and the clip decoded straight to disk (see above); the decoded clip is capped at the same 256 MB as a download.

Auto-generated poster

WebProCMS generates video posters client-side (a first-frame canvas grab — there's no server-side ffmpeg). A server-downloaded AI clip has no browser involved at creation time, so the poster is attached the moment it shows up in the grid:

  1. Finalize dispatches media-video-generated with the new item's id and URL.
  2. An Alpine listener on the Media Library calls window.attachGeneratedVideoPoster(id, url, …).
  3. That reuses the exact same client-side frame-grab the manual "Regenerate from video" button uses (regeneratePosterForVideo → extractFirstFrameFromUrl → the regenerate-poster endpoint), then refreshes the component so the poster appears.

Poster extraction is non-fatal: if the browser can't decode a frame, the clip is still saved — it just shows the "No poster" badge until the editor regenerates it manually. Once a poster is attached, every self-hosted-video consumer (x-dl.video, section background video) emits poster="…" for it automatically via the query-free MediaItem::posterUrlForPath() cache — the same path uploaded videos use.

Image-to-video

Animate with AI on an image's details panel opens the same modal with that image as the start frame, defaulting the aspect ratio to the image's orientation. Google's Veo receives it as base64 instances[].image, Gemini Omni as an image input with the image_to_video task, and fal models as a data-URI image_url sent to the model's own image-to-video endpoint (see the table above). SVGs are excluded — providers only accept raster frames.

What lives where

Path Purpose
app/Support/Ai/AiVideoGenerator.php The shared service. submit() / poll() / download() for Google (Gemini Omni on the Interactions API, Veo on :predictLongRunning) and fal.ai. Three discrete steps so the same class works from the poll loop (and a future job).
app/Support/Ai/AiVideoModels.php The model table: what each model accepts (SPECS), the defaults, the Veo 3.1 retirement swap (RETIREMENTS / resolve()), the settings page's options and the modal's aspect choices.
app/Support/Ai/ReadsSinkedJson.php The streaming JSON / base64 helpers shared with AI image generation — read a sinked response without holding it in memory.
app/Models/AiVideoGeneration.php One in-flight (or finished) generation — status (processing / done / failed), provider, model, prompt, handle, linked media_item_id, error.
resources/views/pages/dashboard/media-library/⚡index.blade.php Hosts openGenerateVideoModal, openAnimateImageModal, submitVideoGeneration, pollVideoGeneration, the hasProcessingVideo computed that gates the root wire:poll, the videoAspectOptions computed, the Generate Video button, and the modal.
resources/js/manager.js window.attachGeneratedVideoPoster — auto-attaches a first-frame poster on completion, reusing the existing client-side extraction.
resources/views/pages/dashboard/settings/⚡ai.blade.php The Video Generation settings section — provider radios (Off / Google / fal) and per-provider model dropdowns fed by AiVideoModels::options().
app/Models/MediaItem.php createForStoragePath() registers the downloaded clip; kind='video' is auto-detected from the file's mime.

Settings reference

Key Default Notes
ai.video_provider '' One of google, fal. Empty means video generation is off — the Generate Video button is hidden.
ai.google_video_model gemini-omni-1.1-flash Active model when Google is the provider. Blank means the default. A stored Veo 3.1 id runs as Gemini Omni from 2026-10-22 (see above), whether or not its Or newer box is ticked.
ai.fal_video_model google/gemini-omni-flash/v1.1/text-to-video Active fal.ai model — stored as its text-to-video endpoint; start frames go to that model's image-to-video endpoint. Blank means the default. A stored Veo 3.1 id runs as Gemini Omni from 2026-10-22, whether or not its Or newer box is ticked.
ai.google_key '' Google AI API key — the same key powers Gemini image and text/vision calls.
ai.fal_key '' fal.ai API key — the same key powers fal image generation.

Relationship to other features

  • Media Library — generated clips are ordinary video MediaItems; everything the library does (categories, rename/move, replace, usage warnings, posters) applies to them.
  • AI Image Generation — the sibling synchronous flow. Video deliberately uses a different (chunked-poll) architecture because of its long render time and the no-worker constraint.
  • Lazy Cron — the same "do background-ish work without a daemon" philosophy WebProCMS applies elsewhere; video generation advances on user-driven wire:poll ticks rather than scheduled cron.
  • The self-hosted video player, poster ownership, and the x-dl.video component are documented alongside the media library and design library; generated clips slot into all of them unchanged.