Scenes for software product commercials, written in Remotion and locked to the frame clock.
Not another pack of logo reveals. CATALOG.md counts what the ecosystem
already ships: 81 templates in the biggest catalogue, nine of them ways to
reveal a logo. It also counts what none of it does. A prompt being typed, a
cursor that acts, real app UI on a tilted plane, a response streaming in. That
gap is what this repo is for.
Four, and they are the reason the scenes compose instead of merely existing.
Frame-locked. useCurrentFrame() and nothing else. No CSS keyframes, no
requestAnimationFrame, no wall clock. A frame renders the same whether it is
the first one or the thousandth, in the studio or in headless CI.
A progress escape hatch. Every scene accepts a normalized 0 to 1 progress
so a parent timeline can drive it. Left unset, the scene derives it from its own
frame.
No cuts. The two Linear commercials this repo takes its grammar from run 52 and 41 seconds in one unbroken shot. Scenes therefore have to chain by continuous transformation rather than by cut, and each one has to be able to enter from the previous one's final state. This is an architectural constraint, not a stylistic preference.
That rule was an assertion until one scene had to enter from another. Two clips
that both start and end at rest can be placed in any order without anyone seeing
a cut, because there is no motion to break. CardHandoff starts from the pose
UIMockup stops in, both reading it from products/topics/geometry.ts, and seam.sh
diffs the two frames to prove it.
One measured join makes the rule true of a pair. CardFocus is the third link:
it enters from the pose CardHandoff stops in and descends onto the card that
scene has just delivered, magnifying it 2.35 times. seam.sh now takes the pair
as arguments, because a join exists for every adjacent couple and hardcoding the
paths meant a second script identical to the first but for two lines. Across the
six scenes yaw runs -18, -9, -4, 0, 0, -34 and pitch 5, 2.5, 1.2, 0, 0, 3.2. It
reverses twice, at CardRelease and at BoardOrbit, and both times between two
poses that are at rest: where the join is still there is no derivative to flip.
In motion it never reverses: a join reads as a cut when the derivative flips, even when the pixels
match.
PromptInput used to stand on its own, and the reason is worth keeping because
it was not laziness. It drew a slab of its own — 2200 wide at perspective 2800
and scale 1.045, with its own chrome and its own sidebar — which made it a
second screen, and between two different screens the passage is a cut whatever
you do with it. So the thirteen seconds that hold half the film's performance,
the typing, the pause, the click, the streaming answer, sat outside the chain
while the other twenty-eight were joined.
Putting it in was not a swap of constants. The board only ever used the top half
of the slab; the bottom half was empty, which is also why the frames read as a
mockup rather than as a screen in use. The assistant thread and the composer now
live down there, in products/topics/Assistant.tsx, drawn by every scene — if only
one scene drew them, the join before it would show half a screen appearing out
of nothing, and seam.sh would call that a cut, correctly. With one screen the
move from the board to the composer stops being a change of screen and becomes a
camera descending, which is a movement the catalogue already has a name for.
Five scenes at that point, 1310 frames, and four measured joins.
Four more of the catalogue's movements went into the film after that, and they
are all one gesture. CardHandoff used to fly the card across on an
interpolation with nobody touching it, which reads as an animation; now the
cursor arrives on an arc with a small overshoot, presses, and the card follows
the hand three frames behind, tilting into the direction of travel. Getting that
into a scene needed the pointer's position and not just its drawing, so
Cursor.tsx exports pointOnPath and keeps the arrow to itself — the
alternative was a second copy of the trajectory inside the scene, equal to the
first exactly as long as nobody touched one of them. The extraction was checked
by re-rendering PromptInput against its previous file: five of six sampled
frames identical, one off by a single pixel.
Then the board answers in a chain rather than all at once. The card lands, the
destination count moves five frames later, and six frames after that the card
rewrites its own age from 12h to ora. The third link was supposed to be the
detail panel — the catalogue said so — and looking at the render said otherwise:
at that pose the camera is close enough that the panel runs off the right edge,
labels visible and values not. A link of the chain outside the frame is not a
link. Before this, the count and the panel both flipped on the same frame at
mid-travel, which is precisely the defect the entry describes: three things
changing together read as three unrelated things, and it is the delay that reads
as cause.
And while the answer streams in PromptInput, everything that is not the answer
drops to 0.62 — the board above, the earlier messages, the composer. Not a blur:
nothing becomes unreadable, only the place where the contrast is full moves.
The film ends on an orbit, which is the one thing a product piece says once:
this is an object. Seen frontally, an inclined plane with UI on it is
indistinguishable from wallpaper glued to the background, and until BoardOrbit
nothing in the chain ever turned far enough to prove otherwise. It stops at 34
degrees of yaw, just under the 40 where the board foreshortens below sixty per
cent of its width and the titles stop being titles, and it holds still for the
last 32 frames so the piece finishes on a pose rather than on an interrupted
move.
The thickness it shows, the edge that Shot draws, is there in all six scenes and
visible in exactly one, because at small yaw angles it sits precisely behind the slab. It
is a sibling of the slab and not a child, and that is not a detail: the slab
clips, any clipping flattens preserve-3d, and a child at translateZ(-30)
would be squashed onto its parent's plane and never stick out. The same fact is
why the parallax truck is still unwritten: per-layer depth inside the slab needs
the clipping moved somewhere else, in every scene, which is a restructure and
not an addition.
Six scenes now, 1460 frames, 48.7 seconds, five measured joins: 557, 64, 55, 29 and 15 pixels.
The page's demos run on a loop. Ten of them were one-way animations — a camera
that pushes in and stays in, a graph that draws itself and stays drawn, a board
that fills and stays full — so the last frame had nothing to do with the first
and every pass ended in a hard cut. On a page whose entire subject is why you do
not cut. GIU-04 changed 28 per cent of the frame at the join.
Nobody reads that as "the demo restarted". It reads as the site stuttering, which is exactly how it was reported.
The fix is one shared idea rather than ten patches: a round-trip profile, and a
virtual clock for the demos whose state is a function of a running frame rather
than of a 0-to-1 parameter — a board filling in has no t to invert, it has a
clock to run backwards. The return leg is not part of the movement and the
readout says so: better a declared leg home than a tear every pass. Two demos
did not need a rewind and got something truer instead: the streaming answer
scrolls up and out the way the app would, and the shared-word sentence swaps back
in the opposite direction, so the loop is click → drag → click.
loop-close.py measures it, and finding the right question took three wrong
ones. Against the demo's typical motion, the demos that sit still between
discrete beats have a median of zero and every join looks infinite. Against the
demo's largest step, a demo that also cuts internally takes its own cut as the
reference and absolves itself — CHR-02 scored 652 against 652, the same defect
counted twice. The question that works is simpler: is the last frame one step
away from the first, or somewhere else entirely? Compare the wrap against a
normal one-frame step taken at the same place in the loop.
It measures the cut in the middle too, and getting there took giving up on being clever. Half the entries show the right case and then the wrong one, and in the handover between the two they can restart from scratch — the same cut, somewhere else in the loop. Hunting for it did not work: a cut and a fade look too much alike. Counting pixels over a threshold, a linear fade spikes halfway through — that is where most pixels cross at once — so the bench flagged a line of text leaving the frame, which is the most continuous thing on the page. With a mean difference the spike goes away and the measure becomes so sensitive it flags everything.
The answer was not a smarter metric, it was to stop guessing: every two-part demo
declares the frame it changes on, and the bench looks there. It is the same
bargain seamAfter strikes in catalog.json — the scene says where the join is
and the bench measures it — and it holds for the same reason: a declared join can
be measured exactly, a guessed one cannot.
Pointed at the declared frames it found exactly one survivor, and it was the one
being reported: CUR-03 changed 547 pixels in a single frame against zero in its
neighbours, because the answer to the click sat there until the half ran out and
then vanished. It resolves now instead — the work finishes, which is what the app
would do — and that is not a rewind: nobody sees the line write itself backwards.
One more defect, and it was the one that showed. TYP-02 grew its keyword by
changing its font size inside a single flex line — and changing the size changes
the box, so the line recomposed, the wrap point moved, and halfway through the
growth the whole sentence jumped 147 pixels. Exactly the opposite of what the
entry claims. The keyword now has a row of its own and grows with a scale(),
which does not touch layout, so the other words move 0.00 px — measured, not
asserted. It is also closer to the references, where the enormous word is alone.
Ten entries with no interface on screen at all. They come from three reference films the type direction was read off — a SaaS promo, a Spotify spot, an Apple Business Essentials piece — and their common DNA: the typography is the film, one phrase per beat, always centred, heavy sans with tight tracking, scale contrast so one word per beat becomes enormous, and colour on the keyword only.
That DNA also said hard cuts on the beat, no continuous camera, which is the
opposite of this repo's founding rule. It is not a contradiction to paper over,
so here is how it resolves: the references chosen later moved the other way on
their own. Apple's Creator Studio film has five cuts in thirty-five seconds and
is continuous transformation rather than editing; the three shots picked for the
UI direction have zero; Linear's and Vercel's have zero. These four entries
sit on that side. TYP-04 is the clearest case — it is this repo's join rule
written as a sentence: two consecutive lines share most of their words, and the
shared ones do not move a pixel while the one that changes is replaced.
The dwell is measured, and it is the entry that matters. How long a line stays is not chosen by eye but in characters per second of net dwell — the stretch in which the sentence is complete and still, after it has arrived and before it starts to leave. Large type holds 15 to 16; over 20 the line is taken away while you are still reading it. In the demo two lines of 29 characters hand over inside a window, the one leaving rising past the top edge while the next rises in from below, and they stand still for 56 frames and for 35: the bench counts the frames in which a line sits exactly in its place and reads 15.5 c/s against 24.9. The corollary is the useful part: to gain reading time without slowing the edit down, keep the transition short and lengthen only the dwell. Every frame the transition takes comes out of the only stretch where anybody is reading.
Six more went in after the first four were looked at, and they split into how a line arrives and where it sits.
Arriving: a mask that uncovers each word from behind the edge of its own box — the most common technique in the reference work, and the reason is that the edge stays still while the word crosses it, so the word is uncovered rather than switched on. The grain of the stagger, letter by letter against word by word: 16 entry moments against 5 on the same words, which is the difference between a line that pours and a line that lands in blocks. Tracking closing from 0.225em, the only entrance that brings nothing in from off-frame — the sentence is all there and only stops holding its breath. And weight landing from 300 to 800.
Two of those corrected the entry that described them, which is the point of measuring. The weight one claimed weight was the axis that changes a word's ink without changing the room it takes; the render disagreed in the first frame, because the long line fitted on one row at 300 and wrapped at 800. It is now two words, the width growth is stated (11 per cent here, 3.6 on the CI's Linux, because how much a family spends on weight is its own business) rather than denied, and the lesson is written down: on a long line you either keep it short or lock the width. Tracking had the same shape of problem one floor down — wide spacing pushed the line onto three rows and tight spacing onto two, so the composition redid itself mid-animation. Tracking is a layout property, which is the entry; the demo is two words so the effect does not eat the composition.
Sitting: the companion on another axis — the small rotated line running up the side in spaced capitals, which is what the references put next to every main sentence. It does not compete because it is not on the axis the eye is following; laid flat, the same words become a caption, and a caption is a second thing to read. And the sentence on the plane, which is the entry that ties this family to the rest of the repo: the type lives in the slab's own perspective instead of sitting on the frame. If the film is an inclined object seen by a camera, a sentence lying flat on the glass comes from a different film. The bench measures it the cheapest way there is — on the plane the two ends of the line differ (0.909), flat they are identical (1.000).
The page grew a second kind of stage for these. Type demos do not mount the slab
— mounting it and then covering it would render a kanban behind a word for
nothing, and the stage's perspective would falsify the type size. Same engine,
same frame lock, same HUD and scrub; what changes is what is inside. The body is
sized in cqw and not pixels, because in a real composition the type is a
fraction of the frame, and the proportion between word and frame is the content
of these entries.
One defect worth recording, because it is the same shape as others in here, and
it took three tries. TYP-04 first cross-faded the two swapping words in the
same box, and at the midpoint you saw join and pose on top of each other —
which does not read as one word replacing another, it reads as a rendering
error. The second try made the swap sequential with the new word laid over the
old one, and pose is wider than join: two words of four letters are not the
same width, and for 118 frames out of 260 it covered is. Now every word lives
in a clipping box that is a grid cell as wide as the wider of its two words, with
both copies inside, and handing over is moving both up on the same stroke — one
leaves through the top edge while the other comes in through the bottom. That
made the pair itself a measurement: in a cell as wide as pose, join — 154 px
against 215 on a 100 px body — floated in a gap on both sides, in the half that
is supposed to be right. The words are click and drag now, 200.6 and 200.9
px, and whatever another font leaves over goes into the narrower one's tracking.
The three words that are supposed to stay put move 0.00 px, against 89 in the
half that hands over every box.
Two rounds of fixes on this family went out with every bench green, and scrolling
the site still showed words on top of words, sentences that went away and came
back, and things that jumped. None of those is a thesis failing, so
demo-check.py could not see them, and none is a torn loop, so loop-close.py
could not either. type-check.py looks for exactly those, on every frame of every
typography demo, at several widths, reading the rendered DOM rather than pixels —
with pixels a fade and a cut look too much alike, with properties a fade changes
opacity by a few hundredths a frame and a cut by all of it. What it checks:
- Covering: no two visible units of text overlap, and none sits under the readout. How much of a unit is visible is the part left inside every box that clips it, times its opacity; the first version looked at opacity alone, and a word rising from behind its box edge — eleven pixels of seventy-four uncovered on its first frame — read as a snap from nothing to everything.
- Frame: no visible text outside the stage.
- A whole word: on every frame at least one word is on screen in full.
- Snaps: nothing changes by a large step in one frame while it is visible — how much of it shows, where it is, what colour it is.
- At rest: with reduced motion emulated, the one frame each demo shows is identical to the frame before and the frame after.
Pointed at the page as it was deployed, it failed all ten entries. TYP-02
covered its second row for 59 frames and left the frame for 63, TYP-03 switched
colour in one frame, TYP-04 had pose over is, and every one of the ten had
stretches of up to 23 frames with nothing on screen, because each went away
between its right case and its wrong one.
The whole word rule is the one worth explaining, because the obvious rule came
first and was not enough. Making sure the frame never went fully black was easy —
stagger the exits and the entrances so there is always something — and the
sentences kept leaving and coming back anyway, in waves: in TYP-01 a fifth of
the sentence was on screen for half a second, twice a loop, and in TYP-04 the
most visible word was at 23 per cent for a frame. What the eye reads as "the sentence is gone" is not a
black frame, it is having nothing whole to read. So where a demo needs a line to
change, the line now hands over instead of leaving: two lines in one window in
TYP-01, two copies in one box per word in TYP-04. Where a demo is about how a
line arrives, and arriving needs it to be missing first, part of the sentence
stays: in TYP-05 and TYP-06, "Real UI," holds while the rest comes and goes.
Three smaller ones came out of the same pass. On a phone the readout, which is in
pixels, covers a sixth of the stage's height, and TYP-09's caption laid flat sat
on the frame counter for 130 frames out of 230 — it never sits lower than 34 px now.
And every demo's frame was read from performance.now() inside the animation
callback, which runs after the refresh by an amount that changes every time — 2.1
ms on median and up to 3.7, measured in headless Chrome with the typography on
screen. With 30 fps demos on a 60 Hz screen, when the boundary between two frames
falls near a refresh that delay decides which side it is on: 3.3 per cent of
frames stayed up for one refresh or three instead of two. The frame is read from
the callback's own timestamp now, which falls on the refresh, and the same count
is 0.7.
The last one is for whoever never sees the motion. With reduced motion on — an
accessibility setting plenty of phones have enabled — every demo shows a single
frame until the scrub is dragged, and that frame was 55 per cent of the way
through the loop for all of them. On the typography it landed in the middle of a
transition: in TYP-01 a line cut by the edge of the window, in TYP-05 and
TYP-06 words half faded, in TYP-02 the keyword still large under a readout
saying it was back to normal size. A demo can declare its frame at rest now, in
still, and every typography entry does; the bench checks it with reduced motion
emulated, so it also measures that the page honours the setting.
Its negative controls are five copies of the built page, each broken in one of
those ways — a keyword scaled from its centre, a line faded out while it gains
weight, a colour switched in one frame, the caption back at 4cqw, and a still
frame moved into the middle of a fade — and the script has to fail each and name
the entry.
Motion design is dense. The references this repo chases run 13 to 42 seconds and
fit more into them than there is in these 48. Going faster used to mean opening
every scene and rewriting a dozen constants — GRAB 78, DRAG_END 176,
CAM_SETTLE 132 — keeping them consistent with each other by eye. That is the
kind of work that goes wrong silently: miss one and the gesture comes apart with
every bench still green.
Now the tempo is the duration. Each scene declares its beats against a
reference duration, and durationInFrames scales all of them: halving it halves
every beat inside the scene rather than cutting the tail off.
But not everything scales, and that is the part that matters. There are two
kinds of number in a scene. Edit tempi scale — when the hand arrives, how long
the travel runs, when the camera settles: those are rhythm decisions, and rhythm
is exactly what you want to change. Perceptual thresholds do not. The four
frames between a click and its consequence are not rhythm, they are the window
in which the eye ties a gesture to its effect — click-gap.py measures that they
sit between 1 and 8, and at double speed they would be two, on the edge of
disappearing. The three frames the card lags behind the hand are the weight of
the object. The caret's fifteen-frame blink is a frequency, not a duration.
Scaling the second kind along with the first is how a faster scene becomes a
broken scene. at() scales; bare numbers do not; and every bare number in a
scene has the reason it stayed bare written next to it.
One of those got the sign wrong first time round, and only measuring caught it.
cps — the typing rate — is a speed, so it goes as the inverse of the factor:
half the duration needs twice the characters per second. Written as a
multiplication, a shorter scene got a slower typist, the send slid to 83 per
cent of the duration instead of 60, and beats.py found the field still full
where it expected the placeholder.
tempo.py proves the mechanism the only way it can be proved: if the beats
scale, frame f of the short render is frame f/k of the long one. Compared
time-normalised the two renders differ by 287 pixels; compared without
normalising, by 5379 — nineteen times worse. And the 287 is not slop: it is the
signature of the thresholds that deliberately stayed put. At zero they would
have scaled too. Its negative control is the same scene truncated instead of
retimed, which is exactly what lowering the duration produced before any of
this, and there the normalised comparison is the wrong one.
Every bench with a hardcoded frame number now follows the duration too —
beats.py, handoff-travel.py, contrast-floor.py — because a bench that
looks at frame 430 of a scene that is now 300 frames long is measuring a scene
that no longer exists. They all pass on both the film and the retimed fixtures,
and that is the real regression guard: retime a scene and the perceptual
measurements still hold.
Real UI, not a drawing of UI. Slabs of the actual product on inclined planes, with the product's own tokens, radii and system font stack. A mockup that is 3px off reads as a mockup.
Every measurement in this repo used to be taken on one slab: dark, 2400 by 1200,
in 16:9. slabPointOnScreen and centreOn had 1920 and 1080 written inside
them, so the maths a product film needs in 9:16 and 4:5 did not exist, and no
bench would have noticed, because no bench looked anywhere else.
video/src/kit/ is where the registry becomes usable for any product. It starts
with two pure modules, which Node reads directly the way the benches already read
topics/geometry.ts: stage.ts, the three stages, and rig.ts, with perspective, origin
and slab scale as parameters and slabPointOnScreen, centreOn, zoomForPush,
pushForZoom and pushForFill written against them. pushForFill exists
because push numbers do not travel between formats: the same push gives the same
magnification, and in a narrow frame the subject fills it much sooner, so the
fill is the decision and the push follows from it. topics/geometry.ts keeps every export
and calls the kit with the stage and rig of Topics. geometry-snapshot.mjs
photographs its geometry (38 constants, seven poses, the functions on sample
points) and the versions before and after the move are the same string.
The origin deserves a sentence, because the direction documents of the first product films got it wrong. They said the 46 per cent "is not a fraction of the frame but the result of the slab geometry and the pitch". It is a choice, written in the CSS and read back by the maths. What changes between formats is the slide that brings the subject onto it.
drift.py proves it on renders rather than on the catalogue page. It runs
CAM-06 as six specimens, in video/src/specimens/: the delivered Topics card,
and the card of a probe slab, in 16:9, 9:16 and 4:5. The probe slab is a product
that does not exist and imitates none. It is light where Topics is dark, vertical
where Topics is wide, at scale 1 where Topics is at 1.04, so a number copied from
Topics does not come out right on it. Each specimen slides the slab once with
centreOn and pushes until the subject fills 90 per cent of the frame, and a
magenta ring on the subject has to stay within 4 px of the origin on the first
and the last frame. The worst reading is 2.03 px, on Topics in 16:9, where the card
sits on fractional coordinates and is magnified three times; the probe slab stays
under 0.71. The negative controls have to fail in all six: once without the
slide, once with the slide worked out for 46 per cent while the CSS says 50. The
second is the subtle one. On the first frame the ring is exactly where it should
be, and it drifts 39.5 to 99.8 px only as the camera pushes in, which is
(0.50 − 0.46) × height × (zoom − 1), the figure the manifest predicts. A bench
that looked at one frame would pass it.
The bench had two defects of its own before it could be trusted. The first mask
turned the magenta white and then blacked out everything that was not white, so
the white surfaces of the probe slab stayed in the mask and the centroid landed
two hundred pixels from the ring: the bench failed the correct case, and against
a bench that fails everything the negative controls read as green. And on Topics
the ring was invisible, under the delivered card, which lives at zIndex 10.
The second piece of the kit is kit/Shot.tsx, and it is a deletion more than an
addition. The block that films a slab (the attenuated plane behind, the edge,
the slab, the light on the frame) was copied by hand into all six scenes, with
perspective 2600, origin "50% 46%", (1920 - SLAB_W) / 2, scale(1.04) and the
seven parallax numbers of the plane behind written into each copy, while the
benches measured the constants in topics/geometry.ts that no scene read. Now every scene
hands Shot a pose and its content, and the numbers that make the shot look like
Topics live once, in products/topics/material.ts. The stage comes from the
composition rather than from 1920 and 1080. The copies differed in exactly three
ways (a fade on the whole frame in UIMockup, a cursor inside the slab in
CardHandoff and PromptInput, and a light edge along the bottom of the slab
that PromptInput and BoardOrbit do not have), and Shot takes each one as a
prop. still-identity.sh renders the first, second, middle, second-to-last and
last frame of every scene from the previous commit and from the working tree:
thirty of thirty identical, with a repeat of one frame per scene to show the
instrument is repeatable, and a pose moved by one pixel makes it fail.
The third piece is kit/project.ts, which says where a point of the slab lands on
screen with the camera in any pose, tilted included. centreOn is exact only
with yaw and pitch at zero, and a bench that watches a moving camera, which is
what almost every product film has, needs to know where an element is at a
tilted pose rather than guess a crop by eye. It redoes the CSS chain of Shot
in arithmetic (scale, then rotateX, then rotateY, then the push, then the
container's perspective around the rig origin), and project-check.py compares
it with Chromium on 42 cases: two slabs, three stages, the seven poses of the
Topics chain in that stage's ratio, five points each, off-centre and off-axis on
purpose. The largest
error is 0.040 px. With the CSS origin set to 50% 50% instead of the rig's, the
error reaches 103.7 px and the check says so.
The four benches that worked out the geometry of Topics inside a node -e
snippet of their own (handoff-travel.py, focus-sharpness.sh,
fixture-screenshot.sh, contrast-floor.py) now ask manifest.mjs, which
computes it in one module per bench under video/src/products/topics/benches/
(node scripts/manifest.mjs bench <name> --ratio <r>).
no-product-literals.sh keeps it that way: a bench that reads video/src by
itself fails it, and pointed at the benches of the commit before this one it
names exactly those four.
The fourth piece takes the camera out of the scenes. Each scene used to compute
its own camera inside the component, five interpolate calls written by hand,
which meant that only the render could say what the pose was at frame 137, so
no bench could ask whether the camera reverses in motion or whether the slab
leaves an edge of the frame uncovered. kit/camera.ts makes the camera data: a
track is one curve per axis (from, to, a window, an easing) and poseAt reads it
with Remotion's own interpolate and Easing, called with the same arguments
the scenes used. The six tracks live in products/topics/tracks.ts, and the scenes
read them. still-identity.sh now takes the frames to compare, because the five
default ones miss the windows that matter: frames 20 to 81 of UIMockup, where
the slab slides in, and the frames either side of where PromptInput and
BoardOrbit settle. All of them are identical to the previous commit. The cursor
also takes the scene's frame now: it used to read its own clock, so in a scene
driven by progress the hand would have gone its own way.
Two benches that the catalogue named and nobody had written read those tracks.
chain-check.py is GIU-04: along the six scenes, in the order they join, no
axis changes direction inside a scene, every join is continuous, and a reversal
at a join is allowed only where the camera is still on both sides. It finds
seven, all at rest, with boundary velocities under a thousandth of the peak; with
the easing taken out of every track the same seven become reversals in motion
and it fails. fill-geom.py is CAM-01: from 20 per cent of each scene to its
last frame, the four corners of the frame have to fall inside the projected slab.
Its first run failed BoardOrbit, by up to 260 px from frame 30, and it is right:
the orbit exists to show the vertical edges of the slab, so the edges of the
frame show the background on purpose. The scene had declared fill anyway, and
the pixel bench had passed it. It does not declare it any more. The other five
scenes are covered on every frame with at least 5.4 px to spare, and
PromptInput sits at exactly zero, because its final pose puts the bottom edge
of the slab on the bottom edge of the frame by construction. Pushed back by 1500,
all five fail.
scripts/manifest.mjs is where a bench asks what it should find. It prints, from
the same modules that produce the render, the variants, their frames, the origin,
where the subject would sit without compensation, and the tolerance. If the
uncompensated point fell on the origin, the negative could not fail, and
drift.py exits 2 and says so.
Everything that belongs to Topics now sits in one folder, video/src/products/topics/:
the slab geometry (geometry.ts, which was primitives/slab.ts), the tokens
(tokens.ts, which was theme.ts), the furniture, the material, the tracks, the
bench geometry (benches/) and the six scenes. What stays outside is what
another product can use as it is: kit/, the cursor, the frame-locked helpers.
The move changed no number. geometry-snapshot.mjs gives the same 52 values as
before the kit existed, and every still is identical to main.
Every scene now exists in 16:9, 9:16 and 4:5. catalog.json lists the ratios,
Root.tsx registers a composition per scene and per ratio (CardFocus,
CardFocus-9x16, CardFocus-4x5), and a scene picks its camera from the size
of its own composition. The 16:9 keeps the ids, the slugs and every pixel: the
published videos are identical to main. The page still shows 16:9; the other
two ratios are in the run artifacts until someone has looked at them.
The portrait poses are derived, not drawn. A 2:1 slab in a 9:16 frame shows a
vertical strip of the board, so each pose keeps the yaw and pitch of 16:9 and
the push ratio between one pose and the next, raises the zoom only as far as
covering the frame needs, and keeps its subject in frame: the column in
UIMockup, the delivered card in CardHandoff, the tag and title of that card
in CardFocus, the thread heading and the prompt in PromptInput. In 9:16 the
two columns the card flies between do not fit together, so the camera follows
the card during the drag. The numbers live in products/topics/poses.ts.
Measured on the tracks: the slab covers the frame on every frame from 20 per
cent of each scene with at least 2 px to spare (exactly 0 on PromptInput, by
construction, as in 16:9), and the camera never reverses while it moves. In 9:16
a message 1180 px wide ran off the frame mid-line, so the thread now wraps at the
visible edge of the slab: 1180 in 16:9, 825 in 4:5, 468 in 9:16.
kit/film.ts and kit/SceneWindow.tsx put the same scenes one after another in
a single composition, TopicsFilm, which is how the product films will be
built. Inside a <Sequence> a scene reads its own frame and its own duration,
so nothing in the scenes changed, and film-identity.sh checks it: the first,
middle and last frame of every window are the same PNG as the scene on its own,
and a window started one frame late is caught in every scene.
The benches no longer list their own negatives in the workflow. Each one declares
its checks in scripts/checks/<bench>.mjs, the manifest resolves them for a ratio
(node scripts/manifest.mjs checks --ratio 9x16), expect.sh runs every line
against its expected exit code without stopping at the first failure, and
bench-coverage.py fails any bench that passes something without a case that it
fails. The bench geometry for a ratio comes from
video/src/products/topics/benches/<bench>.ts; the scripts only read it through
the manifest.
Moving the benches to portrait found faults that 16:9 had been hiding, and each one was a bench that could pass without measuring:
click-gapcounted changed pixels in the whole frame. In 9:16 and 4:5 the send button is out of frame, the frames after the click were still, the quiet was zero, and it passed a two-frame gap whatever the real one was. It now looks at the composer text and the tail of the thread, projected per ratio: f271 and f276 in all three, and the fused, slowed and missing clicks exit 1.handoff-travelmeasured on screen, where in 9:16 it measured the pan, and a hard cut passed because half its samples fell before the grab. It now rectifies every sample of the drag onto the slab with that frame's camera. The first version of that let a freeze frame "cross" 208 px, because rectifying the same image with two cameras moves it; the column headers now check the rectification (zero changed pixels on real renders, 40 to 150 on a freeze). The card crosses 170, 195 and 183 px of the slab.focus-sharpness, fed 16:9 geometry on a portrait render, cropped outside the image and passed with 449159000x. It now checks the frame size and every crop, and reads sharpness at half a pixel: real renders 2.81x, 1.79x and 2.02x against 1.09x, 1.14x and 1.06x for the upscaled screenshot, threshold 1.35.contrast-floorexited 1 on an empty crop, blaming the attenuation floor for a crop that had landed on background. It exits 3 now, and the fixture is one still instead of 450 frames. 4.25:1, 4.54:1 and 4.51:1.tempocompared the top 480x270 bytes of each frame, which in 9:16 is the top third. On the whole frame the correct 9:16 render read like a trimmed one, because the card's three-frame lag (which does not scale, on purpose) weighs more on a card twice the size. The scene now declares that window: outside it the fast render matches exactly, inside it the residual has to be there.seamnever exited 1: a broken join exited 2, "measurement useless". A reversed pair exits 1 now in every ratio. The first CI run of this block then showed thatseamandrest-pointdid not count the same way on the Mac and in CI:compare -fuzzon ImageMagick 6 found the reversed pair almost identical and failed the start ofBoardOrbitin 9:16, which ImageMagick 7 passed. Both now count with ffmpeg (_pixeldiff.sh), which gives the same number in both places.beatsgets its frames from the scene's timeline and leaves out the words the thread already showed ("solo" was in both). A freeze frame and a covered thread exit 1.
Locally the checks take about three minutes per ratio on renders that already exist. In CI each ratio is its own job (render, fixtures, checks, coverage): the first run took 14, 15 and 20 minutes against a limit of 25, with the page checks in a fourth job of 5 minutes from the start, and the deploy waiting for all of them. The example film is three more jobs, one per ratio, which the deploy does not wait for: its checks and its 1350-frame render took 11.1, 10.4 and 9.3 minutes. A whole run is 16 minutes of wall clock, because the seven jobs go together.
video/ |
The Remotion project. The product-independent kit in video/src/kit/, one folder per product in video/src/products/ (topics/ holds the slab, its tokens, tracks, bench geometry and the six scenes; probe/ the synthetic slab), shared primitives such as the cursor and the frame-locked helpers in video/src/primitives/, the catalogue in video/src/scenes/catalog.json, bench specimens in video/src/specimens/ |
scripts/ |
The measurements, the review page, the showcase build, and catalog.mjs, which is how shell and CI read catalog.json without a compiler. scripts/checks/ holds each bench's checks and negatives, which manifest.mjs checks resolves per ratio and expect.sh runs. See below |
showcase/ |
The public pages. index.template.html and grammatica.html are committed; the scene section and the renders are not, showcase-build.sh generates the first from catalog.json and copies the second into showcase/dist/ |
CATALOG.md |
The surveyed libraries with verified licenses, the 81 templates grouped, the market gap |
ref/ |
Reference commercials and their contact sheets. Not in git, see below |
A scene looked at on its own always seems fine. What shows the gap is the comparison, so every check here puts our render and the reference through the same treatment and prints both columns. A number with nothing beside it is decoration.
npx remotion render PromptInput out/prompt-input.mp4 # from video/
./scripts/contact-sheet.sh out/prompt-input-vs-ref.png # composition, frozen
./scripts/review-page.sh # composition + rhythm, moving
./scripts/beats.py <scene.mp4> --ratio R # are all four beats on screen
./scripts/fill-measure.sh video/out/prompt-input.mp4 # border readout only, it cannot fail (see below)
./scripts/legibility.sh # down to what size it reads
./scripts/framelocked-verdict.sh [Composition ...] # is it really frame-locked
./scripts/seam.sh [A.mp4] [B.mp4] # is the join really cutless
./scripts/handoff-travel.py <scene.mp4> --ratio R # does the card actually cross, measured on the slab
./scripts/focus-sharpness.sh <scene.mp4> --ratio R # does the text survive the push-in
./scripts/rest-point.sh [--ratio R] [scene.mp4] # is the scene still at both edges
./scripts/click-gap.py <scene.mp4> --ratio R # does the UI answer the click, or fire with it
./scripts/fixture-screenshot.sh <src> <dst> --ratio R # build the scene focus-sharpness must fail
./scripts/film-identity.sh [--ratio R] [--offset N] # is a film frame the same frame as the scene on its own
./scripts/demo-check.py [page.html] # do the catalogue demos still show their thesis
./scripts/loop-close.py [page.html] # does every demo loop close, or tear every pass
./scripts/type-check.py [page.html] # does the type cover, leave the frame, vanish, snap, or stop mid-move
./scripts/contrast-floor.py <scene.mp4|frame.png> --ratio R # is the attenuated content still readable
./scripts/drift.py [--ratio R] [--props JSON] [--must-fail] # does the subject stay on the origin, on two slabs and three formats
./scripts/project-check.py [--origin-mismatch] # does the kit's projection agree with Chromium
./scripts/chain-check.py [--ratio R] [--linear] [--must-fail] # does the camera ever reverse while it moves
./scripts/fill-geom.py [--ratio R] [--push-offset N] [--must-fail] # does the slab cover the four edges of the frame
./scripts/no-product-literals.sh [scripts-dir] # does any bench read product geometry by itself
node scripts/manifest.mjs bench <name> --ratio R # what a bench must find, from the product
node scripts/manifest.mjs checks --ratio R > checks.tsv # every bench and its negatives for a ratio
./scripts/expect.sh checks.tsv report.json # run them all, each against its expected exit code
./scripts/bench-coverage.py report.json # does every bench have a negative that fails
node scripts/geometry-snapshot.mjs [geometry.ts] # the geometry as a string, to prove a refactor left it alone
./scripts/tempo.py long.mp4 short.mp4 [--percettive a-b] # does shortening a scene retime it or just trim it
./scripts/fixture-tempo.sh [--ratio R] # render the retimed fixtures of a ratio
./scripts/fixture-trim.sh <scene> <fast> <out> # build the trimmed scene tempo.py must fail
./scripts/fixture-attenuation.sh <comp> <frame> <out.png> # build the frame contrast-floor must fail
./scripts/showcase-build.sh # assemble showcase/dist for deploy
node scripts/catalog.mjs render [--ratio R] # the render command for every scene
node scripts/catalog.mjs measures # the benches the catalogue impliescatalog.mjs is not a check, it is how everything that is not TypeScript reads
catalog.json: the workflow gets its render and bench commands from it, and
showcase-build.sh gets the scene section of the page. Run render and pipe it
to a file to redo every scene from scratch — not straight into sh, because a
bench that reads standard input will eat the lines it has not run yet.
beats.py is the one that earns its keep. prompt-input promises four beats:
the cursor arrives, types, sends, and the answer streams in. For a full day the
render delivered three. The thread is anchored to the bottom and ran to y=922,
the composer is opaque and starts at y=838, so the newest message sat
underneath it: the response was assembling itself word by word where nobody
could see it. Every type check passed the whole time. Nothing catches that
except reading the finished frame, which is what this script does, and it
requires the word count to grow across the streaming window rather than
merely be non-zero.
contact-sheet.sh and review-page.sh are the same judgement on two clocks.
The contact sheet compares composition, which is a property of a still. The
page compares rhythm, which only exists in motion: the uneven typing, the pause
before send, the pace of the streaming response. A strip of frames cannot show
any of that.
fill-measure.sh and legibility.sh are the two defects that sank the first
scene, turned into numbers. Text survives downscaling at least as well as the
reference, and better below 640px wide.
The fill number, though, stopped meaning anything, and for a while nobody
noticed because it kept saying 20 of 20. It counts a border strip as alive when
its mean luminance is above 2, which separated a slab from the black background
of the retired OrbitLoop. The scenes now sit on the app's own background, whose
luminance is around 20, so every strip is alive whatever is in it. Measured: the
first render of ui-mockup shrunk to 60 per cent, with the real background
colour around it, reads 20 of 20. Texture does not rescue it either: the ratio
between the local variance of a border strip and of the centre falls to 0.014 on
card-release with the slab in frame and rises to 0.043 on board-orbit, where
the borders show the background on purpose, because the attenuated plane behind
the slab is the board drawn a second time and any pixel measure reads it as
content. So the script is kept as a readout and is out of the CI measurements.
Whether a slab covers the frame is a question for the geometry, and
fill-geom.py asks it on the camera tracks (see the kit, below).
framelocked-verdict.sh had the opposite problem. It measured the right thing
and then exited 0 whatever it found, so a real divergence would have scrolled past
in a green log. It exits 1 now, CI checks that it fails FrameLockedProbeRandom
(the same probe with a Math.random inside), and it also runs on PromptInput
at frames 150, 175 and 200, in the middle of the typing, which is where an
unseeded random would break TXT-01. The random offset in that probe spans 400
pixels and not 40: Chrome snaps the position to whole pixels, and at 40 two
renders landed on the same pixel on the first try.
OrbitLoop is what UIMockup replaced, and it is named here only as the
baseline the measurements are read against. It is no longer a composition.
seam.sh turns the no-cuts rule into a number. It pulls the last frame of
UIMockup and the first frame of CardHandoff and counts differing pixels.
What makes the reading honest is the control beside it: the same last frame
against a frame from the middle of CardHandoff, which is a deliberate cut.
Measured on this machine: 552 pixels for the join, 18,019 for the cut. On the
CI runner the same two frames give 3,921 and 160,595, because a different
encoder puts different noise into both. The absolute counts are not portable and
the script does not compare them across machines; what it checks is the ratio
between the join and its control, which came out 32× locally and 41× on CI. If
the two ever came out close the script exits 2 and says the measurement separates
nothing, because a check that cannot fail is decoration. It does not demand
zero either: two frames survive two independent H.264 encodes, so the bar is a
fraction of pixels past a perceptual tolerance, not byte equality.
focus-sharpness.sh is the only bench here whose first version was thrown away
after it had already gone green. It compared the card crop against a blurred copy
of itself, which sounds reasonable and measures nothing: that ratio stays high
even when the source is already mush, because it is relative to itself. Put
against a scene built by magnifying a still, exactly the defect it exists to
catch, it scored 4.14x and passed it. The version that ships builds the
counterfactual from the render's own first frame instead, so both readings are
the same content at the same pixel size and the only remaining difference is the
scale the pixels were rasterised at. Measured: 2.09x on the real render, 1.03x on
the fixture, threshold at the geometric mean of the two.
rest-point.sh turns the rest-point rule into a number, and finding the right
number took two wrong ones. Consecutive frames measure nothing here: these
cameras move fractions of a degree per frame, and mid-way through a scene where
somebody is typing, well under a tenth of a per cent of pixels change, which is
barely more than the edges. Five frames apart the signal separates. Its first control was wrong too:
CardHandoff is documented as the scene that does not start at rest, and that is
true of its pose and false of its velocity — it eases in and out, so it
starts still like everything else. The control that works is the one seam.sh
already uses: a pair taken from the middle of the same scene. It asserts only on scenes that declare restAtEdges in
catalog.json, and what counts as still is a ratio rather than a number: the
same renders read five to ten times higher on Linux than on macOS, so a threshold
tuned on one platform fails three scenes out of five on the other. An earlier
version appeared to pass everything only because ffmpeg was consuming the loop's
input and it was measuring a single scene.
A bench for the pause before the send was written and then deleted, which is
worth recording because the reason generalises. Measuring stillness by
frame-to-frame pixel difference cannot separate a deliberate hold from a slow
settle: these cameras move fractions of a degree per frame, so a threshold low
enough to call the hold "still" also calls the settle still. Pointed at
CardHandoff, which has no composer and never pauses, it reported a confident
40-frame pause. It promoted its own worst case, the same way the first
focus-sharpness.sh did, and unlike that one it could not be repaired by
changing the control. Measuring this needs the pointer's position, not the whole
frame. On the catalogue page the pointer does have a position, so
demo-check.py measures the pause there: 20 frames between the hand landing on
the button and the button going down, against 0 in the half of the loop that
shows the same press with no wait at all. On the render it is still unmeasured.
demo-check.py measures the catalogue page rather than a render. Every entry on
grammatica.html states a thesis and runs a demonstration next to it, and the
difference between a demonstration that shows its thesis and one that only sits
there is almost always temporal: two events five frames apart instead of on the
same frame, a delay that follows distance instead of index. Screenshots cannot
see that, so the script drives each demonstration frame by frame in a headless
browser and reads the elements' own rectangles and computed styles. It does not
read the on-screen readout: that line is the demonstration's own claim, and
having a claim confirm itself is not a measurement.
It found two entries that were not demonstrating anything. CAM-02 promised that
the layers come apart under a truck, and at the wide pose the repo's z offsets
buy four pixels of shear over the whole move — real, and invisible. The truck now
runs pushed in at 780, where the same offsets, unexaggerated, are worth nineteen,
and the demonstration measures them on the rendering instead of asserting them.
CHR-02 promised a stagger that follows distance rather than index, and
demonstrated it by removing a card from a column so that its one neighbour moved
up: in a single column the two orderings are the same ordering, so the entry
could not have shown its own point. It now settles the whole board outward from
the change, where the correlation between delay and distance is 1.00 against 0.43
for the same spread staggered by reading order.
Its own negative control is a copy of the built page with one line changed, so
that CHR-03 fires its three events on separate frames in both halves and the
contrast disappears. The script has to exit non-zero on that copy and name the
entry, or its green means only that it reached the end.
contrast-floor.py measures the number CAM-05 had been asserting. The
catalogue says the attenuation floor is 0.62 because below it the attenuated
content falls under 3:1 once rendered — and nobody had ever rendered it and
looked. It now reads the WCAG ratio between the attenuated thread heading and its
background on a real frame, with the crop projected out of topics/geometry.ts instead of
picked by eye, and gets 4.17:1 here — 3.84:1 in CI, because Linux renders the
same text with different fonts. Same verdict, and a reminder of why the number
is a floor and not an equality. The same scene rendered at 0.25, which is what
attnFloor exists for, collapses to 1.71:1 and the bench fails it.
It took two wrong versions to get there. The first measured the composer's
placeholder and failed the scene at 1.76:1 — right reading, wrong subject:
"Chiedi qualcosa" is deliberately faint and sits at 2.93:1 with no attenuation
at all, so the bench was failing CAM-05 for a decision about the input field.
A placeholder is not content.
The second measured real content in a place that moves. Messages have natural
heights and the thread is anchored to the bottom, so the instant the font
metrics differ — which is exactly what happens between this machine and the
Linux in CI — everything shifts and a fixed crop lands on empty background. It
exited 3 there, "could not measure", which was at least the honest answer rather
than a verdict about a scene that was fine. The heading it reads instead sits at
THREAD_TOP, which is a constant, so its position is arithmetic. The same
lesson topics/geometry.ts already records about card heights, learned again one floor
down.
How it reads matters too: background is the modal value of the crop, foreground is the mean of the glyph core, the pixels above sixty per cent of the way from background to maximum. Percentiles were not enough — on a crop where text is a small fraction of the pixels, the 97th percentile is still measuring background, and the same scene read 2.59:1 or 4.17:1 depending on how much text happened to fall inside the rectangle.
beats.py was not running anywhere, and had not been for months. It is not in
the workflow's measurement step, and on the machine these scenes are written on
tesseract was never installed — so every OCR read came back empty, every count
came back zero, and the verdict came back "the answer is never visible". A
diagnosis about the scene for a missing tool, which is the same shape as the
handoff-travel.py failure below and the reason that one is described at
length. It now refuses to give a verdict without the OCR: exit 3 means it could
not measure, which is a different thing from exit 1, which means it measured and
the scene is wrong. It runs in CI, and CI proves both halves — that it passes
with tesseract present, and that it exits 3 with only tesseract taken away.
Taking the whole PATH away instead would have proved nothing: the script would
have died at 127 for want of a shell.
click-gap.py was repaired after it reported a click, with confidence, ninety
frames before the real one. Its rule was "the hit is the first frame whose
changed-pixel count is at least five times the window median", which held while
the camera drifted through the whole scene. Once the camera settles before the
performance — which is how it should be shot; nobody moves the camera while
somebody is typing — half the frames in the window change nothing at all, the
median is zero, and five times zero is any flicker at all. It promoted the first
typed character. The baseline is the ninetieth percentile now: still a ratio
inside the window, which is the rule every threshold here follows, but it does
not collapse on a locked-off shot. On a window with no motion at all it reports
no click rather than inventing one. All five negative controls still fail.
rest-point.sh said one thing and did another. It prints, correctly, that its
verdict applies only to scenes declaring restAtEdges and that the others are
measured rather than judged — and then exited 2 on a scene that declared
nothing, because PromptInput holds the camera still through the performance,
so its middle control sample is as still as its edges and the instrument cannot
tell a locked shot from a freeze frame. That check now runs only against scenes
that promised something. A file passed as an argument still counts as a promise,
which is what keeps the freeze-frame control failing.
handoff-travel.py needed a crop it did not need before, and this is the cost of
putting the assistant on the slab. It isolates the travelling card by diffing
whole frames, which worked while the bottom half of the slab was empty. With a
thread down there — static relative to the slab, but moving with the camera like
everything else — thousands of high-contrast text pixels drag the centroid down.
The reading fell from 154px to 83 and invented a backward step: the scene blamed
for a change in the instrument's surroundings. It now diffs only the board's half
of the frame, and where that half ends is read from topics/geometry.ts rather than picked
by eye, so it follows if the assistant moves.
handoff-travel.py had never run on macOS. Its centroid step was a heredoc
inside a process substitution, which bash 3.2 cannot parse, so the script died
before the first sample and printed "the card does not travel": a diagnosis about
the scene for a fault in the equipment. CI runs bash 5 and went green, which is
why it stayed invisible from one side and total from the other. Since block 6
the whole bench is Python, and the question is gone with the heredoc.
handoff-travel.py checks the thing none of the others look at: whether the
gesture happens. A scene where the card never moves passes seam.sh and
fill-measure.sh with full marks, because a freeze frame has a perfect join and
live edges. So this one tracks the centroid of changed pixels across the travel
window and requires the horizontal motion to be monotonic and to cover half a
column. Verified against both failure modes: it exits 1 on ui-mockup (a scene
with no travelling card) and on a still frame looped into a video, and 0 on
card-handoff.
cd video
npm install
npm run dev # Remotion studio
npx remotion render <CompositionId> out/<name>.mp4showcase/index.template.html is the public page, and it is a template: the
scene section is generated from catalog.json by showcase-build.sh, so a
render that exists and a page that shows it cannot drift apart. Open
showcase/dist/index.html after a build to see it. It carries the six scenes
playing
(UIMockup, CardHandoff, CardFocus, CardRelease, PromptInput,
BoardOrbit, in the order they join), the four rules, the
license note. Beside it, showcase/grammatica.html is the catalogue: thirty-six
movements with a live demo each, the numbers they start from, and the bench that
can fail them. The ones without a bench that runs and can fail are printed in grey,
and the page counts them from its own data: the hand-written count once said
thirty-two when it was twenty-nine. The demos are browser re-creations of the slab, not the renders,
which is the point of keeping them next to the renders rather than instead of
them. Neither page carries a build step or a dependency, so what you open
locally is what ships.
Live at https://scenes.armonia.io.
Pushing to main publishes it. .github/workflows/showcase.yml renders the
scenes from source, runs the measurements, and deploys only if they all pass.
This exists because the page once spent days showing OrbitLoop, a scene that
had already been deleted: the code was current, the .mp4 files on somebody's
laptop were not, and a hand-run wrangler pages deploy had no way to know. Now
the videos cannot be older than the commit.
The deploy step needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as repo
secrets. Without them the workflow still renders, still measures, and still
uploads the videos as run artifacts, so it is useful on day one and a CI nobody
has to switch on:
gh secret set CLOUDFLARE_API_TOKEN --repo armonia/scenes # Pages:Edit
gh secret set CLOUDFLARE_ACCOUNT_ID --repo armonia/scenes # npx wrangler whoamiMake the token at
dash.cloudflare.com/profile/api-tokens
with Cloudflare Pages:Edit on this account and nothing else. The credential
wrangler login leaves on a laptop is not a substitute: it is an OAuth token
that expires within the hour and carries account-wide scope, so pasting it into
a repo secret would give a public repo's CI broad access to the account and stop
working before anyone noticed. A scoped token is the smaller and the more
durable option at the same time.
The Pages project is still called remotion-scenes while the repo is scenes.
Cloudflare cannot rename a project, and scenes.armonia.io is a CNAME to that
project's hostname: creating a correctly named project and moving the domain
onto it took the site down with 522s until the domain was moved back. The
internal name is invisible from outside, the outage was not.
To publish by hand instead:
./scripts/showcase-build.sh
npx wrangler pages deploy showcase/dist --project-name remotion-scenesRe-render before building whenever a scene changes. The script refuses to assemble a page whose videos are missing, but it cannot tell a stale render from a fresh one, which is the whole argument for letting CI do it.
The order matters, and step 4 is the one people skip.
- Decide the pose it enters from and the pose it leaves in, and put both in
the product's
geometry.ts,products/topics/geometry.tsfor Topics.CARD_HANDOFF_END_POSEspent three scenes as three literals insideCardHandoff.tsx, which was fine exactly as long as nothing came after it. - Write it in
video/src/products/<product>/scenes/. Takeprogress?: numberand derive everything else fromuseCurrentFrame(); read the ratio withstageFor(width, height)fromuseVideoConfig()and take the poses of that ratio. If you reach forDate.now(),Math.random()or a CSS keyframe, the scene is no longer reproducible andframelocked-verdict.shwill say so. - Reuse the kit and the product folder.
kit/Shot.tsxdraws the slab,kit/camera.tsturns a track into a pose; the product'sgeometry.tsholds the poses,tracks.tsthe camera,SlabChrome.tsxthe app furniture. A scene that redraws its own sidebar can only stay aligned with the others by hand, and it will not. - Add one entry to
video/src/scenes/catalog.json— id, slug, duration, the blurb for the page, andseamAfterif it follows another scene — then the one line inCOMPONENTSinRoot.tsxthat binds the id to the import. That entry is what the render step, the generic benches and the showcase page all read, in every ratio the catalogue lists: there is no second list to keep in sync. Leave out theCOMPONENTSline and the project refuses to load and says which id is unbound, which is the one failure mode a JSON file cannot cover on its own. - Give it a check that can fail. Every scene here has one bench that
fails when the scene's own promise is broken:
beats.pyfor the four beats,handoff-travel.pyfor the card crossing. Declare its checks inscripts/checks/<bench>.mjs, write the negative first, and runexpect.shon the three ratios before trusting the pass;bench-coverage.pywill refuse a bench without one.npm run lintproves nothing about a video. - Push. CI renders, measures, deploys — the scene is on the site without any of those three files being touched. Rendering only happens when it can change something: the renders are cached under a key made of the scene sources, the dependencies and the benches, so a push that only touches the page or the README reuses the videos that already passed and goes straight to the deploy. Change a scene or a bench and the full run comes back.
If your scene is meant to follow another without a cut, read the previous
scene's end pose from the product's geometry.ts rather than retyping the
numbers, and name that scene in seamAfter. Two copies of the same pose stay equal exactly as long as nobody
edits one of them.
A commercial is one shot: 45 seconds, 1350 frames, in 16:9, 9:16 and 4:5. From the product it needs three things and nothing else: the slab (the product's UI in DOM, cut into the parts the camera separates), a film file (camera keys per ratio, what happens on the slab frame by frame, the sentences) and the data (texts, numbers, states). The motion comes from the kit, with the numbers of the scripts already inside; the checks run by themselves in the three ratios.
video/src/products/demo/ is the proof. Registro is an invented request desk,
light like the panels of Cifra and Zeno, built only from kit pieces; nothing in it
comes from either product. DemoFilm, DemoFilm-9x16 and DemoFilm-4x5 are in
the project, and the film job renders and checks them on every push.
| File | What it holds |
|---|---|
geometry.ts |
Slab size and rig, every rectangle of the UI as arithmetic (no natural heights, so a bench knows where things are without rendering), the camera subjects, and the camera keys per ratio |
timeline.ts |
stateAt(frame): what the slab shows at a frame, each beat computed by a kit function. cuesFor(ratio): the sentences, with the line breaks of each format |
Slab.tsx |
The UI drawn by parts (header, rail, document, panel, active block), so the film can put each at its own depth |
DemoFilm.tsx |
The assembly on the kit's Film: material, layers, typography, typeface, and the two props the benches use (solo, guasto) |
rules.ts |
The directing rules computed on the data, and one broken copy of the film per rule |
typeSamples.ts |
The frames at which the typography is measured, with the layer and the role of each |
products/topics/benches/film-demo.ts |
What the manifest gives the benches: composition, rules, samples |
scripts/checks/film-demo.mjs |
The checks, each rule with the negative that must trip it |
A new film copies the folder and rewrites those files. The kit does not change.
| Grammar | Kit | Built-in numbers |
|---|---|---|
| CAM-06, GIU-04 | filmCamera.ts: one track for the whole film from keys per ratio, monotone cubic, still at both ends; checkFilmCamera reports reversals |
Poses from centreOn and pushForZoom, never by eye |
| CAM-02 | Shot layers: sibling planes, each at its depth and clipped to its rectangle (clip: false for type) |
Header 6, rail 10, panel 18, document 26, active block 40 |
| CAM-05 | Opacity of the parts while one of them speaks; FilmShot.dim for the close, a veil between the shot and the typography |
0.62 while reading, 0.42 at the close |
| MAT-01, MAT-03 | ShotMaterial.edge, ShotMaterial.backdrop |
Edge 30 deep with 6 px overhang, contact shadow 90/180 at 75%; backdrop at 0.45, blur 14 |
| MAT-02 | Shot light with screenLight: the screen's light folded onto the floor under the slab |
Takes the UI colour, turns yellow on approval |
| CHR-01 | choreo.ts handoffAt, handoffOrder |
Gap at +30, flight +40 to +126, origin closes at +56 |
| CHR-02, CHR-03 | staggerByDistance, chain, chainProblems |
22 frames per 1400 px; 2 to 40 frames between links |
| TXT-04 | insertAt |
The gap opens 18 frames before the row lands |
| TXT-01, TXT-05, TXT-03, TXT-02 | text.ts: seeded typing plans, erasing back to a word boundary, settleAt, streamedWords |
Erasing at 1.6 characters per frame; overshoot 7.5% by default |
| CUR-01, CUR-02, CUR-03 | cursorArc.ts cursorAt, hesitationProblems; Cursor takes at |
Travel 78, settle 12, overshoot 7.5%; 10 to 25 frames before a press |
| TYP-01 to TYP-05 | type.ts (cues, wordStates, dwellProblems, cueProblems) and TypeLine |
Word 26 frames, stagger 3.4, swap 24, accent 24, key 44, travel 160%; at most 15.5 characters per second of net dwell |
| TYP-09 | Companion, anchored at the bottom or at the top |
1.5cqw bottom left in 16:9, 2.4cqw anchored top in portrait, never under 34 px from an edge |
| TYP-10 | A TypeLine inside a layer on the slab's plane |
|
| The close | Lockup: the wordmark, which the product brings as a node, over the call to action and the domain, rising from the mask |
The scripts put it at f1300, with the claim already still |
| Typeface | font.ts useFontFile: the render waits for the file and fails without it |
- List what the product really does, on the product's own repository, before writing a frame. The script revisions of Cifra and Zeno both had to change copy that the product does not say ("PRV-" numbering, "locali" for languages).
- The slab. Draw the UI by parts, every rectangle computed.
- The subjects, then the camera. One subject per scene, ordered so that the
camera never has to come back on any axis: left to right, top to bottom. Keys
per ratio from
centreOn../scripts/film-rules.py --ratio Ranswers in a second, so run it while moving keys. - The beats. Each one from a kit function with its numbers, in
timeline.ts. - The sentences per ratio. Same words and frames, different line breaks. A key word sits alone on its row, or at the end of a short one.
- The typeface from a file, with
useFontFile. - The rules module, the samples, the checks. One broken copy per rule; the checks name the rule that has to catch it.
- Check the three ratios, then render:
./scripts/film-type.py --ratio Rrenders only the sampled frames.
Every item here looked fine in the frame someone had checked by eye:
- The ground under the glass sentences was tuned on 16:9. In 9:16 the sentence sits higher, above the platform controls, and read at 2.8:1. The ground now rises with the sentence: 6.3 to 9:1.
- The swap left a hole. A word cell held both copies and was as wide as the wider one, so after "una" became "un" a letter's width stayed between "un" and "lavoro.". The cell now goes from one width to the other during the swap, with no measuring in JavaScript: two invisible copies side by side, the old one's size going to zero and the new one's going to full, and text width is proportional to size.
- The mask reached into the next row. With 0.28em above and below every row, the word leaving the second row crossed the baseline of the first. Between rows the mask is now 0.06em, enough for descenders at a line height of 1.08.
- The close dimmed the UI and not the slab. The parts went to 0.42 opacity
over a slab background that stayed light, and under "Richieste che" one tile
measured 1.53:1. The close now veils the whole shot (
dim), and the 16:9 last key comes down 108 slab units so the slab ends above the sentence. - The average hid the caption. Measured over the whole caption, contrast was 3.34:1; its last three characters were white on the white slab, 1.03:1. Contrast is now the worst 48 px tile, with 3:1 for sentences and 4.5:1 for captions.
- The typeface depended on the machine. A system stack renders Helvetica Neue on a Mac and a metric clone of Arial on Linux, so a sentence 36 px from the edge on one can leave the frame on the other. The film loads Inter from a file.
- The mask depended on the film remembering.
film-type.pyfinds the letters in a render of the typography alone, and each film used to build that pass with its own transparent material. The first film written outside this repository forgot, and its mask held the whole slab.Filmnow takessoloand removes the slab, edge, backdrop, light and veil itself, and a mask that lights more than 30% of the frame exits 3: the instrument is not looking at letters.
The films of real products live in a private repository, with the brand
typeface and the product's UI. This was tried end to end on a separate Remotion
project that imports the kit and runs these benches on its own film, in the
three ratios, with expect.sh and bench-coverage.py green. What it needs:
- This repository as a git submodule, for example at
vendor/scenes, not as an npm dependency. The benches load the film's pure modules with Node's type stripping, and Node refuses to strip types undernode_modules. The bundler does not care: Remotion's Rspack rule compiles any.tsfile it reaches. - One copy of React and Remotion. Install them in the film project only, at
the versions of
video/package.json, and do not runnpm ciinside the submodule: the kit then resolvesremotionfrom the film project, and there is a single instance. - The same
tsconfig.jsonoptions asvideo/,allowImportingTsExtensionsabove all: the pure modules import each other with.tsextensions so that Node can load them. - The typeface in the film project's
public/, loaded withuseFontFile.staticFileresolves against the project being bundled, so the brand font never has to be in this repository. - A bench module and a checks module in the film project. The bench module
exports
geometry(ratio, { guasto })and returns the composition, the rules and the samples, asproducts/topics/benches/film-demo.tsdoes. The checks module builds the same lines asscripts/checks/film-demo.mjs, with absolute paths. Then, from the film project:
node vendor/scenes/scripts/manifest.mjs checks --ratio 9x16 --moduli checks > checks.tsv
vendor/scenes/scripts/expect.sh checks.tsv report.json
vendor/scenes/scripts/bench-coverage.py report.jsonThe scripts take the film from outside: film-rules.py and film-type.py
accept --film path/to/bench.ts, film-type.py bundles the project given with
--progetto, and framelocked-verdict.sh bundles $PROGETTO. Coverage works the
same way there: the first typography negative of the test film doubled the type
size, which in 9:16 still fitted the frame, and bench-coverage.py refused the
report until the negative really failed.
Our code is MIT (see LICENSE). That covers everything under video/src,
written from scratch.
Remotion is not MIT. It carries its own license, and a company above a headcount threshold needs a paid company licence to render with it. Nothing in the MIT grant above covers that. It is between you and Remotion, so check remotion.dev/license before rendering commercially.
Inter is not MIT either. The example film's typeface,
video/public/fonts/InterVariable.woff2 (Inter 4.001, © 2016 The Inter Project
Authors), is under the SIL Open Font License 1.1, whose full text sits next to it
in OFL.txt. The films of real products load their own typeface the same way
from their own repository: the brand fonts never enter this one.
Of the scene libraries surveyed in CATALOG.md, only
Curvable/motion carries a license (MIT).
The other two carry none, which means all rights reserved rather than public
domain, so no code is taken from them. Copyright protects the code, not the
effect. Studying what an effect does and then writing it yourself is the whole
method here.
Four product commercials pulled with yt-dlp, plus the keyframe contact sheets
made from them. They are somebody else's copyrighted work, kept as study
material. Verification happens on the render, with our contact sheet set beside
the reference's, so the reference has to be on disk. Fetch it again with:
mkdir -p ref && cd ref
for id in 7gZBxBTapDQ ovxL42LkKNg _gBF27M7NHI uJ70iGerYzE; do
yt-dlp -f 'bv*[height<=1080]+ba/b' -o "$id.%(ext)s" "https://www.youtube.com/watch?v=$id"
doneTwo of those are Linear (Introducing Linear Diffs, Loops in Linear) and they are the ones that matter. They are the only two of the four that are motion graphics. The other two are live action, an actor and a camera, and no library makes those.