Every video we use to advertise AI Budżet is made without a single frame of stock footage. There is no After Effects in it, no Remotion, no Lottie and no image-generation model. Every pixel is either a real screenshot of the app or drawn by code — in Python, or in JavaScript on Canvas 2D. The most interesting of these, 30-second animated films with their own fully synthesized score, are built by Claude Code agents: one subagent per scene file, then waves of critics who measure pixels. Below is the whole kitchen from the technical side: three A pipeline is a fixed, predefined sequence of processing steps — unlike an agent it does not choose the order, it always runs the same one. Full definition →, the scene contract, the determinism gate, the 6 milliseconds you have to give back to the compressor, and the ffmpeg arguments that get a file through Instagram and TikTok.

AI Budżet (ai-budget.pl) is an AI budgeting app for individuals and families: voice expense capture (Whisper), receipt OCR (Optical Character Recognition) turns a scan or photo of a document into text a computer can read — the first step to an emailed invoice entering your system by itself. Full definition →, bank import, shared family accounts, a trip wallet with bill splitting, and a "safe to spend" figure. It is offline-first with end-to-end encryption and runs in nine languages. The stack is a Turborepo with NestJS and Prisma, Expo/React Native for mobile and web, and a Next.js admin panel. We wrote about the AI architecture separately. One detail matters for the ads: the app ships on Android and on the web, with no iOS version — so the creative rules explicitly forbid showing the App Store or an iPhone.

Why code instead of an editing suite and stock

The first reason is reproducibility. The app's UI changes, and an ad cut in an editor ages along with it. A script that assembles a reel from screenshots only has to be run again on fresh screenshots. A film drawn in code renders from scratch with one command, identical frame for frame — we enforce that with a dedicated gate, described below.

The second reason is localization. The app speaks nine languages, and we run campaigns in at least Polish and English. In a video editor every language version is manual work. For us the text is data: a dictionary of captions per language in a Python script, or swapped strings and currency in a copy of the film project.

The third reason is truthfulness. An ad for a finance app must not promise a feature that doesn't exist or show a bill that doesn't add up. When the creative is code in the same repository as the product, it can be checked the way code is checked: against the product wiki, with numbers, with a diff.

The fourth is version control. Every change to an ad is a commit that can be reviewed, reverted and compared. The rendered video files themselves, though, agents leave out of commits — more on that in the section on campaign flow.

Three pipelines and when to use which

The ai-budget-assistant repository holds three independent ways of making video. They differ in input, in tooling and in how much effort one piece costs.

A: screenshotsB: feature tourC: procedural film
InputReal phone or browser screenshots plus the PHASES and PILLS listsA raw recording of the app's own animated feature tourA brief, the product wiki, the app theme file
ToolsPython, Pillow 12, imageio, numpy, imageio-ffmpegPython, numpy, ffmpeg over a pipeJavaScript (Canvas 2D), Web Audio, Playwright with headless Chromium, ffmpeg, Claude Code agents
Output9:16 or 4:5 MP4 at 30 fps and a 600 px-wide GIF, no audioA 19 s 9:16 MP4 with a silent stereo AAC track30 s, 1080×1920, 24 fps, synthesized score, PL and EN versions
When we use itA single-feature campaign that should show the real screens; about 77 scripts for about 37 campaignsOne reel that walks through several features in the real UIA story ad with one premise, where motion and music carry the message

Pipeline A is a factory: cheap to use, good for a single-feature campaign. Pipeline B is one reel from a recording of the real UI. Pipeline C is craft — thirty seconds of animation backed by over ten thousand lines of scene code. We describe them in that order, but spend most of the space on the third.

Pipeline A: screenshot reels in Python

The docs/marketing/scripts/ directory holds about 77 scripts named build_<feature>_reel.py, covering roughly 37 campaigns: budgets, goals, bill splitting, trips, maps, inflation, AI chat, subscriptions and more. The dependencies are modest: Pillow 12, imageio, numpy and imageio-ffmpeg.

