// ===== demos/typography/typewriter-moves/TerminalTypewriter.tsx =====
// terminal-typewriter —— 终端打字机触发
// 深色终端窗居中,"$ acme deploy --prod" 逐字符敲出(2f/字符,帧确定
// substring),方块光标 12f 方波闪 → 敲完停 12f → 回车帧:整场景 6f
// Easing.in(cubic) 急推 scale 1→3.2 向命令行推入(末 2f 加 blur)硬切到
// FakeDashboard A 全屏,1.06→1 回稳 4f 落定。收尾真静止 ≥40f。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G, FakeDashboard } from '../../_fixtures/Fixtures';
const CMD = 'acme deploy --prod';
// 时间轴(30fps,共 145f)
const T = {
typeStart: 10, // 开始敲字
typeEnd: 10 + CMD.length * 2, // 46:18 字符 × 2f
enter: 58, // 敲完停 12f 后回车
pushEnd: 64, // 6f 急推结束,硬切帧
settleEnd: 68, // dashboard 1.06→1 回稳 4f
total: 145, // f68 起真静止 77f
};
// 终端窗几何
const TW = 1100;
const TH = 620;
const TL = (1920 - TW) / 2; // 410
const TT = (1080 - TH) / 2; // 230
const TITLEBAR = 52;
const PAD = 34;
// 命令行基线(推入焦点):标题栏下第二行文字中心
const FOCUS_X = 960;
const FOCUS_Y = TT + TITLEBAR + PAD + 92; // ≈ 408
const TerminalWindow: React.FC<{ chars: number; cursorOn: boolean }> = ({ chars, cursorOn }) => (
{/* 标题栏:三圆点窗控(灰阶) */}
{['#6a6a68', '#8f8f8d', '#b5b5b3'].map((c, i) => (
))}
{/* 内容区 */}
{/* 一行历史输出做上下文 */}
~/acme-app (main)
{'$ '}
{CMD.substring(0, chars)}
);
export const TerminalTypewriter: React.FC = () => {
const frame = useCurrentFrame();
// 帧确定打字:2f/字符
const chars = Math.min(CMD.length, Math.max(0, Math.floor((frame - T.typeStart) / 2)));
// 方块光标 12f 周期方波闪(全程)
const cursorOn = frame % 12 < 6;
// 回车急推:整场景 scale 1→3.2,6f Easing.in(cubic),向命令行推入
const pushScale = interpolate(frame, [T.enter, T.pushEnd], [1, 3.2], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.in(Easing.cubic),
});
// 末 2f 运动模糊(f62–f64),硬切后摘罩=条件挂载
const pushBlur = interpolate(frame, [T.pushEnd - 2, T.pushEnd], [0, 10], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
// 硬切:f64 起终端场景整体卸载,dashboard 挂载
const cut = frame >= T.pushEnd;
// dashboard 落定:1.06→1 回稳 4f
const dashScale = interpolate(frame, [T.pushEnd, T.settleEnd], [1.06, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
});
return (
{!cut ? (
0 ? { filter: `blur(${pushBlur}px)` } : {}),
}}>
) : (
)}
);
};
// ===== demos/typography/typewriter-moves/TypewriterErrorRetype.tsx =====
// typewriter-error-retype|打字机误删重打
// 浅底居中大字:f2 起 2f/字符打出 "just a dashboard"(16 字符) → f32–48 停顿
// 16f(光标闪两下=犹豫) → f48 起 1.5f/字符逐字退格删掉 "a dashboard"(11 字符,
// 字符直接消失) → f68 起 1.5f/字符果断打出 "your command center"(19 字符,
// f95 打完) → 光标闪两个周期(10f/周期)后 f110 永久熄灭。节奏三档:打 2f/删
// 1.5f/重打 1.5f 且无停顿。f110 后全静止,160f 总长 → 收尾真静止 50f。
// 等宽感:逐字符固定宽 span,无 letter-spacing 动画。全部帧确定。
import React from 'react';
import { useCurrentFrame } from 'remotion';
import { G } from '../../_fixtures/Fixtures';
const TEXT1 = 'just a dashboard'; // 16 chars
const KEEP = 5; // "just " 保留
const DEL = TEXT1.length - KEEP; // 11 chars 待删
const TEXT2 = 'your command center'; // 19 chars
const T1 = 2; // 第一遍打字起点,2f/字符
const PAUSE_START = T1 + (TEXT1.length - 1) * 2; // f32 打完
const DS = PAUSE_START + 16; // f48 开删,1.5f/字符
const RS = 68; // 重打起点,1.5f/字符(删完 f64.5 后小顿 3.5f)
const TYPE2_END = RS + (TEXT2.length - 1) * 1.5; // f95 打完
const CURSOR_OFF = 115; // 两个 10f 闪烁周期后熄灭(最后一次视觉变化 f110)
const CHAR_W = 58; // fontSize 96 的等宽感字符宽
// 光标可见性:打字/删除时常亮;停顿段 8f 周期闪两下;打完后 10f 周期闪两下;之后永灭
const cursorOn = (f: number): boolean => {
if (f >= CURSOR_OFF) return false;
if (f >= TYPE2_END) {
// f95 起:on[95,100) off[100,105) on[105,110) off[110,115)
return Math.floor((f - TYPE2_END) / 5) % 2 === 0;
}
if (f >= DS) return true; // 删除 + 重打:常亮(果断)
if (f >= PAUSE_START) {
// 犹豫段 f32–48:on[32,36) off[36,40) on[40,44) off[44,48)
return Math.floor((f - PAUSE_START) / 4) % 2 === 0;
}
return true; // 第一遍打字:常亮
};
export const TypewriterErrorRetype: React.FC = () => {
const f = useCurrentFrame();
// 第一遍已打出字符数
const n1 = f < T1 ? 0 : Math.min(TEXT1.length, Math.floor((f - T1) / 2) + 1);
// 已删除字符数(从尾部删)
const removed = f < DS ? 0 : Math.min(DEL, Math.floor((f - DS) / 1.5) + 1);
// 第二遍已打出字符数
const n2 = f < RS ? 0 : Math.min(TEXT2.length, Math.floor((f - RS) / 1.5) + 1);
const shown =
TEXT1.slice(0, Math.max(KEEP, n1 - removed)).slice(0, n1) +
TEXT2.slice(0, n2);
const chars = shown.split('');
return (
{/* 左缘锚定:最终句 24 字符×58=1392px,左起 264 恰好整体居中;
打字过程不横移(真打字机感) */}
{chars.map((c, i) => (
{c === ' ' ? ' ' : c}
))}
{/* 光标:竖线,条件挂载而非 opacity 0 */}
{cursorOn(f) && (
)}
);
};