Skip to content

Commit dadc919

Browse files
committed
fix(skills): keep motion-blur-streak trail opacity independent of seek order
The driven ghost trail read the lead opacity written by another tween, so a frame could get a different trail opacity depending on the previous seek. Ghosts now sit inside the positioned, faded track wrapper and take the fade as group opacity. The snippet also names which element owns position, rotation and counter-rotation so the transform writes cannot clobber each other.
1 parent 0fafb5b commit dadc919

2 files changed

Lines changed: 22 additions & 7 deletions

File tree

‎skills-manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"files": 29
2323
},
2424
"hyperframes-animation": {
25-
"hash": "8c183924c57c2801",
25+
"hash": "c6686d8445f4e2b2",
2626
"files": 122
2727
},
2828
"hyperframes-audio": {

‎skills/hyperframes-animation/rules/motion-blur-streak.md‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,22 @@ tl.to(blurProxy, { v: 0, duration: 0.35, ease: "power4.out", onUpdate: writeBlur
104104

105105
## Per-Frame-Driven Carve-In (baked tracks)
106106

107-
When the element is not tweened but **driven** — a tracked insert or callout riding a baked per-frame track, a title matched to camera shake — there is no move window to share. The envelope becomes a per-frame function of the track's own delta, computed in the same `onUpdate` that applies the position:
107+
When the element is not tweened but **driven** — a tracked insert or callout riding a baked per-frame track, a title matched to camera shake — there is no move window to share. The envelope becomes a per-frame function of the track's own delta, computed in the same `onUpdate` that applies the position.
108+
109+
One element per transform, so no write clobbers another:
110+
111+
```html
112+
<!-- .track: position + any fade, the only element either touches -->
113+
<div class="track">
114+
<!-- ×2, the lead's box -->
115+
<div class="streak-ghost" aria-hidden="true" data-layout-allow-overlap></div>
116+
<!-- Path A only: rotate onto the travel axis; same box as .lead so both rotate about one center -->
117+
<div class="blur-wrap" style="filter: url(#streak)">
118+
<!-- counter-rotate only; no GSAP transform tweens on it -->
119+
<div class="lead">…</div>
120+
</div>
121+
</div>
122+
```
108123

109124
```js
110125
// inside the frame-lookup driver (an ease:"none" proxy → frames[i]); TRACK.x / TRACK.y are baked arrays
@@ -113,14 +128,14 @@ const dx = TRACK.x[j] - TRACK.x[j - 1];
113128
const dy = TRACK.y[j] - TRACK.y[j - 1];
114129
const speed = Math.hypot(dx, dy); // px per frame
115130
const angle = (Math.atan2(dy, dx) * 180) / Math.PI; // the streak axis IS the travel direction
131+
track.style.transform = `translate(${TRACK.x[i]}px, ${TRACK.y[i]}px)`;
116132

117133
// Path B, driven form (the default here): text-free silhouette clones trail the lead along −delta.
118134
const base = speed > TRAIL_GATE ? Math.min(TRAIL_MAX, TRAIL_K * speed) : 0;
119-
const leadOpacity = parseFloat(gsap.getProperty(lead, "opacity")); // slaved: a fading lead fades its trail
120135
ghost1.style.transform = `translate(${-dx * 0.25}px, ${-dy * 0.25}px)`;
121136
ghost2.style.transform = `translate(${-dx * 0.5}px, ${-dy * 0.5}px)`;
122-
ghost1.style.opacity = String(base * leadOpacity);
123-
ghost2.style.opacity = String(base * 0.5 * leadOpacity);
137+
ghost1.style.opacity = String(base); // a fade on .track multiplies in; never read another tween's value
138+
ghost2.style.opacity = String(base * 0.5);
124139

125140
// Path A, driven form: rotate a filter wrapper onto the travel axis, blur along X only, counter-rotate the content.
126141
const sigma = speed > TRAIL_GATE ? Math.min(SIGMA_MAX, 0.5 * speed) : 0;
@@ -132,7 +147,7 @@ blurNode.setAttribute("stdDeviation", `${sigma} 0`);
132147
Three deltas from the tweened recipe:
133148

134149
- **Ghosts are text-free silhouettes** — the lead's box, border, radius and fill with **no content** (`aria-hidden="true"`, `data-layout-allow-overlap`). A 25–50%-opacity clone reads as shape; a clone carrying the text reads as a duplicate element, and content-free clones give the layout and contrast checkers nothing to flag.
135-
- **Velocity-gated, not window-shaped** — the trail exists only above `TRAIL_GATE` px/frame and scales with speed (`min(TRAIL_MAX, TRAIL_K × speed)`): a slow drift shows nothing, a whip shows a 180°-shutter smear. It is slaved to the lead's own opacity so an entrance or exit fade takes the trail with it.
150+
- **Velocity-gated, not window-shaped** — the trail exists only above `TRAIL_GATE` px/frame and scales with speed (`min(TRAIL_MAX, TRAIL_K × speed)`): a slow drift shows nothing, a whip shows a 180°-shutter smear. Fade `.track`, never `.lead`: group opacity takes the trail with it, and the ghost opacity stays a pure function of the frame index whatever order frames are seeked in.
136151
- **Axis from the baked delta** — `atan2(dy, dx)` per frame; the streak follows the track around corners with nothing to seed at setup.
137152

138153
**Which path for a driven subtree.** Path B is the default: it costs no filter raster (a full filter region re-rasterizes every frame under per-frame drive), its clones carry no text so the layout and contrast checkers see nothing new, and it degrades to nothing when the track is still. Path A in the driven form is legitimate when a true blur is the look. Both forms measured deterministic on the reference host (2026-09-08: single-card 120-frame and three-card 450-frame drives of each path, hardware-GL and software lanes, every double render bit-identical) — but a per-frame-driven filter is the first thing to re-measure on a new host: keep the double-render frame-hash comparison (`--format png-sequence` twice) in the pipeline rather than trusting one clean run. One earlier build (2026-08) observed 1-LSB `feGaussianBlur` jitter under per-frame load and shipped Path B for that reason.
@@ -165,7 +180,7 @@ Three deltas from the tweened recipe:
165180
- Dwell ≥1 s sharp after the snap; a streak landing at the last beat reads as "flashed and gone".
166181
- Heavy element on a solid field — thin type (< ~120px / 800 weight) or a busy backdrop swallows the smear.
167182
- `overflow: hidden` on the scene — the smear / furthest ghost extends past the resting position during travel.
168-
- Driven form: clones are text-free silhouettes (`aria-hidden`, `data-layout-allow-overlap`), opacity slaved to the lead, axis from the baked delta; nothing is seeded at setup except the structural frame-0 state.
183+
- Driven form: clones are text-free silhouettes (`aria-hidden`, `data-layout-allow-overlap`) inside the faded `.track`, axis from the baked delta; nothing is seeded at setup except the structural frame-0 state.
169184

170185
## See also
171186

0 commit comments

Comments
 (0)