A scene is data, not code. The script receives a list of real phone or browser screenshots and two lists: PHASES — an eyebrow and a headline for each scene — and PILLS, the feature tags. A new campaign is in practice new screenshots and new captions, not a new animation.

The background is computed once, before the frame loop: a vertical gradient from (44, 28, 15) to (11, 11, 14), Gaussian-blurred orange glows, a phone bezel or a browser window, the app icon, the brand badge and the URL. What is left per frame is only what moves: the screenshot slides inside a rounded-rect mask, and the motion follows a smoothstep curve, t · t · (3 − 2t). It has zero velocity at both ends, so the slide never jolts.

Code fragment (Python, pipeline A)
What it does
def ease(t): return t*t*(3 - 2*t)
Smoothstep: zero velocity at both ends of the slide, none of the jolt of linear motion
content = Image.new("RGBA", (VW, VH), (22, 22, 27, 255))
A dark "screen" the size of the phone display
content.alpha_composite(shots[scene], (0, int(-e*VH)))
The current screenshot slides up by a fraction e of the screen height
if off > 0: content.alpha_composite(shots[nxt], (0, int((1-e)*VH)))
During a transition the next screenshot comes in from below
content.putalpha(mask)
A rounded-rect mask clips both screenshots to the screen shape
frame.alpha_composite(content, (VX, VY))
Composite onto the background computed once before the loop
paste_alpha(frame, tops[pc], max(0.0, 1 - 2*e))
The old headline fades out in the first half of the transition
paste_alpha(frame, tops[pn], max(0.0, 2*e - 1))
The new headline fades in during the second half, so the two never overlap

The least obvious detail is the headlines. With a classic cross-fade the old caption fades out while the new one fades in at the same time, so halfway through the transition both are semi-transparent and overlap into an unreadable haze. That is why the fade is sequential: the old headline disappears in the first half of the transition and the new one appears in the second. In the website reel the URL in the browser's address bar switches exactly at the midpoint, so it matches what the window shows.

The rhythm is fixed: 30 frames per second, a 3.8–4.0 s hold per screen and transitions of 0.5–0.8 s. The script is invoked as python build_<name>_reel.py [gif|mp4|both] [9:16|4:5]. The 9:16 format is 1080×1920. The 4:5 format is 1080×1344 — a number worth explaining. Both dimensions are multiples of 8, so the codec does not have to resize anything, and the ratio stays inside the 4:5 to 16:9 range Instagram accepts.

Encoding happens in two stages. imageio writes the file through libx264, then ffmpeg re-encodes it with -profile:v main -level 4.0 -pix_fmt yuv420p -movflags +faststart -crf 18. The Main profile at level 4.0 with yuv420p is the most widely supported H.264 variant, and faststart moves the file's index to the front so playback starts before the whole file has downloaded. Next to the MP4 a 600 px-wide GIF is written. The reels carry no audio: music is picked inside the Instagram or TikTok app.

Language versions are per-language COPY dictionaries and a separate render folder per language. One privacy note, learned the hard way: personal data in screenshots is cropped, not blurred. We tried blur and it did not work, so now those areas are simply not in the frame.

Pipeline B: a reel from the real feature tour

The app has its own animated feature tour with a promo mode: the UI without navigation chrome, 6 s per slide. Pipeline B's input is a raw screen recording of that tour, and the build_feature_tour_reel.py script turns it into a finished reel. It adds an intro card (1.2 s), an ease-out cross-fade (0.4 s) into the recording, and at the end a 0.7 s fade into a call-to-action card that holds for 3.5 s. It alpha-blends the brand and URL overlay in numpy, and it skips blank leading frames by checking the colour of a single pixel.

Two engineering decisions here matter more than everything else. First, frames are streamed rather than held in memory: about 830 full-HD frames cost about 5 GB of RAM. The script pipes raw RGB straight into ffmpeg. Second, the file gets a silent stereo AAC track from the anullsrc filter. Many phone players and in-app viewers refuse an MP4 with no audio stream, or show a black frame in its place. The reel runs 19 s, respects the safe zones (nothing in the top 190 px or below y = 1540, where Instagram and TikTok put their UI), and exists in a Polish version for Instagram and an English one for TikTok.

