mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 20:37:30 +00:00
* feat(registry): add text-match-cut — one text becomes another mid-move 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. * chore(registry): integrate text-match-cut with current main --------- Co-authored-by: Jake Moran <jakemoran2011@gmail.com>
408 lines
19 KiB
Plaintext
408 lines
19 KiB
Plaintext
---
|
||
title: "Text Match Cut"
|
||
description: "One line of text becomes another mid-move: both ride a single 85 % easy-ease curve and hard-swap at the speed peak, as scale, tracking, or a same-direction slide."
|
||
mode: "frame"
|
||
---
|
||
|
||
import { CatalogDetail, CatalogSlot } from "/snippets/catalog-detail.jsx";
|
||
import { InstallCommand } from "/snippets/install-command.jsx";
|
||
|
||
<CatalogDetail
|
||
previewSrc="/public/catalog/components/text-match-cut.json"
|
||
compositionId="text-match-cut"
|
||
compositionSrc="compositions/components/text-match-cut.html"
|
||
title="Text Match Cut"
|
||
description="One line of text becomes another mid-move: both ride a single 85 % easy-ease curve and hard-swap at the speed peak, as scale, tracking, or a same-direction slide."
|
||
variables={[{"id":"text_a","type":"string","role":"content","label":"Outgoing text","description":"The line on screen before the cut.","default":"Basic Interface Behavior"},{"id":"text_b","type":"string","role":"content","label":"Incoming text","description":"The line that continues the move after the cut.","default":"Thoughtful Experience Design"},{"id":"mode","type":"enum","role":"style","label":"Move","description":"Which property carries the cut. Both texts ride the same curve.","default":"scale-down","options":[{"value":"scale-down","label":"Scale down (tutorial)"},{"value":"scale-up","label":"Scale up"},{"value":"tracking-in","label":"Tracking in"},{"value":"slide-left","label":"Slide left"},{"value":"slide-right","label":"Slide right"},{"value":"slide-up","label":"Slide up"},{"value":"slide-down","label":"Slide down"}]},{"id":"sweep","type":"enum","role":"style","label":"Gradient sweep","description":"Cyan-to-blue bar sweeping through the glyphs around the cut.","default":"on","options":[{"value":"on","label":"On"},{"value":"off","label":"Off"}]},{"id":"background","type":"enum","role":"style","label":"Background","description":"The tutorial navy glow, or transparent so the host shows through.","default":"navy","options":[{"value":"navy","label":"Navy glow"},{"value":"transparent","label":"Transparent"}]}]}
|
||
meta={{"category":"Effects","badge":"Stable","codeLines":324}}
|
||
attribution={{"path":"registry/components/text-match-cut","tags":["motion-primitive","transition","typography","match-cut","hard-cut","continuity"]}}
|
||
hasCode
|
||
rawUrl="https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/components/text-match-cut/text-match-cut.html"
|
||
>
|
||
|
||
<CatalogSlot slot="code">
|
||
|
||
```html text-match-cut.html
|
||
<!doctype html>
|
||
<!--
|
||
text-match-cut: HyperFrames video primitive (transitions / match-cut / connect)
|
||
|
||
Concept: one line of text becomes another in the middle of a move. Both texts
|
||
ride ONE value curve — the After Effects recipe is a single keyframe pair,
|
||
split at the speed peak, with the second text continuing the first layer's
|
||
motion. The outgoing text is removed and the incoming text appears at the same
|
||
value, still moving, so the cut lands at peak velocity with no crossfade.
|
||
|
||
Measured from the source tutorial at 60 fps: the keyframe pair is easy-eased
|
||
with 85 % influence on both sides, cubic-bezier(.85, 0, .15, 1); the split is
|
||
exactly half-way through the move; a cyan→blue gradient bar sweeps through
|
||
the glyphs on its own 87 % curve and crosses the text centre at the cut.
|
||
|
||
Variables:
|
||
- text_a (string, default "Basic Interface Behavior"): outgoing line.
|
||
- text_b (string, default "Thoughtful Experience Design"): incoming line.
|
||
- mode (enum, default scale-down): which property carries the cut.
|
||
scale-down 170 → 100 % (the tutorial's move, Z pull)
|
||
scale-up 60 → 100 % (same curve, opposite sign, Z push)
|
||
tracking-in +0.22 → −0.05 em (the line contracts through the cut)
|
||
slide-left / slide-right / slide-up / slide-down (exit and enter in
|
||
the same direction, partial travel, the incoming line lands off-centre
|
||
by the same distance the outgoing one started off-centre)
|
||
- sweep (enum on | off, default on): the gradient bar through the glyphs.
|
||
- background (enum navy | transparent, default navy): the tutorial's navy
|
||
glow, or nothing so the host scene shows through.
|
||
|
||
Envelope (fixed IN/OUT, elastic HOLD only, never timeScale):
|
||
IN_BASE = 2.00s the whole move: text A rides the curve to the peak, text
|
||
B continues it to rest
|
||
HOLD = elastic = max(0, D - (IN_BASE + OUT_BASE)); text B rests
|
||
OUT_BASE = 0.30s the shell fades cleanly
|
||
If D is shorter than IN_BASE + OUT_BASE, IN and OUT compress together and
|
||
HOLD becomes zero.
|
||
|
||
Sync point: hard-cut at CUT_AT = 1.00s in the default duration, frame 30 at
|
||
30 fps / frame 60 at 60 fps. Text A opacity becomes 0 and text B opacity
|
||
becomes 1 at the same timeline instant.
|
||
|
||
Mount contract: this is a template-wrapped sub-composition. The root has no
|
||
data-width or data-height, fills its host box, uses container query units,
|
||
and registers one paused timeline under the literal text-match-cut key.
|
||
-->
|
||
<html
|
||
lang="en"
|
||
data-composition-id="text-match-cut"
|
||
data-composition-duration="3"
|
||
data-composition-variables='[
|
||
{ "id": "text_a", "type": "string", "role": "content", "label": "Outgoing text", "description": "The line on screen before the cut.", "default": "Basic Interface Behavior" },
|
||
{ "id": "text_b", "type": "string", "role": "content", "label": "Incoming text", "description": "The line that continues the move after the cut.", "default": "Thoughtful Experience Design" },
|
||
{ "id": "mode", "type": "enum", "role": "style", "label": "Move", "description": "Which property carries the cut. Both texts ride the same curve.", "default": "scale-down", "options": [
|
||
{ "value": "scale-down", "label": "Scale down (tutorial)" }, { "value": "scale-up", "label": "Scale up" }, { "value": "tracking-in", "label": "Tracking in" },
|
||
{ "value": "slide-left", "label": "Slide left" }, { "value": "slide-right", "label": "Slide right" }, { "value": "slide-up", "label": "Slide up" }, { "value": "slide-down", "label": "Slide down" } ] },
|
||
{ "id": "sweep", "type": "enum", "role": "style", "label": "Gradient sweep", "description": "Cyan-to-blue bar sweeping through the glyphs around the cut.", "default": "on", "options": [{ "value": "on", "label": "On" }, { "value": "off", "label": "Off" }] },
|
||
{ "id": "background", "type": "enum", "role": "style", "label": "Background", "description": "The tutorial navy glow, or transparent so the host shows through.", "default": "navy", "options": [{ "value": "navy", "label": "Navy glow" }, { "value": "transparent", "label": "Transparent" }] }
|
||
]'
|
||
>
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>Text Match Cut</title>
|
||
</head>
|
||
<body>
|
||
<template>
|
||
<div id="root" data-composition-id="text-match-cut" data-duration="3" data-fps="30">
|
||
<style>
|
||
*,
|
||
*::before,
|
||
*::after {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
#root {
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
container-type: size;
|
||
isolation: isolate;
|
||
color: #ffffff;
|
||
font-family:
|
||
Inter,
|
||
system-ui,
|
||
-apple-system,
|
||
"Segoe UI",
|
||
sans-serif;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.tmx-clip,
|
||
.tmx-shell {
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.tmx-shell[data-bg="navy"] {
|
||
background: radial-gradient(
|
||
48cqw 58cqh at 50% 104%,
|
||
#05055e 0%,
|
||
#030346 25%,
|
||
#02022e 50%,
|
||
#000012 80%,
|
||
#000006 100%
|
||
);
|
||
}
|
||
|
||
/* One text per layer. The wrapper is centred and carries the move;
|
||
the two spans stack so the gradient reads through the glyphs. */
|
||
.tmx-text {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
white-space: nowrap;
|
||
font-size: 8.2cqmin;
|
||
font-weight: 450;
|
||
letter-spacing: -0.052em;
|
||
line-height: 1;
|
||
transform-origin: 50% 50%;
|
||
will-change: transform, filter;
|
||
}
|
||
|
||
.tmx-layer {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
white-space: nowrap;
|
||
letter-spacing: inherit;
|
||
}
|
||
|
||
.tmx-grad {
|
||
color: transparent;
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
background-repeat: no-repeat;
|
||
background-image: linear-gradient(
|
||
90deg,
|
||
rgba(0, 197, 255, 0) 0%,
|
||
rgba(0, 197, 255, 1) 16%,
|
||
rgba(0, 180, 255, 1) 30%,
|
||
rgba(7, 38, 249, 1) 58%,
|
||
rgba(66, 71, 255, 1) 76%,
|
||
rgba(93, 86, 255, 0) 100%
|
||
);
|
||
}
|
||
|
||
.tmx-shell[data-sweep="off"] .tmx-grad {
|
||
display: none;
|
||
}
|
||
</style>
|
||
|
||
<div
|
||
id="text-match-cut-clip"
|
||
class="tmx-clip clip"
|
||
data-start="0"
|
||
data-duration="3"
|
||
data-track-index="0"
|
||
>
|
||
<div class="tmx-shell" data-bg="navy" data-sweep="on">
|
||
<div class="tmx-text tmx-text-a" aria-label="Outgoing text">
|
||
<span class="tmx-layer tmx-white" data-layout-allow-occlusion></span
|
||
><span class="tmx-layer tmx-grad" aria-hidden="true"></span>
|
||
</div>
|
||
<div class="tmx-text tmx-text-b" aria-label="Incoming text">
|
||
<span class="tmx-layer tmx-white" data-layout-allow-occlusion></span
|
||
><span class="tmx-layer tmx-grad" aria-hidden="true"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||
<script>
|
||
(function () {
|
||
"use strict";
|
||
|
||
var root = document.getElementById("root");
|
||
var shell = root.querySelector(".tmx-shell");
|
||
var texts = [root.querySelector(".tmx-text-a"), root.querySelector(".tmx-text-b")];
|
||
var vars =
|
||
window.__hyperframes && window.__hyperframes.getVariables
|
||
? window.__hyperframes.getVariables()
|
||
: {};
|
||
|
||
var MODES = {
|
||
"scale-down": { prop: "scale", v0: 1.7, v1: 1.0, sweep: 1 },
|
||
"scale-up": { prop: "scale", v0: 0.6, v1: 1.0, sweep: 1 },
|
||
"tracking-in": { prop: "tracking", v0: 0.22, v1: -0.052, sweep: 1 },
|
||
"slide-left": { prop: "x", v0: 0.0885, v1: -0.0885, sweep: -1 },
|
||
"slide-right": { prop: "x", v0: -0.0885, v1: 0.0885, sweep: 1 },
|
||
"slide-up": { prop: "y", v0: 0.139, v1: -0.139, sweep: 1 },
|
||
"slide-down": { prop: "y", v0: -0.139, v1: 0.139, sweep: 1 },
|
||
};
|
||
var mode = MODES[vars.mode] ? MODES[vars.mode] : MODES["scale-down"];
|
||
var textA = vars.text_a == null ? "Basic Interface Behavior" : String(vars.text_a);
|
||
var textB = vars.text_b == null ? "Thoughtful Experience Design" : String(vars.text_b);
|
||
shell.dataset.bg = vars.background === "transparent" ? "transparent" : "navy";
|
||
shell.dataset.sweep = vars.sweep === "off" ? "off" : "on";
|
||
var copy = [textA, textB];
|
||
texts.forEach(function (el, i) {
|
||
el.querySelector(".tmx-white").textContent = copy[i];
|
||
el.querySelector(".tmx-grad").textContent = copy[i];
|
||
});
|
||
|
||
// cubic-bezier(a, 0, 1 - a, 1), solved by bisection. 0.85 is the
|
||
// measured AE easy-ease influence; 0.87 is the gradient precomp's.
|
||
function bez(a, x) {
|
||
var p1x = a,
|
||
p2x = 1 - a,
|
||
lo = 0,
|
||
hi = 1,
|
||
t,
|
||
bx;
|
||
for (var i = 0; i < 28; i++) {
|
||
t = (lo + hi) / 2;
|
||
bx = 3 * (1 - t) * (1 - t) * t * p1x + 3 * (1 - t) * t * t * p2x + t * t * t;
|
||
if (bx < x) lo = t;
|
||
else hi = t;
|
||
}
|
||
t = (lo + hi) / 2;
|
||
return 3 * (1 - t) * t * t + t * t * t;
|
||
}
|
||
var E85 = function (x) {
|
||
return bez(0.85, x);
|
||
};
|
||
var EBAR = function (x) {
|
||
return bez(0.87, x);
|
||
};
|
||
|
||
var IN_BASE = 2.0;
|
||
var OUT_BASE = 0.3;
|
||
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "3"));
|
||
var envelopeBase = IN_BASE + OUT_BASE;
|
||
var phaseScale = duration < envelopeBase ? duration / envelopeBase : 1;
|
||
var IN = IN_BASE * phaseScale;
|
||
var OUT = OUT_BASE * phaseScale;
|
||
var CUT_AT = IN / 2;
|
||
var HOLD = Math.max(0, duration - IN - OUT);
|
||
var OUT_START = IN + HOLD;
|
||
var FPS = parseFloat(root.dataset.fps || "30") || 30;
|
||
|
||
// Geometry, measured at build time so the tweens are pure functions of time.
|
||
var W = root.clientWidth || 1920;
|
||
var H = root.clientHeight || 1080;
|
||
var FS = parseFloat(getComputedStyle(texts[0]).fontSize) || H * 0.082;
|
||
var natW = texts.map(function (el, i) {
|
||
var w = el.querySelector(".tmx-white").getBoundingClientRect().width;
|
||
return w > 0 ? w : copy[i].length * FS * 0.47;
|
||
});
|
||
var CX = W / 2;
|
||
var D_GRAD = (2316 / 1920) * W; // gradient bar travel
|
||
var XG_CUT = CX - (12 / 1920) * W; // bar centre at the cut
|
||
var BAR = (720 / 1920) * W; // bar width (~620 px of it reads as colour)
|
||
texts.forEach(function (el, i) {
|
||
gsap.set(el, {
|
||
width: natW[i],
|
||
height: FS,
|
||
xPercent: -50,
|
||
yPercent: -50,
|
||
transformOrigin: "50% 50%",
|
||
});
|
||
});
|
||
|
||
// One value curve. p = E85(t / IN); value = v0 + (v1 - v0) * p; both texts read it.
|
||
var clock = { t: 0 };
|
||
function apply() {
|
||
var tt = Math.min(1, Math.max(0, clock.t));
|
||
var p = E85(tt);
|
||
var val = mode.v0 + (mode.v1 - mode.v0) * p;
|
||
var s = mode.prop === "scale" ? val : 1;
|
||
var x = mode.prop === "x" ? val * W : 0;
|
||
var y = mode.prop === "y" ? val * H : 0;
|
||
var ls = mode.prop === "tracking" ? val : -0.052;
|
||
var xg = XG_CUT + (EBAR(tt) - 0.5) * D_GRAD * mode.sweep;
|
||
// motion smear stands in for AE motion blur: proportional to px per frame
|
||
var dp =
|
||
(E85(Math.min(1, tt + 0.002)) - E85(Math.max(0, tt - 0.002))) / 0.004 / IN / FPS;
|
||
var v = 0;
|
||
if (mode.prop === "x") v = Math.abs((mode.v1 - mode.v0) * W * dp);
|
||
else if (mode.prop === "y") v = Math.abs((mode.v1 - mode.v0) * H * dp);
|
||
else if (mode.prop === "scale")
|
||
v = Math.abs(((mode.v1 - mode.v0) * dp * natW[tt < 0.5 ? 0 : 1]) / 2);
|
||
else v = Math.abs((mode.v1 - mode.v0) * dp * FS * 12);
|
||
var blur = Math.min(6, v * 0.07) * (H / 1080);
|
||
texts.forEach(function (el, i) {
|
||
var w = natW[i] + (ls + 0.052) * FS * copy[i].length;
|
||
if (mode.prop === "tracking") gsap.set(el, { width: w });
|
||
el.style.letterSpacing = ls + "em";
|
||
gsap.set(el, { scale: s, x: x, y: y, filter: "blur(" + blur.toFixed(2) + "px)" });
|
||
var bw = BAR / s;
|
||
var g = el.querySelector(".tmx-grad");
|
||
g.style.backgroundSize = bw + "px 100%";
|
||
g.style.backgroundPositionX = w / 2 + (xg - CX - x) / s - bw / 2 + "px";
|
||
});
|
||
}
|
||
|
||
var tl = gsap.timeline({ paused: true });
|
||
|
||
tl.set(shell, { opacity: 0 }, 0);
|
||
tl.set(texts[0], { autoAlpha: 1 }, 0);
|
||
tl.set(texts[1], { autoAlpha: 0 }, 0);
|
||
tl.to(shell, { opacity: 1, duration: Math.min(0.2, IN), ease: "power2.out" }, 0);
|
||
tl.fromTo(
|
||
clock,
|
||
{ t: 0 },
|
||
{ t: 1, duration: IN, ease: "none", onUpdate: apply, immediateRender: false },
|
||
0,
|
||
);
|
||
|
||
// HARD CUT at the speed peak: text A leaves, text B arrives at the same value.
|
||
tl.set(texts[0], { autoAlpha: 0 }, CUT_AT);
|
||
tl.set(texts[1], { autoAlpha: 1 }, CUT_AT);
|
||
|
||
tl.to(shell, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
|
||
|
||
apply();
|
||
tl.seek(0);
|
||
window.__timelines = window.__timelines || {};
|
||
window.__timelines["text-match-cut"] = tl;
|
||
})();
|
||
</script>
|
||
</div>
|
||
</template>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
</CatalogSlot>
|
||
|
||
<CatalogSlot slot="install">
|
||
|
||
<InstallCommand command="npx hyperframes add text-match-cut" item="text-match-cut" />
|
||
|
||
That writes one file: `compositions/components/text-match-cut.html`.
|
||
|
||
</CatalogSlot>
|
||
|
||
<CatalogSlot slot="docs">
|
||
|
||
## Paste it into your composition
|
||
|
||
Open `compositions/components/text-match-cut.html` and copy what is inside into your own composition.
|
||
|
||
A component has no size or duration of its own. It takes both from the composition
|
||
you paste it into.
|
||
|
||
## Variables
|
||
|
||
Every one of these has a default, so the piece works untouched. Set the ones you
|
||
want to change on the element:
|
||
|
||
| Variable | Default | Accepts | What it does |
|
||
| --- | --- | --- | --- |
|
||
| `text_a` | `Basic Interface Behavior` | string | The line on screen before the cut. |
|
||
| `text_b` | `Thoughtful Experience Design` | string | The line that continues the move after the cut. |
|
||
| `mode` | `scale-down` | `scale-down`, `scale-up`, `tracking-in`, `slide-left`, `slide-right`, `slide-up`, `slide-down` | Which property carries the cut. Both texts ride the same curve. |
|
||
| `sweep` | `on` | `on`, `off` | Cyan-to-blue bar sweeping through the glyphs around the cut. |
|
||
| `background` | `navy` | `navy`, `transparent` | The tutorial navy glow, or transparent so the host shows through. |
|
||
|
||
Set them with `data-variable-values` on the element that mounts it. These are the
|
||
defaults, so this behaves exactly like the preview above until you change one:
|
||
|
||
```html wrap
|
||
<div
|
||
data-composition-id="text-match-cut"
|
||
data-composition-src="compositions/components/text-match-cut.html"
|
||
data-variable-values='{"text_a":"Basic Interface Behavior","text_b":"Thoughtful Experience Design","mode":"scale-down","sweep":"on","background":"navy"}'
|
||
></div>
|
||
```
|
||
|
||
{/* hf:generated-footer */}
|
||
|
||
Tagged `motion-primitive` `transition` `typography` `match-cut` `hard-cut` `continuity`.
|
||
|
||
## Related topics
|
||
|
||
- [Browse the complete Catalog](/catalog)
|
||
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
|
||
- [Build a richer composition](/go-further)
|
||
|
||
</CatalogSlot>
|
||
|
||
</CatalogDetail>
|