// ===== 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
);
};