Pipeline C: procedural films — overview

The third pipeline produces 30-second ad films drawn entirely in JavaScript: Canvas 2D, no framework, not a single image or audio file. The score is synthesized in Web Audio. The Node tooling has one dependency — playwright, i.e. headless Chromium — plus system ffmpeg.

The films are built by Claude Code agents through the project skill .claude/skills/ad-film. That skill adds the AI Budżet brand brief on top of the open-source, MIT-licensed procedural-film skill, which supplies the method and the tooling. We contributed a Windows fix to it: file:// URLs built with pathToFileURL, so Playwright loads local pages on Windows too.

Five films exist so far, each in Polish and English: receipt-journey ("Paragon, który liczy się sam" — a receipt that splits itself into categories), kto-placi (who pays, i.e. bill splitting), oddam-pietnastego (debts), real-salary and shared-family. The specs are shared: 12–14 shots, 30 s, 120 bpm, 24 fps, 1080×1920. Scene code runs to 8–15 thousand lines per film. receipt-journey has 13,623 lines across 14 scene files of 700–1,400 lines each.

Ten steps from brief to delivery; critic waves loop back into the scenes while fixes remain

The process has ten steps. Brief: feature, premise, about 30 s, 9:16. Research: every fact pinned to the product wiki and user docs, so the film never claims a feature that doesn't exist. Art bible: brand colours straight from the app's theme file — brand #E37F2B, brandDeep #B85F16, brandPale #FBE3CC and brandGlow #FFBA60. A storyboard on a beat grid, with shared-geometry tables for match cuts and a safe area of x 60–940, y 220–1540. Then the timeline as data, a stub pass with the gate and a half-scale draft, the scenes, the music, the critic waves and delivery.

The timeline as data, and the scene contract

The heart of a film is timeline.js. It describes the tempo, length and format, the shot list and a flat list of audio cues — receipt-journey has 46 of them. Every shot starts and ends on a beat, has a mode (an illustrated "paper" plate or a schematic navy blueprint) and a brief, a sentence-level description of what has to happen. For the today-coin shot the brief reads roughly: caption "Bezpieczne wydatki / na dziś" ("safe to spend / today"), today's column becomes a coin reading 77 zł that lands on beat 2 and turns slowly.

A scene is an object with a draw(ctx, t, info) function. The t parameter is seconds since the shot started, and info carries its duration, the global time T, the frame number, the frame dimensions and a helper library. Inside the scene, beats are named constants with a comment tying them to the global timeline, so anyone reading the file can see which hit in the music a movement is locked to.

Code fragment (JavaScript, pipeline C)
Meaning
FILM.TIMELINE = { title: 'Paragon, który liczy się sam', bpm: 120, duration: 30, fps: 24, width: 1080, height: 1920, shots: […], cues: […] }
The whole film as data: tempo, length, format, the shot list and the audio cue list
{ id: 'today-coin', file: '11-today-coin.js', start: 20, end: 23, mode: 'illustrated', title: 'Safe to spend today', brief: '…' }
One shot: starts and ends on a beat, has a mode (illustrated or schematic) and a brief for the scene author
{ t: 20.5, kind: 'hit', note: 'The coin: bright FM bell, tock and noise burst ka-ching, sub hit' }
One of the 46 audio cues in receipt-journey; music and picture read the same clock
FILM.scene({ id: 'egg-blueprint', draw(ctx, t, info) { … } })
The scene contract; the id must match a shot in FILM.TIMELINE.shots
t, info: { dur, p, T, frame, W, H, lib, shot }
t is seconds since the shot started (0 … info.dur); info carries the duration, global time, frame number, dimensions and the helper library
const B_LAND = 0.5; // T 20.5 beat 2: the coin lands, yellow ring bursts
Beats as named constants: the comment gives the global time and the beat number
const GLINTS = [1.0, 1.5, 2.0, 2.5]; // a glint every beat
At 120 bpm a beat lasts 0.5 s, so a glint every beat is one every half second
const tw = L.onTwos(t);
Time quantized to 1/12 s — animation "on twos"
const LAT = 0.006;
Compensation for the compressor's 6 ms delay in the audio chain

