feat: rebuild ordinary moments as a 60-second product film - #81
Open
sjquant wants to merge 55 commits into
Open
Conversation
sjquant
force-pushed
the
ordinary-moments-example
branch
from
August 1, 2026 02:18
ec86dfa to
02768d1
Compare
The example read as a feature slideshow: scenes shared one layout and one role, footage repeated without purpose, and the motion decorated the frame instead of carrying the message. Rebuild it around a single argument — remaking the same video for every screen is the expensive part — across nine scenes, give each scene its own job and layout, and reuse each clip only as a deliberate callback. Motion now demonstrates what the copy claims: caption blocks fill across exactly their own cue window, a progress bar fills in step with the scene it labels, and the fit proof plays one clip window in three live frames at once.
AnimationSpec compiled correctly and then went nowhere: _canonical_render read only opacity and reveal, so position, scale, rotation, and blur were dropped at composite time. Every preset built on them — rise, zoom, pop, float, pulse, shake, ken burns — played as a plain fade, and the two public introspection APIs disagreed about it: capabilities_for said "full" for video while validate_export said "static". Apply the documented T·R·S geometry to a layer's rendered pixels in one shared helper used by both pipelines, so every layer type moves the same way and stills match video frame for frame. Image layers keep their own semantics, reading scale as a viewport zoom, and are now re-rendered per frame so ken burns animates in video instead of baking at t=0. The capability registry now declares only what the pipelines actually compile, leaving colour tracks and document targets honest about their fallbacks.
A scrim or vignette is the most common device in a footage-led film, and a shape could only take a flat colour — the example built its scrims from 115 stacked 2px rectangles to fake a ramp. Add fill=LinearGradient|RadialGradient to shapes, painted through the shape's own alpha so it turns with rotation, and keep color as the declared flat fallback for targets that cannot draw one. Both gradient builders also stopped short of their end stops: the ramp is built across the layer's diagonal and cropped from the centre, so a black-to-white gradient rendered 71 to 184 on a 400x200 box and a radial one reached only 1/sqrt(2) of its range at the far corner. Stretch each back over the extent it actually covers, and memoise the result so an animation stops rebuilding a diagonal-sized ramp every frame.
A clip could only be placed and trimmed: no opacity, no effects, no rounded corners, no clip or mask, and no animation. Footage could sit in a composition but never take part in it, which is what made video look pasted in rather than composed. Give VideoLayer the shared layer vocabulary and route a sampled frame through the same overlay compositor image layers use, so grading, corner radius, rotation, blending, masking, and entrance reveals all behave the same on footage. Legacy reveals now apply to component units too, so a clip can fade or wipe in while it plays. Also repairs stale expectations that predate this work: text and shape layers gained serialized fields the exact-dict specs never learned about, and the investor deck spec did not know about transition-repetition.
The scrims were 115 stacked rectangles faking a gradient and the cue strip had a playhead that could not move, because neither gradient fills nor position tracks reached the frame. Replace each scrim with one gradient-filled rectangle, restore the real playheads on the caption strip and the timeline bar, and give the framed clips a soft corner now that footage can be shaped. Documents the video layer, which had no API page at all, and the new shape fill.
An odometer pads its slots with blanks, and a blank slot took the static path: the incoming digit was painted at full opacity the moment the roll started, while its neighbours were still mid-roll. Counting 99 to 100 therefore rendered a frame reading "199". Blank slots were also measured as spaces, so the block resized whenever the number gained a digit and dragged the suffix sideways with it. Roll a blank like any other character — outgoing for the first half of the handoff, incoming for the second — and reserve one digit width per slot for the whole animation. The suffix now holds a single position from start to finish and the number only steps when a digit really arrives. Also aligns the example's timeline track with its own fill, which diagnose() had flagged as a fully hidden layer.
Every entrance effect was locked to one hardcoded CSS ease curve, so a bar that fills to report elapsed time flattered its own progress. Placing an effect at a known moment also meant abusing with_previous plus a delay, because the only way to reach an absolute time was to sit in the first chain group. Add easing= from the shared easing vocabulary and start= for an absolute anchor. Anchored effects do not move the cursor the relative effects around them chain from, so both styles compose. The browser runtime now receives a timing function per node instead of assuming ease. The film's cue blocks are declared at the cue times they illustrate, and its progress bar measures time linearly.
Three rules judged the settled frame without asking what the composition was doing, and buried the real findings under them. Edge crowding treated a layer that spans the canvas as content stopping too near an edge, so every full-bleed clip and every full-width scrim asked to be moved off edges they exist to cover. Hidden-layer and overlap both read an animated reveal as redundancy, because in the settled frame the incoming layer does sit exactly on the outgoing one. Contrast measured text against the layers below it even when the text carried its own Background effect, so ink on an amber chip scored 1.00. An edge a layer spans is no longer crowding, a cover that animates is a reveal rather than a collision, and a text chip is measured against the backing the glyphs actually sit on. The example's findings drop from 64 to 34, and what is left is either true or a documented judgement call.
A backdrop-dependent layer has to be rasterized with everything beneath it, and the exporter turned that into a blanket refusal: a frosted panel froze its own animation and every layer below it. The error even told you to move animated layers after the backdrop layer, which is where they already were. Re-render that group per frame instead, applying each layer's own motion, so a panel and the composition under it move together. Only PPTX-style entrance effects still need the group static, and the refusal now names that constraint and the way out. Two bugs surfaced while checking both pipelines agree. Canonical opacity was applied twice in video, squaring every fade, and the same overloaded value drove the clip, so a typewriter typed at a quarter speed while fading in. Opacity and clip now travel separately, stills match video, and charts and QR codes are left to reveal themselves.
A stagger compiled one timeline per target and then averaged them into a single reveal, so three lines asked to arrive 0.4s apart translated as one block and faded together. The sequence the API describes never reached the screen. Slice the layer out of its own finished render, one fragment per line, and give each fragment its own sampled state. Lines keep the layout they were drawn with — re-rendering a subset would shift them — and a target whose turn has not come waits off screen instead of sitting in its settled place, which is what makes a stagger read as a sequence at all. Lines that touch cannot be told apart in a render, and word and character targets have no separable band, so both fall back to moving the layer as a whole. The film's cost lines are one layer with a stagger again rather than three layers chained by hand.
Removes an unused reveal parameter left over from moving the opacity logic, replaces the positional canonical-state tuple with a named record, shares the one per-target composite loop between the two pipelines, and states the edge-bleed rule as the single condition it actually is. Splitting a layer into bands also scanned rows by cropping each one, which the still pipeline repeats for every frame of a staggered layer. One byte scan over the ink region halves it.
_unit_state returned four different shapes — two sentinels, a bare tuple, and a record — so every reader had to know which one meant what. One frozen _UnitState names the cases instead, and the sentinels are two instances of it. The new specs had each grown their own copy of "which pixels are lit". Those move to tests/_helpers, where the tuned thresholds each test depends on are now passed at the call site rather than buried in a local default.
The soundtrack sat within 1 dB across all nine scenes and still played at full level 1.4 seconds from the end, so the closing card arrived while the music was still going and then the music simply stopped. There is no better cut of the track to reach for — every 20-second block of it measures the same mean and the same peak, so an in-point would only choose a different ambient bar. The ending has to come from the mix instead: the fade is now the length of the closing card, so the bed starts receding exactly as the card appears. The two full-bleed statements that bookend the film also held one line over a still composition for seven seconds. Both now creep 6% closer across the scene, slow enough to read as the shot not sitting still rather than as an effect. Only possible since video layers took an animation.
Every rule judged one settled frame, so nothing could see a defect that only exists over time. Two checks close the cheapest part of that gap using data already on the layer. caption-reading-time reports a cue held under 0.8s or running past 20 display columns a second. Cost is counted in columns rather than characters via east_asian_width, because a Korean line carries far more per character than a Latin one and would otherwise race past unreported. clip-stretch reports footage played at half speed or slower, or double or faster, which renders cleanly and looks wrong: a three second clip asked to fill ten seconds simply crawls. Both thresholds are deliberately generous — they exist to catch mistakes, not to arbitrate typography — and neither fires on the example film. The docs table also never listed the three existing caption rules, and still warned that text with its own Background effect reports a false contrast positive, which was fixed earlier.
Review found eleven issues in the two new rules. The two that mattered: caption-reading-time judged columns per second but reported raw characters, so a Korean cue firing at 24 columns/s printed "13 characters in 1.00s" — a number that argues against its own finding. That was self-inflicted: an earlier pass removed the word "columns" as jargon and made the message wrong instead of merely dense. It now states the rate that fired and the limit it passed. diagnose() also opened a decoder per video source and never closed it, unlike the render and export paths, leaving a live ffmpeg process behind every call. It now closes them in a finally. The rest: clip-stretch told a racing clip to trim a longer window, the exact inverse of the repair; the docs claimed 0.5x fired when the code treats it as in range; reading time measured a cue's declared window rather than the time it is really on screen; both rules are pure predicates and move to _diagnostic_rules.py, which also lets them be specified without decoding real footage; display columns no longer charge per code point, so a joined emoji costs one glyph rather than eleven; threshold payload keys are scalars in both rules; and the engine docstring stops carrying a third hand-synced copy of the code list. The specs grow from 7 to 16, run on a synthesised clip rather than a 1.2 MB asset, and pin every threshold with literals — deriving the boundary from the constant let the constant move without failing anything. Six mutants that survived the original suite now fail.
The second review found the previous repair half-landed. caption-reading-time clamped against layer.duration, which is None unless an author declares it, so the fix covered only the shape that declares one: a cue visible for 0.2s at 150 columns a second went unreported when the clip's length came from its trim instead. The clip's on-screen length is now resolved from the trim and speed, probing the source only when it must. When a cue does outlive its clip the suggestion no longer tells an author to hold it longer, which cannot help, but to start it earlier or lengthen the clip, and the declared window travels in the payload beside the visible one so the two cases can be told apart. The rules module now owns each decision rather than only its constants: it takes the caption and clip models, returns a named CaptionReading, and answers clip_pace. The comparison, the threshold, and the tolerance live together, and the boundary uses the same epsilon caption-timing does, so a cue of 0.9 to 1.7 is no longer failed by float representation while an identical 1.0 to 1.8 passes. The unreachable duration guard, reintroduced with the clamp, is gone again. The specs grow to 22. The leak spec previously passed against a decoder that begin_render_pass had already closed, so replacing the close with a bare cache clear still passed; it now records the decoders diagnose itself opens and asserts each process is terminated. Both payloads are asserted whole, and joiners, combining marks, fullwidth forms, padding and severity each have coverage. Nine mutants that survived the last suite now fail, and the example spec goes back to asserting codes through deck.diagnose().
The film argued its case in Korean, which limited who it could argue to. Every line on screen is now English, and rewritten rather than transliterated: the hook asks how many times you have rebuilt the same video, the cost scene counts landscape, vertical and preview to three, and the close lands on "Compose once. Ship everywhere." The turn now says what is actually new. "Compose it once" was true of any template; that the framing, captions, motion and sound are written once, in code, is the part no timeline editor offers. The delivery scene states its determinism concretely — same composition, same frames, every single run — rather than gesturing at it. The font roles are renamed from KR/KR_BOLD/KR_HEAVY to VOICE/VOICE_BOLD/ VOICE_HEAVY, since they always described the film's voice rather than a language, and the README no longer calls the example Korean-language.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
diagnose()reports zero errors on exactly this kind of problem — repetition and a missing narrative are invisible to it.Changes
speed, trim window, cue times) from the values the composition actually renders, so a label cannot drift from the film.