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