The contract is deliberately narrow. A scene knows nothing about other scenes, does not manage time and holds no state between frames. It receives a time and draws. That is what makes it possible to render frames in any order, in parallel across several browsers, and to reproduce any frame a week later.

The determinism gate

A narrow contract is worth nothing if nobody enforces it. The check.cjs script does, running six checks: media scan, determinism, source scan, timeline, draw and frame cost. The first two are the most interesting from an engineer's point of view.

Determinism is tested bluntly. The first, middle and last frame of every shot are rendered five ways and their hashes compared: on a warm page forward, on a warm page reversed, on a fresh page in shuffled order mixed with decoy frames, cold, and sequentially. If a scene holds hidden state — a counter, a A cache reuses the repeated, unchanging part of a prompt, which model providers charge less for — so a well-ordered prompt can cost several times less. Full definition → that depends on call order, an animation started "on first call" — one of the five hashes will diverge.

The source scan rejects anything that feeds non-deterministic input into a frame: Math.random, Date, performance.now and crypto randomness. In their place the library provides a seeded generator, lib.rng(seed), and a lib.hash function. draw(ctx, t, info) is stateless per frame, and a cache is allowed only if it is a pure function of its inputs.

A hand-drawn feel from code

Vector graphics computed fresh every frame usually look too smooth and too "digital". Two simple mechanisms bring them closer to hand-drawn animation. The first is a "boil" clock: lib.boil(T, 12) re-seeds the line wobble 12 times a second, so outlines shimmer slightly as if redrawn frame by frame. The second is onTwos(t), which quantizes motion to 1/12 s — as in classic animation drawn "on twos", where one drawing is held for two frames at 24 fps.

On top of that comes paper grain on illustrated shots and fine noise on schematic ones, plus a small set of easing curves: inOutCubic, outBack, outExpo and inOutSine. All of these effects are functions of time and seed, so the determinism gate lets them through without exceptions.

A synthesized score, and 6 milliseconds

music.js does not load a single sample. The whole score is built from oscillators, periodic waves (PeriodicWave), seeded noise buffers, convolver reverbs on generated impulse responses, a ping-pong delay, a glue compressor and a soft limiter. The coin in the today-coin shot, for example, is a bright FM bell, a tock, a noise-burst "ka-ching" and a sub hit, described by a single entry in the cue list.

The best story in that file is about the compressor. Web Audio's DynamicsCompressorNode delays its output by a fixed 6 ms look-ahead — we measured 288 samples at 48 kHz. Without a correction every hit left the mix 6 ms after its beat. The fix is simple: everything that goes in before the compressor is scheduled a constant LAT = 0.006 early, so it leaves the master exactly on its cue. Only an event inside the first 6 ms of a render window can land late. An analysis tool verifies that cue onsets hit their written times to within 10 ms.

The same render function serves two modes. Offline we render through an OfflineAudioContext that suspends 0.25 s before each bar to schedule it. Live, in the browser, scheduling is driven by a timer with a 1.5 s look-ahead. Because the code is shared, what you hear in the preview is what ends up in the file.

Agents: one scene file, one subagent

The scene code is written by Claude Code agents. After the stub step — every shot exists as an empty scene that passes the gate, and a half-scale draft shows the rhythm of the whole — the scenes go to subagents, one per file, working in parallel. There is one rule, and it is absolute: file ownership is law. A subagent edits only its own scene file. That is why one shot can be rendered and inspected while its sibling files are still half-written.

This pipeline does not record the real app UI. It is redrawn in code: cast.js holds uiChat, uiHome, uiDebt, uiSplit and other components that use the app's real Polish UI strings. A screen in the film looks like the app but is part of the illustration and can animate along with it.

