// ===== demos/rhythm/trailer-grammar-moves/CardFootageCadence.tsx =====
// card-footage-cadence|字卡穿插节奏
// UI 镜头 ↔ 黑底字卡像对话接拍:0–14f UI A 缓推 → 硬切 8f 字卡 SHIP →
// 硬切 12f UI B 1.6x 裁切缓移 → 8f 字卡 FASTER → 10f UI A 2x 裁切 →
// 10f 字卡 TODAY(下划线) → 62f 硬切收尾全景定格。
// UI 段全部带微动(推/移),字卡段除落定微缩(1.05→1, out-cubic)外全静,
// 动↔静对比即手法本体。所有切换零过渡(条件挂载,无 crossfade)。
// 收尾 62–105f 轻推收完,105–150f 真静止 45f ≥ 40f。帧确定,无随机。
import React from 'react';
import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from 'remotion';
import { FakeDashboard } from '../../_fixtures/Fixtures';
const CLAMP = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const;
// 黑底白字字卡:落定微缩 1.05→1(out-cubic,段内前 5f 完成),其余全静
const TitleCard: React.FC<{ text: string; local: number; underline?: boolean }> = ({
text,
local,
underline = false,
}) => {
const scale = interpolate(local, [0, 5], [1.05, 1], {
...CLAMP,
easing: Easing.out(Easing.cubic),
});
return (
{text}
{underline && (
)}
);
};
// UI 镜头:包一层 transform 做缓推/缓移,硬切由外层条件挂载保证
const UiShot: React.FC<{
variant: 'A' | 'B';
transform: string;
origin?: string;
}> = ({ variant, transform, origin = '50% 50%' }) => (
);
export const CardFootageCadence: React.FC = () => {
const frame = useCurrentFrame();
// ---- 分段:切点 14 / 22 / 34 / 42 / 52 / 62,总长 150 ----
// 段1 0–14f:UI A 全景缓推 1.0→1.08
if (frame < 14) {
const s = interpolate(frame, [0, 14], [1, 1.08], CLAMP);
return ;
}
// 段2 14–22f:字卡 SHIP
if (frame < 22) {
return ;
}
// 段3 22–34f:UI B 1.6x 裁切 + 横向缓移(120px 内容位移,屏上 ~192px)。
// origin 偏左让列表行图标入画,读得出是 UI(QA 后调:50%→35%)
if (frame < 34) {
const tx = interpolate(frame - 22, [0, 12], [60, -60], CLAMP);
return (
);
}
// 段4 34–42f:字卡 FASTER
if (frame < 42) {
return ;
}
// 段5 42–52f:UI A 另一处 2x 裁切(左上卡片区)+ 继续缓推 2.0→2.14
if (frame < 52) {
const s = interpolate(frame - 42, [0, 10], [2, 2.14], CLAMP);
return ;
}
// 段6 52–62f:字卡 TODAY + 下划线
if (frame < 62) {
return ;
}
// 段7 62–150f:收尾全景 UI 定格。62–105f 极缓推收完(out-cubic),
// 105f 后 transform 值恒为 1.02,105–150f 真静止 45f。
const s = interpolate(frame, [62, 105], [1, 1.02], {
...CLAMP,
easing: Easing.out(Easing.cubic),
});
return ;
};
// ===== demos/rhythm/trailer-grammar-moves/SmashCut.tsx =====
// smash-cut|猛切(喧闹→死寂)
// 0–41f 轰鸣段:5 张 Card 多方向高速飞掠冲镜(穿屏 translate + scale 1.5→3
// 冲脸 + 微 rotate,各卡错峰跑两轮,第二轮更快=整体仍在加速),背景
// FakeDashboard A 以 ease-in 持续加速推近 1→1.55 并滚动 rotate 1.8°——
// 切点前一刻动势最猛。42f 一帧硬切 variant B 整齐静止全景,无一物在动,
// 停满 93f(>50f)。反差即手法本体。总 135f。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G, Card, FakeDashboard } from '../../_fixtures/Fixtures';
const CUT = 42; // 硬切帧:>=42 全静止
// 每张飞卡:两轮 pass,第二轮更短(更快),ease-in 让每一程都在加速。
// from/to 为屏幕外起终点(中心系坐标),飞行中 scale 1.5→3 冲脸。
type Fly = {
from: [number, number];
to: [number, number];
rot: [number, number]; // 微旋转 起→终
seed: number;
w: number;
h: number;
};
const FLIES: Fly[] = [
{ from: [-1400, -120], to: [1400, 60], rot: [-6, 5], seed: 1, w: 440, h: 290 },
{ from: [1400, 180], to: [-1400, -100], rot: [7, -4], seed: 2, w: 400, h: 260 },
{ from: [-300, -900], to: [200, 900], rot: [-3, 8], seed: 3, w: 460, h: 300 },
{ from: [-1300, 800], to: [1300, -750], rot: [5, -7], seed: 4, w: 420, h: 280 },
{ from: [1350, -780], to: [-1350, 820], rot: [-8, 4], seed: 5, w: 480, h: 310 },
];
// 卡 i 的第 k 轮:start = i*4 + k*20,时长 16 / 12(第二轮更快)。
// card4 第二轮 36–48f 在切点 42f 被硬切截断——正中"绝不减速迎接切点"。
const passWindow = (i: number, k: number): [number, number] => {
const start = i * 4 + k * 20;
const dur = k === 0 ? 16 : 12;
return [start, start + dur];
};
const FlyCard: React.FC<{ fly: Fly; i: number; frame: number }> = ({ fly, i, frame }) => {
// 找当前活跃的 pass(两轮)
let active: [number, number] | null = null;
for (let k = 0; k < 2; k++) {
const [s, e] = passWindow(i, k);
if (frame >= s && frame < e) { active = [s, e]; break; }
}
if (!active) return null;
const [s, e] = active;
// ease-in:全程加速,越接近终点越快
const p = interpolate(frame, [s, e], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.in(Easing.quad),
});
const x = fly.from[0] + (fly.to[0] - fly.from[0]) * p;
const y = fly.from[1] + (fly.to[1] - fly.from[1]) * p;
const rot = fly.rot[0] + (fly.rot[1] - fly.rot[0]) * p;
const scale = 1.5 + 1.5 * p; // 1.5 → 3 冲脸
// 速度门控方向模糊感:ease-in 下瞬时速度 ∝ p,速度越快越糊
const blur = 1 + 4 * p;
return (
);
};
export const SmashCut: React.FC = () => {
const frame = useCurrentFrame();
// —— 死寂段:42f 起 variant B 整齐静止全景,无任何动画属性 ——
if (frame >= CUT) {
return ;
}
// —— 轰鸣段:背景 ease-in 加速推近 + 滚动,切点前 3f 仍在加速 ——
const bgScale = interpolate(frame, [0, CUT], [1, 1.55], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.in(Easing.quad),
});
const bgRot = interpolate(frame, [0, CUT], [0, 1.8], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.in(Easing.quad),
});
return (
{FLIES.map((fly, i) => (
))}
);
};
// ===== demos/rhythm/trailer-grammar-moves/TrailerBumper.tsx =====
// trailer-bumper|前置速剪预告
// 结构即全部:三连 9f 速剪(A 整页 / B 整页 / A 2.2x 怼脸特写)→ 6f 纯黑喘息
// → 正式开场大标题淡入+微升 16f 定格。每个速剪镜头内部带 1→1.04 轻推近。
// 收尾真静止:49f 后所有动画结束,静止 91f(>40f)。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G, FakeDashboard } from '../../_fixtures/Fixtures';
// 时间轴(帧)
const CUT_1 = 0; // 镜头1:variant A 整页
const CUT_2 = 9; // 镜头2:variant B 整页
const CUT_3 = 18; // 镜头3:variant A 2.2x 怼脸特写
const BLACK = 27; // 纯黑静默
const TITLE = 33; // 正式开场
const TITLE_IN = 16; // 标题淡入+微升时长
// 速剪镜头内部轻推近:镜头内 local 0→9f,scale 1→1.04(线性,活镜头感)
const push = (frame: number, start: number) =>
interpolate(frame, [start, start + 9], [1, 1.04], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
export const TrailerBumper: React.FC = () => {
const frame = useCurrentFrame();
// —— 段落 1–3:三连速剪 ——
if (frame < BLACK) {
let inner: React.ReactNode;
if (frame < CUT_2) {
// 镜头1:A 整页 + 轻推近
inner = (
);
} else if (frame < CUT_3) {
// 镜头2:B 整页 + 轻推近
inner = (
);
} else {
// 镜头3:A 的 2.2x 放大裁切怼脸特写(对准中列卡片),叠加同样的轻推近
const s = 2.2 * push(frame, CUT_3);
inner = (
);
}
return (
{inner}
);
}
// —— 段落 4:纯黑静默 6f,必须纯黑无物 ——
if (frame < TITLE) {
return ;
}
// —— 段落 5:正式开场——浅底大标题淡入+微升 16f 后定格 ——
const opacity = interpolate(frame, [TITLE, TITLE + TITLE_IN], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const rise = interpolate(frame, [TITLE, TITLE + TITLE_IN], [44, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
});
return (
);
};