mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
* feat(skills): c2v mining pass over animation blueprints and rules Compacts ~45 existing animation rules/blueprints into tighter recipe form (net -3.4k lines) and adds 17 mined from the c2v corpus: - 7 blueprints: agent-progress-theater, camera-journey, fixed-anchor-cycle, panel-edit-live-sync, prompt-type-submit-generate, transcript-scroll-artifact-reveal, zoom-out-workspace-reveal - 10 rules: 3d-camera-flight, anchored-layout-expand, chart-scrub-readout, chromatic-glitch, control-target-sync, cursor-drag, gradient-text-sweep, multi-cursor-choreography, particle-burst, theme-crossfade-morph Both indexes updated. * feat(skills): sync product-launch script bank with mined blueprint roles The role->blueprint script bank in product-launch-video/story-design.md is kept 1:1 with blueprints-index role declarations, which the c2v mining pass expanded. Adds the 25 missing entries (script-shape descriptor + example lines + pattern): 13 for the 7 new blueprints, 12 for role widenings on 7 existing ones (cursor-ui-demo, dataviz-countup, titlecard-reveal, et al.), and states the 1:1 sync contract in the bank's intro. * docs(skills): cover constellation-hub scatter-drift variant in the script bank Review follow-up on #2680: the SOCIAL_PROOF constellation-hub entry patterned only the orbit shape; the c2v pass added a scatter-drift end-card variant with the opposite geometry (no hub, no ring). Adds an example line and extends the pattern so a scatter-drift beat's VO isn't steered toward the orbit shape.
139 lines
7.4 KiB
Markdown
139 lines
7.4 KiB
Markdown
---
|
||
name: coordinate-target-zoom
|
||
description: Zoom into a specific non-centered element by combining scale with counter-translation — target ends at viewport center after the zoom completes.
|
||
metadata:
|
||
tags: camera, zoom, scale, translate, target, off-center, focus
|
||
---
|
||
|
||
# Coordinate Target Zoom
|
||
|
||
A simple `scale > 1` on a wrapper pushes off-center content OFF the visible canvas. To zoom _into_ a specific non-centered element, apply scale AND an inverse translation in lockstep so the target lands at viewport center.
|
||
|
||
## How It Works
|
||
|
||
Two nested wrappers, separated concerns — never scale and translate on the SAME element (`translate * scale` ≠ `scale * translate` in CSS transform composition):
|
||
|
||
1. **Outer wrapper** applies `scale` (the zoom) around `transform-origin: 50% 50%`
|
||
2. **Inner wrapper** applies `translate(x, y)` (the counter-shift)
|
||
|
||
The counter-translate is the **negation** of the target's offset from viewport center:
|
||
|
||
```
|
||
T = -offset
|
||
```
|
||
|
||
Derivation: the inner translate moves the target to `offset + T` in pre-scale units; the outer scale S (around center) maps that to `S × (offset + T)`; landing at center means `S × (offset + T) = 0` → **`T = -offset`**. The formula does NOT depend on S — the translate is identical at 1.5×, 2×, or 3×. A common wrong intuition is `T = -offset × (S - 1)`: it coincidentally matches at S = 2 and is wrong at every other scale.
|
||
|
||
⚠️ **This is the NESTED-wrapper formula.** The single-wrapper camera in [viewport-change.md](viewport-change.md) puts `translate(x,y) scale(S)` on ONE element, where CSS applies scale first — there the counter-translate is **`T = -offset × S`**. The two formulas are not interchangeable; match the formula to the wrapper structure.
|
||
|
||
## Getting the offset
|
||
|
||
`T = -offset` is only as good as `offset`. The #1 way this pattern ships broken is hand-computing `offset` from a layout formula, getting the **sign** or magnitude wrong, and letting the zoom amplify a small error off-screen. **Default to measuring the target's real laid-out center; reserve the formula for symmetric rows.**
|
||
|
||
**Default — measure the actual center (works for ANY layout).** Immune to sign errors because it reads the rendered DOM, not a mental model:
|
||
|
||
```js
|
||
await document.fonts.ready; // metrics final; fallback fonts are 10–30px off → tens of px after a 3×+ zoom
|
||
const W = 1920,
|
||
H = 1080;
|
||
const r = document.getElementById("target-card").getBoundingClientRect();
|
||
const TARGET_OFFSET_X = r.left + r.width / 2 - W / 2;
|
||
const TARGET_OFFSET_Y = r.top + r.height / 2 - H / 2;
|
||
```
|
||
|
||
Measure **once at setup** and bake — never per-frame in `onUpdate`. Because the measurement is async (`fonts.ready`), build and register the timeline inside the same `async` setup so the baked offset is ready before `window.__timelines[id]` is published.
|
||
|
||
**Shortcut — symmetric equal-width row ONLY:**
|
||
|
||
```js
|
||
const index_offset = targetIndex - (N - 1) / 2;
|
||
const TARGET_OFFSET_X = index_offset * (CARD_WIDTH + CARD_GAP);
|
||
```
|
||
|
||
⚠️ This assumes every sibling is the **same width**. The moment the row is asymmetric, it gives the wrong answer — often the wrong **sign**: the heavier side shifts the centered target the _opposite_ way you'd guess (e.g. `companion(220) + gap + wordmark + gap + chip(110)` puts the wordmark ~55px **right** of center, but "chip − companion" intuition says left). For anything but equal cards, **measure**.
|
||
|
||
**Headroom budget — cap the scale from the measured size.** A zoom multiplies any centering error; keep the target ≤ ~88% of the canvas at peak:
|
||
|
||
```js
|
||
const maxScale = Math.min((0.88 * W) / r.width, (0.88 * H) / r.height);
|
||
const ZOOM_SCALE = Math.min(DESIRED_SCALE, maxScale);
|
||
```
|
||
|
||
A target filling 97%+ of the frame reads as cut-off the instant its center is slightly off — and a hand-baked offset always is. (The perception gate flags this as `primary-offscreen`; `data-layout-allow-overflow` does **not** exempt it.)
|
||
|
||
## Recipe
|
||
|
||
```html
|
||
<div class="zoom-outer" id="zoom-outer">
|
||
<div class="zoom-inner" id="zoom-inner">
|
||
<div class="content">
|
||
<div class="card">{other}</div>
|
||
<div class="card target" id="target-card">{target}</div>
|
||
<div class="card">{other}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
```css
|
||
.scene {
|
||
overflow: hidden; /* REQUIRED — at zoom > 1 the scaled content leaks past the frame */
|
||
}
|
||
.zoom-outer {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: grid;
|
||
place-items: center;
|
||
transform-origin: 50% 50%; /* center scaling is what the counter-translate math assumes */
|
||
will-change: transform;
|
||
}
|
||
.zoom-inner {
|
||
display: grid;
|
||
place-items: center;
|
||
will-change: transform;
|
||
}
|
||
```
|
||
|
||
```js
|
||
// TARGET_OFFSET_X/Y and ZOOM_SCALE come from "Getting the offset" — measured
|
||
// at setup (after fonts.ready), baked. Counter-translation = -offset.
|
||
const counterX = -TARGET_OFFSET_X;
|
||
const counterY = -TARGET_OFFSET_Y;
|
||
|
||
// Scale and counter-translate MUST share position, duration, AND ease —
|
||
// otherwise the target visibly wanders mid-zoom.
|
||
tl.to("#zoom-outer", { scale: ZOOM_SCALE, duration: ZOOM_DUR, ease: "power3.inOut" }, ZOOM_AT);
|
||
tl.to(
|
||
"#zoom-inner",
|
||
{ x: counterX, y: counterY, duration: ZOOM_DUR, ease: "power3.inOut" },
|
||
ZOOM_AT,
|
||
);
|
||
```
|
||
|
||
## Variations
|
||
|
||
- **Zoom out (target → wide view)**: reverse the phases — start zoomed-in, then tween to `scale: 1` + `x: 0, y: 0`; the "reveal" beat is the panorama.
|
||
- **Multi-target zoom sequence**: chain zooms (target A → pause → target B → pull back); each segment needs its own counter-translation pair.
|
||
|
||
## Values
|
||
|
||
| token | range | notes |
|
||
| ---------- | --------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||
| ZOOM_SCALE | 1.5× modest → 3× dominant → 5×+ extreme | cap via the headroom budget; raster media needs `sourceResolution ≥ rendered × ZOOM_SCALE` |
|
||
| ZOOM_DUR | 1.0–2.0s | under 0.8s feels like a teleport, over 2.5s drags; both tweens share it |
|
||
| ZOOM_AT | after the layout lands + 0.5–1.5s | give the viewer time to scan the layout before the camera commits |
|
||
| DWELL | ≥ 1.0s after the zoom settles | 1.5–2s ideal — the viewer must be able to read the target (climax dwell) |
|
||
|
||
## Critical Constraints
|
||
|
||
- **Outer scales, inner translates** — never both transforms on one element; nested wrappers keep the math clean.
|
||
- **`transform-origin: 50% 50%` on the outer wrapper** — non-center origin breaks the counter-translate derivation.
|
||
- **`overflow: hidden` on the scene root** — zoomed content leaks past the frame otherwise.
|
||
- **Scale and counter-translate share duration + ease** at the same timeline position, or the target drifts mid-zoom.
|
||
- **Offset measured once at setup** (after `fonts.ready`), baked — never recomputed per-frame, never hand-derived for a non-symmetric layout (wrong sign → target shoved off-frame).
|
||
- **Scale within the headroom budget** — target ≤ ~88% of the canvas at peak, derived from the measured size.
|
||
|
||
## See also
|
||
|
||
[viewport-change.md](viewport-change.md) (single-wrapper form, `T = -offset × S`) · [multi-phase-camera.md](multi-phase-camera.md) (a zoom phase inside a phased camera) · [sine-wave-loop.md](sine-wave-loop.md) (idle breathing after the zoom settles) · [discrete-text-sequence.md](discrete-text-sequence.md) (text assembly in the target before the zoom).
|