Critic waves, and what they actually found

Once the scenes are done the critics come in — subagents again. They score contact sheets of frames and measure pixels with numpy. They check caption extents and the safe area, WCAG contrast, both sides of every match cut, Polish diacritics and the arithmetic shown on screen. Findings are ranked P1, P2 or P3 and fixed in waves, and after each wave the critics look again.

Three real findings from receipt-journey show what they look for. First: captions overflowed x = 940, because the art bible gave captions an 860 px width, which contradicted the safe area. We lowered the cap to 800 px. Second: a glint on the coin washed out the "77 zł" label for about 35% of its hold, down to a contrast of 2.1:1. The fix: clip the glint around the digits and add a 3 px ink stroke, which lifts contrast above 7:1. Third, the on-screen arithmetic was verified: 240 = 180 + 35 + 25 and 912 − 65 = 847 = 11 × 77. The amount for today has to follow from the numbers shown earlier.

Encoding and delivery per platform

Rendering is arranged so failures surface as early as possible. Audio renders first, so a broken score fails before minutes of frame rendering. Parallel workers each run their own browser, PNG frames flow into ffmpeg through image2pipe, and the previous export is replaced only once the new encode has completed in full. An interrupted render never leaves a broken file where a good one used to be.

The most important part of the ffmpeg arguments is colour conversion. Canvas draws in full-range RGB, while video is meant to live in limited-range BT.709. So the scale filter says explicitly scale=in_range=full:out_range=tv:out_color_matrix=bt709 with the lanczos+accurate_rnd+full_chroma_int flags, then format=yuv420p, and the file is tagged with -colorspace bt709, -color_primaries bt709, -color_trc bt709 and -color_range tv. Without that, the same #E37F2B orange would look different in every player. Audio is -c:a aac -b:a 192k at 48 kHz stereo, and -movflags +faststart again moves the index to the front of the file.

VersionFormatVideoAudio
A — 9:16 reel1080×1920, 30 fpsH.264 Main@4.0, yuv420p, CRF 18, faststartNone — music is picked in the Instagram or TikTok app
A — 4:5 reel1080×1344, 30 fpsAs aboveNone
A — GIF600 px wideGIF—
B — feature-tour reel1080×1920, fps from the recordingH.264 Main@4.0, yuv420p, CRF 18, preset slow, faststartSilent stereo AAC (anullsrc, 44.1 kHz)
C — film master1080×1920, 24 fpsH.264, CRF 16, yuv420p, BT.709; 34–43 Mb/s, 120–155 MB per 30 sAAC 192 kbps, 48 kHz, stereo; raw score about −16.2 LUFS
C — phone transcode720×1280, 24 fpsH.264, CRF 23AAC
C — social transcode1080×1920, 24 fpsH.264, about 19 Mb/sAAC after loudnorm I=−14, TP=−1.5, LRA=11; −13.1 to −14 LUFS

One film yields a full set: the master (libx264, CRF 16, yuv420p BT.709, 24 fps, AAC 192k at 48 kHz), a 720×1280 phone transcode at CRF 23 and a 1080×1920 social transcode with loudness normalization, loudnorm=I=-14:TP=-1.5:LRA=11. The raw score sits around −16.2 LUFS, and the delivered files land between −13.1 and −14 LUFS. Masters run 120–155 MB per 30 s (34–43 Mb/s); the social version is about 19 Mb/s. On top of that: a self-contained HTML player, a shot list, a static Story poster (Pillow, with the Google Play link sticker) and the ad copy.

Localization without touching the animation

In pipeline A, localization is a COPY dictionary per language and a separate render folder. In pipeline C, the English film is a copy of the project with timing, geometry, music and animation untouched. Only the strings and the currency change — złoty to euro, with a decimal point instead of a comma — plus the coin art. The English version of the bill-splitting film gets a bimetallic €1 coin.

