Skip to content

feat: rebuild ordinary moments as a 60-second product film - #81

Open
sjquant wants to merge 55 commits into
mainfrom
ordinary-moments-example
Open

feat: rebuild ordinary moments as a 60-second product film#81
sjquant wants to merge 55 commits into
mainfrom
ordinary-moments-example

Conversation

@sjquant

@sjquant sjquant commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Why

  • The example read as a feature slideshow rather than an ad: scenes shared one layout and one role, footage repeated without purpose, and the motion decorated the frame instead of carrying the message.
  • It also stopped at ~38 seconds, before the product's case was made, and diagnose() reports zero errors on exactly this kind of problem — repetition and a missing narrative are invisible to it.

Changes

  • Rebuilds the example as a 60-second film with a single argument — remaking the same video for every screen is the expensive part — carried by nine scenes: hook, cost, turn, three proofs, delivery payoff, resolution, and close.
  • Gives every scene its own job and layout, and reuses each of the five clips only as a deliberate callback in a different frame and a different window.
  • Demonstrates composition instead of describing it: one proof scene places the same clip window in live 16:9, 1:1, and 9:16 frames at once, and the payoff scene runs footage inside the delivery card.
  • Ties motion to meaning — caption cue blocks fill across exactly their own cue window, a progress bar fills in step with the scene it labels, the cost lines land one remake at a time, and counters carry cost and render progress.
  • Unifies the visual system: one margin and type scale, Pretendard for the film's voice and Roboto for every functional readout, an accent colour withheld until the narrative turn, and graded scrims in place of flat washes.
  • Derives on-screen readouts (speed, trim window, cue times) from the values the composition actually renders, so a label cannot drift from the film.
  • Rewrites the black-box specs around the story contract, clip roles, and caption cues, and shrinks the committed GIF preview from 27 MB to a short cut of the payoff scene.

@sjquant
sjquant force-pushed the ordinary-moments-example branch from ec86dfa to 02768d1 Compare August 1, 2026 02:18
sjquant added 9 commits August 1, 2026 16:43
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.
@sjquant sjquant changed the title feat: polish ordinary moments video example feat: rebuild ordinary moments as a 60-second product film Aug 1, 2026
sjquant added 4 commits August 2, 2026 12:17
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.
@sjquant sjquant self-assigned this Aug 2, 2026
sjquant added 13 commits August 2, 2026 16:08
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant