Skip to content

feat(registry): text-match-cut - #4276

Closed
jmoran-heygen wants to merge 2 commits into
heygen-com:mainfrom
jmoran-heygen:feat/registry-same-curve-match-cut
Closed

jmoran-heygen wants to merge 2 commits into
heygen-com:mainfrom
jmoran-heygen:feat/registry-same-curve-match-cut

Conversation

@jmoran-heygen

@jmoran-heygen jmoran-heygen commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

What

A new component, text-match-cut: one line of text becomes another in the middle of a move. Both texts ride a single keyframe pair and hard-swap at the speed peak, so the cut lands at peak velocity with no crossfade.

Reversed from an After Effects tutorial (Editing Snapped, The Match Cut Technique Every After Effects User Needs), measured per frame at 60 fps rather than taken from the narration:

  • curve cubic-bezier(.85, 0, .15, 1) over 2 s (fit rmse 0.0015), split at exactly frame 60 of 120
  • gradient sweep on its own 87 % curve, 2316 px of travel at 1920, centre 948 px at the cut
  • motion smear stands in for AE motion blur, proportional to px per frame

Variables

id type default notes
text_a string Basic Interface Behavior outgoing line
text_b string Thoughtful Experience Design incoming line, continues the same move
mode enum scale-down scale-down (the tutorial), scale-up, tracking-in, slide-left, slide-right, slide-up, slide-down
sweep enum on cyan-to-blue bar through the glyphs
background enum navy the tutorial's navy glow, or transparent

Envelope: IN 2.0 s (the move), elastic HOLD, OUT 0.3 s. Sync point hard-cut at 1.0 s.

Checks

  • Author-reported hyperframes check on the demo: 0 errors (see maintainer validation below for the current CLI limitation).
  • node scripts/lint-registry-items.mjs: passes for this item (the two existing failures, vfx-text-cursor and caption-emoji-pop, are untouched)
  • generate-registry-items, generate-catalog-payloads --only text-match-cut, generate-catalog-pages run; oxfmt --check clean
  • demo.html mounts four modes in a 2×2 grid with non-default copy; every tile cuts on the same frame

Preview

hyperframes.dev project: https://hyperframes.dev/p/bf7a04d1-4647-400e-b20b-32338d7884a7 (claim pending on my side, will flip to public once claimed).

Maintainer generated and uploaded the catalog poster and MP4; links and verification below.

🤖 Generated with Claude Code

Maintainer integration and validation

Head 7a098147980eb843eb030d9ad99e3f5c26820d22 merges current main without changing Jake's component, demo, or variable metadata. Regenerated payload/search vectors, preserved current navigation structure, and added the missing gallery-data entry. Fork branch updated without force-push; merge commit is signed.

  • Named registry lint: 1 item, 0 errors.
  • Catalog consistency: 409 gallery items, no duplicate sidebar entries, all pages present.
  • Search-vector coverage: all 409 items match the published revision.
  • Pre-commit formatting, lint, typecheck, and artifact checks pass.
  • Rendered the four-mount demo through the repository's catalog preview pipeline. Independently checked before/at/after the 1s hard cut and backward seeking in Chrome: four mounts, six seeks, exactly one visible text per mount, stable repeated state, no page errors.
  • Inspected poster and cut-boundary frames. CDN MP4 re-download SHA-256 matches local: c85bc9597ffce894bf50560f8a19315368e21f1f7b099004f1931369f5d3d3cb.
  • The generic check command does NOT supply a current full-layout pass: it rejects the dimensionless registry snippet as a standalone root; feeding it compiled HTML instead trips lint on bundled GSAP/runtime code. Named registry lint and actual render/Chrome assertions are the measured evidence, not a claimed all-green generic check.

Poster: https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/text-match-cut.png

Video: https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/text-match-cut.mp4

Text Match Cut preview

jakemoran2011 and others added 2 commits September 21, 2026 23:33
…e on a single 85 % curve

