// ===== demos/typography/type-assembly-moves/LetterformDriftAssembly.tsx ===== // 字形漂移合拢(letterform-drift-assembly)——Stranger Things 片头式入场。 // 标题 "ASSEMBLE" 拆 9 字符:各自从不同方向(h(i) seeded 随机向量, // 幅度 ±260–360px)带 blur 8px + opacity 0.35 缓慢漂入,错峰归位 // (delay i×3f,45f 行程,Easing.out(cubic))。每字锁定瞬间给一次 // "加深脉冲":字色 G.ink→#000→G.ink + 描边 0→3px→0(8f)——白底上 // 不用发光用加深(库判例)。全部合体后整词 scale 1→1.04→1 收束呼吸 // (判例:1.02 太弱,加码到 1.04)。 // 关键帧:0–24 各字错峰启程 → i 字 [i*3, i*3+45] 漂入归位 → // 锁定帧 i*3+45 起 8f 加深脉冲(最后一字 69–77)→ 80–104 整词呼吸 → // 104–150 全静止(46f,无逐帧滤镜)。 import React from 'react'; import { useCurrentFrame, interpolate, Easing } from 'remotion'; import { G, TitleBlock } from '../../_fixtures/Fixtures'; const h = (n: number) => { const s = Math.sin(n * 127.3) * 43758.5453; return s - Math.floor(s); }; const WORD = 'ASSEMBLE'; const TRAVEL = 45; // 每字漂入行程帧数 const STAG = 3; // 错峰间隔 export const LetterformDriftAssembly: React.FC = () => { const frame = useCurrentFrame(); const chars = WORD.split(''); const lastLock = (chars.length - 1) * STAG + TRAVEL; // 69 // 整词收束呼吸:80–92 放大到 1.04,92–104 回落,之后恒 1 → 帧确定 const breath = frame < 92 ? interpolate(frame, [80, 92], [1, 1.04], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.cubic), }) : interpolate(frame, [92, 104], [1.04, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.cubic), }); return (
{chars.map((c, i) => { const start = i * STAG; const lock = start + TRAVEL; // 漂入进度 const p = interpolate(frame, [start, lock], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic), }); // seeded 起始向量:方向 h(i),幅度 260–360px const ang = h(i + 1) * Math.PI * 2; const mag = 260 + h(i + 101) * 100; const dx = Math.cos(ang) * mag * (1 - p); const dy = Math.sin(ang) * mag * (1 - p); const blur = 8 * (1 - p); const op = interpolate(p, [0, 1], [0.35, 1]); // 锁定加深脉冲:lock→lock+8,三角波 0→1→0 const pulse = frame <= lock || frame >= lock + 8 ? 0 : frame < lock + 4 ? (frame - lock) / 4 : (lock + 8 - frame) / 4; const shade = Math.round(47 * (1 - pulse)); // #2f(47)→#00 const color = `rgb(${shade},${shade},${shade})`; const strokeW = 3 * pulse; return ( 0.01 ? `blur(${blur}px)` : undefined, WebkitTextStroke: strokeW > 0.01 ? `${strokeW}px #000` : undefined, }} > {c} ); })}
); }; // ===== demos/typography/type-assembly-moves/SplitTextStagger.tsx ===== // 逐字遮罩裂升(split-text-stagger)——GSAP SplitText 惯用入场。 // 标题 "MOTION SYSTEM" 按字符拆 span,每字外包 overflow:hidden 的行高盒, // 内层从 translateY(115%) 升到 0,各 14f Easing.out(cubic),带 10% 过冲 // 再 6f 回落(原案 6% 过冲,按可感性红线加码到 10%)。delay = 字符索引×2f。 // 底部基线细线(G.bar 2px)在首字起跳同帧从左向右生长,暗示裁切线存在。 // 关键帧:0–12 空场 hold → 12 首字起跳 + 基线开始生长 → 每字 12+i*2 起跳, // 14f 升至 -10% 过冲 → 再 6f 回落归 0 → 末字(索引12)于 56f 落定 → // 56–130 全静止(74f ≥ 40f,无逐帧噪声层)。 import React from 'react'; import { useCurrentFrame, interpolate, Easing } from 'remotion'; import { G, TitleBlock } from '../../_fixtures/Fixtures'; const TEXT = 'MOTION SYSTEM'; const START = 12; // 首字起跳帧 const RISE = 14; // 升起时长 const SETTLE = 6; // 过冲回落时长 const OVERSHOOT = -10; // 过冲到 -10%(原案 6%,加码) const FONT = 120; // 单字符纵向位移(%):115 → -10(out cubic)→ 0(out quad),帧确定 const charY = (f: number, idx: number): number => { const t0 = START + idx * 2; if (f < t0 + RISE) { return interpolate(f, [t0, t0 + RISE], [115, OVERSHOOT], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic), }); } return interpolate(f, [t0 + RISE, t0 + RISE + SETTLE], [OVERSHOOT, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.quad), }); }; export const SplitTextStagger: React.FC = () => { const frame = useCurrentFrame(); const chars = TEXT.split(''); // 基线:首字起跳同帧开始,从左向右生长到 100% const lineW = interpolate(frame, [START, START + 26], [0, 100], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic), }); return (
{chars.map((c, i) => (
{c === ' ' ? ' ' : c}
))}
{/* 基线细线:宽度从 0 生长到全文宽 */}
); }; // ===== demos/typography/type-assembly-moves/TextOnPath.tsx ===== // 文字沿曲线流入(text-on-path)——字符沿一条上升贝塞尔曲线(像图表增长线) // 鱼贯滑入,行进中按切线角旋转;到达各自终点后再从"贴线姿态"lerp 到水平基线位 // 拼成正常标题。曲线本身随字符前进同步 evolve(dashoffset 生长)。 // 关键帧:字符 i 于 i*2 起跑、45f 沿线到达 t_i(out cubic)→ 到达后停 8f → // 12f 摆正到水平基线(y 拉平、rotate→0)→ 全部落定约 f103 → 103–150 真静止。 import React from 'react'; import { useCurrentFrame, interpolate, Easing } from 'remotion'; import { G, TitleBlock } from '../../_fixtures/Fixtures'; // 上升贝塞尔:左下 → 右上,先缓后陡(增长线形状) const P0 = { x: 200, y: 820 }; const P1 = { x: 760, y: 810 }; const P2 = { x: 1240, y: 660 }; const P3 = { x: 1500, y: 320 }; const bez = (t: number) => { const u = 1 - t; return { x: u * u * u * P0.x + 3 * u * u * t * P1.x + 3 * u * t * t * P2.x + t * t * t * P3.x, y: u * u * u * P0.y + 3 * u * u * t * P1.y + 3 * u * t * t * P2.y + t * t * t * P3.y, }; }; // 切线角(度) const tangent = (t: number) => { const u = 1 - t; const dx = 3 * u * u * (P1.x - P0.x) + 6 * u * t * (P2.x - P1.x) + 3 * t * t * (P3.x - P2.x); const dy = 3 * u * u * (P1.y - P0.y) + 6 * u * t * (P2.y - P1.y) + 3 * t * t * (P3.y - P2.y); return (Math.atan2(dy, dx) * 180) / Math.PI; }; const TEXT = 'GROWTH ALL THE WAY'; const N = TEXT.length; const CHAR_W = 46; // 64px fontWeight 800 的近似字宽 const FINAL_Y = 300; // 水平基线(标题最终落位) const FINAL_X0 = 960 - (N * CHAR_W) / 2; export const TextOnPath: React.FC = () => { const frame = useCurrentFrame(); // 曲线 evolve:随最前字符推进同步生长 const evolve = interpolate(frame, [0, 82], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.quad), }); return (
{TEXT.split('').map((ch, i) => { if (ch === ' ') return null; const tEnd = 0.12 + 0.82 * (i / (N - 1)); // 各字符沿线终点 const start = i * 2; // 沿线推进:start 起 45f 到达 tEnd const t = interpolate(frame, [start, start + 45], [0, tEnd], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic), }); const pOnCurve = bez(t); const angOnCurve = tangent(t); // 到达后停 8f,再 12f 摆正到水平基线 const settle = interpolate(frame, [start + 53, start + 65], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.cubic), }); const x = pOnCurve.x + (FINAL_X0 + i * CHAR_W - pOnCurve.x) * settle; const y = pOnCurve.y + (FINAL_Y - pOnCurve.y) * settle; const ang = angOnCurve * (1 - settle); const op = interpolate(frame, [start, start + 8], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }); return (
{ch}
); })}
); }; // ===== demos/typography/type-assembly-moves/TrackingExpandReveal.tsx ===== // 字距呼吸展开(tracking-expand-reveal)——电影片头字幕惯用的 letter-spacing 入场。 // 标题 "BREATHE"(150px)出场时字符几乎叠压(相当于 letter-spacing -0.42em), // 50 帧内展开到 0.14em,同步 blur 10px→0、opacity 0.6→1、scaleX 0.92→1。 // 不动 letter-spacing 本身(逐帧重排会抖):容器常量 letterSpacing 0.14em 定终位, // 逐字符 span 用 translateX 手动插值——第 i 字起始位移 = (i - 中心) × 每缝差值 84px, // 全部挤向词心,位移只与字缝有关、与字宽无关,天然对中。 // 关键帧:0–50 展开(Easing.out(poly(5)))+去糊提亮 → 35–58 副标题淡入(≈主词展开 70% 时点) // → 58–130 全静止(≥72f,滤镜彻底摘除)。 import React from 'react'; import { useCurrentFrame, interpolate, Easing } from 'remotion'; import { G, TitleBlock } from '../../_fixtures/Fixtures'; const WORD = 'BREATHE'; const FS = 150; // 主词字号 // 每个字缝的起始-终点差:(-0.42em) - (0.14em) = -0.56em = -84px @150px const GAP_DELTA = -0.56 * FS; export const TrackingExpandReveal: React.FC = () => { const frame = useCurrentFrame(); // 展开进度 0→1(0–50f,out poly(5)) const p = interpolate(frame, [0, 50], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.poly(5)), }); const blur = 10 * (1 - p); const op = interpolate(p, [0, 1], [0.6, 1]); const sx = interpolate(p, [0, 1], [0.92, 1]); // 副标题:主词时间轴走到 70%(帧 35)起淡入 const subOp = interpolate(frame, [35, 58], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.quad), }); const N = WORD.length; const center = (N - 1) / 2; const settled = frame >= 50; // 展开完成后摘掉一切滤镜/变换,保证逐帧完全相同 return (
{/* 主词:容器 letterSpacing 恒为 0.14em(终态),字符仅做 translateX */}
{WORD.split('').map((ch, i) => { const tx = (1 - p) * (i - center) * GAP_DELTA; return ( {ch} ); })}
{/* 副标题:主词展开 70% 时点淡入 */}
= 58 ? 1 : subOp, }} > A CINEMATIC TITLE ENTRANCE
); };