A copy instead of a language switch inside one project has a simple advantage: a translation cannot accidentally shift a cue by a beat, and every version goes through the same gate and the same critics on its own. A longer English caption that doesn't fit in 800 px is simply a new finding.

From idea to campaign

Films do not start from a blank page. Agents scan the repository and propose 3 to 7 campaign ideas, each with checkable evidence — a commit, a file or a number. The ideas land on an internal ideas board where agents propose campaigns with evidence and a human approves them. Once one is approved, an agent builds the creative, runs a verify command and reports the files it produced, named <name>-<format>-<locale>.<ext>. Agents never commit the creatives themselves.

An honest word on cost: one film consumes a large share of a usage plan. That is why the skill keeps a HANDOFF.md file that lets a stopped session resume where it left off instead of starting over. We are not publishing ad results here — this piece is about the workshop, not about campaign performance.

Lessons and gotchas

A silent audio track. An MP4 with no audio stream is sometimes refused by phone players and in-app viewers, or shows a black frame. A track from anullsrc costs nothing and removes the whole problem.

Dimensions divisible by 8. 1080×1344 rather than the "nice" 1080×1350: the codec does not resize, and the ratio stays inside Instagram's 4:5 to 16:9 range.

Crop, don't blur. We do not blur personal data in screenshots; we cut it out of the frame. We tried blur and it did not work.

Safe zones measured in pixels. The top 190 px and everything below y = 1540 belong to Instagram's and TikTok's UI. In the films the safe area is x 60–940 and y 220–1540, and the critics measure it on rendered frames instead of trusting the storyboard.

Audio first. The score renders before the frames, so a broken score costs seconds, not minutes. A finished export replaces the previous one only after a complete encode.

Stream the frames. 830 full-HD frames in memory is about 5 GB. A pipe into ffmpeg turns that into a small, constant RAM footprint.

Captions in sequence, not at once. A sequential headline fade is cheaper than any effect and removes the unreadable haze of two overlapping texts.

An ad that is code has the same strengths as code: it can be reviewed, reproduced, translated without touching the animation, and checked for whether it tells the truth. You can try AI Budżet at ai-budget.pl or on Google Play, and the app's code is on GitHub. If you want a similar video pipeline for your own product — from screenshot reels to procedural films built by agents — write to us at development@mi-code.pl.

Frequently asked questions

Can you make an app video ad without After Effects or stock footage?
Yes. In the AI Budżet ads every pixel is either a real screenshot or drawn by code. Screenshot reels are assembled in Python (Pillow, imageio, numpy, ffmpeg), and the 30-second films are drawn in JavaScript on Canvas 2D with a score synthesized in Web Audio, then rendered frame by frame through Playwright and ffmpeg.
How do you make a canvas animation render deterministically, frame for frame?
The scene receives only a time and draws: draw(ctx, t, info) holds no state between frames, and a cache is allowed only as a pure function of its inputs. Randomness comes from a seeded generator. The check.cjs gate rejects Math.random, Date, performance.now and crypto randomness in the source, and renders the first, middle and last frame of every shot five different ways and compares the hashes.
Why add a silent audio track to an MP4 that has no music?
Many phone players and in-app viewers refuse an MP4 with no audio stream, or show a black frame in its place. We add a silent stereo AAC track in ffmpeg with the anullsrc filter and -shortest. The exception is reels whose music is picked inside Instagram or TikTok.
What resolution should a 4:5 Instagram video use?
We use 1080×1344 instead of 1080×1350. Both dimensions are multiples of 8, so the H.264 encoder does no resizing, and the ratio stays inside the 4:5 to 16:9 range Instagram accepts. We encode with the Main 4.0 profile, yuv420p, CRF 18 and +faststart.
How do you sync Web Audio music with video frames?
Music and picture read the same timeline with its cue list. Offline we render through an OfflineAudioContext that suspends 0.25 s before each bar to schedule it. DynamicsCompressorNode delays its output by 6 ms (288 samples at 48 kHz), so everything before the compressor is scheduled 6 ms early. An analysis tool checks that cues land on their time to within 10 ms.