Video primitive reversed from an After Effects tutorial (Editing Snapped, "The Match
Cut Technique Every After Effects User Needs"), measured per frame at 60 fps. Both
texts ride one keyframe pair, cubic-bezier(.85, 0, .15, 1) over 2 s, and hard-swap at
the speed peak (1.0 s). A `mode` variable picks the property that carries the cut:
scale down (the tutorial), scale up, tracking in, or a same-direction slide in four
directions. Optional cyan-to-blue gradient sweep on its own 87 % curve, navy or
transparent background.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve at 7a098147980eb843eb030d9ad99e3f5c26820d22. Well-built component and a clean integration — no blocking findings. Two notes below, neither one a reason to hold.

What I verified independently

Ran, not read off the description:

  • node scripts/lint-registry-items.mjs text-match-cut → Registry item lint passed — 1 item(s), 0 errors., exit 0.
  • node scripts/check-docs-catalog.mjs → exit 0, four PASS lines: 409 pages before, 410 after (1 legitimately new: /catalog/components/text-match-cut), 409 gallery items, all present in the sidebar and on disk; no duplicate sidebar entries, no .html/.js/.css under docs/public, and catalog-gallery.jsx clean. That settles the maintainer's consistency and count claims.
  • The two numbers in the description that are checkable at source both hold at W = 1920: D_GRAD = (2316 / 1920) * W is the stated 2316 px of travel, and XG_CUT = CX - (12 / 1920) * W is 960 − 12 = 948 px, the stated centre at the cut. bez(0.85, x) with p1x = a, p2x = 1 - a and the y-curve 3(1-t)t² + t³ is cubic-bezier(.85, 0, .15, 1) as claimed.
  • Placement: the item lands in motion-effects / Effects while its registry family is transitions, which looks wrong until you count it — all 10 other components with family: "transitions" land in exactly the same group/section. Consistent, not misfiled.
  • Conventions all match the registry: GSAP pinned to 3.14.2 (365 of 372 uses repo-wide), window.__timelines registration (380 files), data-fps="30" on the root (272 roots), document.getElementById("root") (117 components), and preview.mode: "player" with poster: null (229 of 409 items have that exact shape).

Three multi-mount hazards I went looking for and found already handled — recording them so nobody re-runs the check. The demo puts four mounts in one document, and the demo's own outer container is also id="root", so document.getElementById("root") inside a mount looks like it should resolve to the demo shell, and window.__timelines["text-match-cut"] looks like four writers to one key. packages/core/src/compiler/compositionScoping.ts closes both: the scoped document proxy intercepts getElementById and accepts a global hit only when __hfContains puts it inside this mount's root (an ancestor fails that test and falls through to the scoped lookup), and __hfTimelineRegistryProxy namespaces the timeline registry per composition.

Note 1 — this is the first component in the registry that reads dataset.fps, and the value it reads is the declared rate, not the render rate

text-match-cut is 1 of 221 components whose script reads root.dataset.fps (line 239); every other component's animation is fps-independent by construction. The smear is deliberately proportional to px-per-frame — dp is divided by FPS — so the value matters here in a way it never has before.

data-fps flows one way: packages/cli/src/utils/compositionFps.ts reads it so hyperframes render can default --fps to it. Nothing writes it back, so when --fps is passed explicitly (or a cloud backend picks from {24, 30, 60}), the attribute still says 30 and FPS reads 30. Rendered at 60, the smear is about twice what the model intends, capped by Math.min(6, …).

To be fair about whose problem this is: window.__hyperframes exposes only getVariables, so there is no API that would let the component do better today. The declared rate is the best available source, and nothing else in the registry is affected because nothing else looks. Worth knowing as the first consumer of a value the runtime does not actually publish.

Note 2 — landing order against #4274

Heads-up rather than a defect, since you plan to merge on approval. This PR moves registry.json's catalogArtifact.revision from eda107ee… to cf2a7c8f… and rewrites local-vectors.{json,bin}. #4274 (catalog item removals) mutates the same corpus hash, and scripts/catalog/check-artifact-coverage.ts verifies the revision, not just per-item coverage — its own docblock: "every corpus change still invalidates the published revision so stale vectors cannot pass the gate unnoticed."

So whichever of the two lands second goes red on catalog-index-coverage until the artifact is regenerated, and regeneration needs the embedding model locally — CI verifies freshness, it does not fix it. Sequencing thing, not a code change.

What I did not verify

No Chrome run and no oxfmt --check / check:catalog-drift / verify:catalog-payloads from me — this worktree has no node_modules and I borrowed only typescript to run the docs gate. The four-mount cut-timing and backward-seek assertions are taken as reported, not reproduced.

— Rames

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Superseded by #4288 (signed re-land on current main).

auto-merge was automatically disabled September 22, 2026 15:38

Pull request was closed

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.

4 participants