feat(engine): close the dark-premium capability gaps and ship the template - #122
Merged
Conversation
Both reference films reveal a line word by word, each word arriving blurred and settling sharp. The per-unit machinery already existed — granularity, stagger, delay, easing, overshoot — but none of the six char presets blurred, so the one detail that reads as expensive was the one thing not expressible. char_blur_in drives blur, opacity and a slight upward drift from a single eased progress, so a word is one continuous animation rather than three stacked effects. Sigma is configurable and defaults to 14px, chosen because at 150px type it reads as unmistakably blurred early while letterforms stay ghost-legible. Measured on the rendered frames: Laplacian variance over a word's bounding box climbs from 1.29 mid-reveal to 3712 once settled, converging exactly at that word's unit_end and staying flat after. Known limitation, documented in the code: the preset is resolved directly from style.animation rather than through extract_effects, so unlike its five siblings it does not pick up container-level stagger shifting or timeline-step copies. Refs #118
The halo preset is the right home for the ambient glow of a dark video: it is a background, so it escapes geometry validation, and the renderer draws it continuously rather than per scene. But HaloZone exposed only colour, position and radius, so the only way to soften a glow was to bury alpha inside the hex string. With opaque colours it renders a flat saturated wall instead of glows — reproducing the reference look took two attempts and a lot of guessing. opacity defaults to 1.0 and multiplies whatever alpha the colour already carries, so it never overrides an author's intent. The default is a true no-op: a scenario with the field absent and one with opacity 1.0 render byte-identical PNGs, which is guaranteed rather than incidental since f32 * 1.0 is exact. Measured falloff at 1.0 / 0.5 / 0.2: centre pixel R = 255 / 128 / 51. Refs #119
A radial glow running past the frame edge is the base layer of the reference style, and the validator rejected it as viewport overflow. The obvious workaround — wrapping it in a full-frame overflow: hidden container — passes validation and then destroys the effect it was protecting: the container draws a hard rectangle, and during a camera pan that edge sits in plain view. bleed: true exempts a component from viewport_overflow and, under --strict-anim, from animated_text_overflow. It deliberately does not exempt content_overflows_box: a component may extend past the frame on purpose, but it is still responsible for its own contents. Nor does it suppress checks on children. This extends the existing exemption — geometry.rs already exempts marquee and cursor, with a comment saying their job is to bleed — rather than adding a second mechanism. Chosen over inferring which shapes look decorative: a shape is very often real content, and a wrong guess would silently hide a genuine overflow. Also adds bleed to WRAPPER_KEYS. Without it the attribute checker, blocking by default since it was hardened, rejected bleed as unknown — the geometry was correct while the CLI refused the file, which would have shipped the feature dead. Refs #120
The world view is the engine's spatial composition mode and appeared in no authoring rule — not composition, not world-position, not camera_pan_duration, not persist. A generator cannot produce what is not written down, which is why the capability audit had to rediscover it from source, getting the coordinate model wrong twice on the way. Two things the docs now state explicitly because they cost real time: world-position is the camera waypoint, not the scene's origin, and its default is vw/2 + i*vw; and children use scene-local coordinates, so a child placed at world coordinates is rejected as viewport overflow. Corrects a claim the audit made: CameraPan is not merely a crossfade. Between scenes of a slide view it dispatches to a real foreground pan over a static background; it degrades to a crossfade only at a view boundary. That one enum variant meaning two different things is noted as a footgun. Also notes that scene.transition is silently ignored inside a world view, and the world-position kebab / freeze_at snake casing inconsistency. Every fenced JSON block added was extracted and validated. Refs #121
A second template, in the register of the Machina and Aikido reference films — deliberately not replacing the brutalist 1600-style one, which is coherent in its own language. Seven beats, 20.8s, and no beat that reads as a cut. The seamlessness rests on three things, in order of importance: camera_pan transitions with a world-position delta per scene, so the background is drawn once and stays put while foregrounds slide across it; an ambient halo layer living in the scene background rather than in shapes, since shapes would need clipping and a clip rectangle is exactly the hard edge being avoided; and a camera that never returns to where it started, so no beat is a still frame. Exercises the three features landing alongside it: char_blur_in for word reveals, halo opacity for the ambience, and bleed on the decorative orbs. Refs #117
Each halo zone breathes: its radius is scaled by 1 + 0.15*sin(time*freq). `freq` came straight from `speed`, which is shared with the scrolling presets where it means pixels per second and defaults to 30. Used as an angular frequency that is 30 rad/s — a period of 0.16 to 0.30 seconds, so the ambience pulsed at roughly 5 Hz. It read as flicker, not as light, and on a dark gradient it was the most visible thing in the frame. Convert the scroll speed into a breathing rate instead. At the default it now gives a period of 8 to 15 seconds — about one cycle over a short video, which reads as the glow being alive rather than broken. Measured on the dark background of examples/dark-premium.json, mean absolute difference between consecutive frames: 2.04 before, 0.71 after, while the slow luminance swing that carries the breathing is preserved (8.8 vs 9.3).
A camera pan drew one background — the outgoing scene's — as a fixed backdrop, and slid only the foregrounds over it. That is what makes a shared ambience survive a junction, and it is the right default: it is precisely why a multi-beat video reads as a single shot rather than a sequence of cuts. But it also made a per-beat background impossible. A scene whose ambience differed from its predecessor's never showed it during the pan; the new background appeared only once the transition had finished, as a pop. `transition.background` now chooses. `static` keeps the current behaviour and stays the default, so no existing scenario changes. `travel` renders both backgrounds and locks each to its own foreground, so a beat moves as one plane and the whole world changes at the junction rather than just the words. The second background is only rendered when it will actually be used.
The template shared one halo layer across all seven beats. That was the safe choice while a pan could only hold a fixed backdrop, but it left the ambience inert: the words changed, the world did not. Each beat now carries its own palette and the pans use `background: travel`, so the ambience arrives with the scene. The progression follows the argument — cold and thin while the problem is stated, darkest and most crowded at the beat about too many tools, opening into blue as it turns, resolving on magenta for the number and on both accents at the call to action. Also drops the decorative orbs. They were foreground children, and during a pan the two foreground planes are adjacent rather than overlapping, so ambience living there breaks at the join. All of it belongs in the background — which is what this template's own recipe says, and what it was not doing.
…hem together `background: travel` slid each background locked to its foreground, at the same distance. The two are opaque full-frame images that tile exactly, so they met on a line — and a line between two different ambiences is a scene boundary, which is the one thing this transition exists to avoid. It was clearly visible whenever consecutive beats had different palettes. Three changes, each needed for the others to work: Backgrounds now drift at a fraction of the foreground's distance. That is how parallax behaves — what is far away moves less — and it makes them overlap across nearly the whole frame rather than sharing an edge. They are drawn overscaled by the largest drift either can take. Translating an opaque image uncovers a strip on the opposite side, and that strip reads as a hard edge exactly as a join would; the overscale means neither ever exposes one. The outgoing background stays opaque and the incoming one dissolves over it. Fading both leaves the frame under-covered wherever only one of them reaches, which trades the seam for a dark band — measurably worse than the seam it replaced. Largest adjacent-column luminance step across the background of examples/dark-premium.json: 7.74 before, 5.48 after, and what remains sits mid -frame on a gradient rather than on a layer edge.
Each travelling background was overscaled by the largest drift it could take, which made the margin constant across the whole transition — including at both ends, where the displacement is zero. The background therefore switched between a normal frame and an enlarged one at every junction. Tie the margin to each layer's current offset instead: just enough to cover the strip its own translation uncovers, and exactly zero where a transition meets a normal frame. This is correct rather than dramatic. Measured on the background of examples/dark-premium.json, peak frame-to-frame change went from 10.65 to 10.47 — the remaining jumps have a different cause, which reproduces with `background: static` and therefore predates travelling backgrounds entirely.
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.
Closes the gaps a capability audit found between the engine and the two reference
films (Machina by 1600.agency, Aikido), ships a template that exercises them, and
fixes what rendering that template exposed.
Parent: #117 · Sub-issues: #118 #119 #120 #121
Capabilities the audit asked for
char_blur_in— blur, opacity and drift from one eased progressHaloZone.opacity, default 1.0, multiplied with the colour's own alphableed: true, extending the existing marquee/cursor exemptionexamples/dark-premium.json, 7 beatsThen the template was rendered, and three defects surfaced
The halo preset strobed. Each zone breathes on
1 + 0.15*sin(time*freq), andfreqcame straight fromspeed— shared with the scrolling presets, where it meanspixels per second and defaults to 30. Used as an angular frequency that is 30 rad/s: a
period of 0.16s, so the ambience pulsed at roughly 5 Hz. Converting it to a breathing
rate gives 8–15s instead. Mean frame-to-frame change on the dark background: 2.04 →
0.71, with the slow luminance swing that carries the breathing preserved (9.3 → 8.8).
A camera pan could not carry a per-scene background. It drew one background — the
outgoing scene's — as a fixed backdrop. That is right when every beat shares an
ambience, and it is why a multi-beat video reads as one shot, so it stays the default.
transition.background: travelrenders both and locks each to its own scene.Travelling backgrounds joined on a hard line. Two opaque full-frame images tile
exactly, so they meet on an edge, and an edge between two ambiences is a scene
boundary. Fixed with three things that only work together: backgrounds drift at a
fraction of the foreground's distance (real parallax, and it makes them overlap rather
than abut); each is overscaled by its own current displacement, since translating an
opaque image uncovers a strip that reads as a hard edge too; and the outgoing one stays
opaque while the incoming dissolves over it. Largest adjacent-column luminance step:
7.74 → 5.48, and what remains sits mid-frame on a gradient rather than on a layer edge.
Two of the three approaches tried were regressions, caught by measurement rather than by
eye — fading both backgrounds, which looked like the obvious answer, measured worse
(12.25) than the seam it replaced.
Verification
cargo test --workspace→ 15 suites green.cargo clippy --workspace --all-targetsclean apart from a transitive dependency. All 8 example scenarios validate.
Every visual claim is measured, not asserted:
char_blur_in— Laplacian variance over a word's bounding box: 1.29 mid-reveal →3712 settled, converging exactly at that word's
unit_end.haloopacity — centre pixel R = 255 / 128 / 51 at opacity 1.0 / 0.5 / 0.2;no-op proven by byte-identical PNGs.
bleed— the same bleeding shape validates with the flag and still errors withoutit; a
bleed: truecomponent whose content overflows its own box is still reported.Known, unfixed
frames shows isolated single-frame spikes right after each transition, 47–62× the
median background change. They reproduce with
background: static, so theypredate the travelling backgrounds. Lead, unverified: the normal render path does its
own background crossfade via
render_scene_frame_scaled_with_prev_bg(prev_bg)whilethe transition path draws the background raw — two independent crossfades that do not
know about each other, meeting at the boundary. Three earlier hypotheses were tested
and refuted; this one is not proven and should not be treated as a conclusion.
validatereports 20.8s for a render that is 16.2s. Transition duration issubtracted from the scenes rather than overlapping them, so the announced duration is
22% off.
char_blur_inresolves fromstyle.animationdirectly rather than throughextract_effects, so unlike its five siblings it ignores container-level staggershifting and timeline-step copies.
scene.transitionis silently ignored inside a world view, with no validator warning.TransitionType::CameraPanmeans two different things depending on context: a realforeground pan between scenes of a slide view, a plain crossfade at a view boundary.