Skip to content

Latest commit

 

History

45 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

scenes

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.

The rules

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.

Ten of thirty demos tore on every pass

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.

Typography, where the sentence is the film

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.

What the sentences did in playback

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.

Speed is a number in catalog.json

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.

The kit, starting with the geometry

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.

Three formats

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, per ratio

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-gap counted 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-travel measured 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-floor exited 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.
  • tempo compared 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.
  • seam never 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 that seam and rest-point did not count the same way on the Mac and in CI: compare -fuzz on ImageMagick 6 found the reversed pair almost identical and failed the start of BoardOrbit in 9:16, which ImageMagick 7 passed. Both now count with ffmpeg (_pixeldiff.sh), which gives the same number in both places.
  • beats gets 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.

Layout

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

Verification happens on the render

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 implies

catalog.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>.mp4

The showcase page

showcase/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 whoami

Make 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-scenes

Re-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.

Adding a scene

The order matters, and step 4 is the one people skip.

  1. Decide the pose it enters from and the pose it leaves in, and put both in the product's geometry.ts, products/topics/geometry.ts for Topics. CARD_HANDOFF_END_POSE spent three scenes as three literals inside CardHandoff.tsx, which was fine exactly as long as nothing came after it.
  2. Write it in video/src/products/<product>/scenes/. Take progress?: number and derive everything else from useCurrentFrame(); read the ratio with stageFor(width, height) from useVideoConfig() and take the poses of that ratio. If you reach for Date.now(), Math.random() or a CSS keyframe, the scene is no longer reproducible and framelocked-verdict.sh will say so.
  3. Reuse the kit and the product folder. kit/Shot.tsx draws the slab, kit/camera.ts turns a track into a pose; the product's geometry.ts holds the poses, tracks.ts the camera, SlabChrome.tsx the app furniture. A scene that redraws its own sidebar can only stay aligned with the others by hand, and it will not.
  4. Add one entry to video/src/scenes/catalog.json — id, slug, duration, the blurb for the page, and seamAfter if it follows another scene — then the one line in COMPONENTS in Root.tsx that 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 the COMPONENTS line 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.
  5. Give it a check that can fail. Every scene here has one bench that fails when the scene's own promise is broken: beats.py for the four beats, handoff-travel.py for the card crossing. Declare its checks in scripts/checks/<bench>.mjs, write the negative first, and run expect.sh on the three ratios before trusting the pass; bench-coverage.py will refuse a bench without one. npm run lint proves nothing about a video.
  6. 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.

Making a commercial

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.

The files of a film

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.

The kit, entry by entry

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

The order

  1. 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).
  2. The slab. Draw the UI by parts, every rectangle computed.
  3. 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 R answers in a second, so run it while moving keys.
  4. The beats. Each one from a kit function with its numbers, in timeline.ts.
  5. 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.
  6. The typeface from a file, with useFontFile.
  7. The rules module, the samples, the checks. One broken copy per rule; the checks name the rule that has to catch it.
  8. Check the three ratios, then render: ./scripts/film-type.py --ratio R renders only the sampled frames.

What the checks found on the example film

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.py finds 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. Film now takes solo and 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.

Using the kit from another repository

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 under node_modules. The bundler does not care: Remotion's Rspack rule compiles any .ts file 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 run npm ci inside the submodule: the kit then resolves remotion from the film project, and there is a single instance.
  • The same tsconfig.json options as video/, allowImportingTsExtensions above all: the pure modules import each other with .ts extensions so that Node can load them.
  • The typeface in the film project's public/, loaded with useFontFile. staticFile resolves 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, as products/topics/benches/film-demo.ts does. The checks module builds the same lines as scripts/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.json

The 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.

Licensing, which has two halves

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.

ref/ is deliberately not committed

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"
done

Two 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.

About

Frame-locked scenes for software product commercials, written in Remotion. Real UI, not a drawing of UI.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages