SceneFlyIn 发牌镜头 深度解析
这页拆一个高能量镜头:牌堆特写、硬加速发牌、追逐式滚动、搜索筛选和点击推近。
导入链与契约
发牌镜头证据 01:src/aifl/live/SceneFlyIn.tsx:8、src/aifl/live/SceneFlyIn.tsx:17、src/aifl/live/SceneFlyIn.tsx:25、src/aifl/live/SceneFlyIn.tsx:34、src/aifl/live/SceneFlyIn.tsx:44。这一组行号说明 deck-deal 不是简单 grid 动画:它先定义真实槽位和额外行,再用确定性 cue、堆叠高度、相机 key 和两段缓动,把“内容源源不断汇入”的语义落成可复现帧序列。
1import { Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion'; 2import { PageCam, CamKey } from './PageCam'; 3import layout from '../live-layout.json'; 4 5const cards = layout.projects.cards; 6const PAGE_H = layout.projects.pageH; 7 8const HOVER_H = 40; // px above the slot (translateZ) a card hovers at mid-dive 9const SETTLE_EASE = Easing.bezier(0.3, 0, 0.25, 1.15); // slight overshoot / compress 10const DIVE_EASE = Easing.bezier(0.3, 0, 0.2, 1); // decelerate into the hover 11const SLIDE_EASE = Easing.bezier(0.35, 0, 0.2, 1); // nano-lab card slide to the top slot 12 13// page paper colors, sampled from the rendered projects-empty texture 14const PAPER = '#f9f6f1'; // page background (bottom rows) 15const FIELD = '#fefcf9'; // search-box interior 16 17// ---- 16 "overflow" extras extend the grid DOWNWARD (no hovering, no overlap): 18// fill the empty 3rd column of the y=1402 row, then five new rows below the 19// texture at y=1795…3367, columns matching the real grid. ---- 20const COLS = [408, 781.328125, 1154.65625]; 21const CARD_W = 357.328125; 22const EXTRA_ROWS = [1795, 2188, 2581, 2974, 3367]; 23const PAPER_EXT = { x: 0, y: PAGE_H, w: 1920, h: 2036 }; // paper extension below the texture (flush bottom 3782) 24
核心实现,逐段拆
▸卡片网格与 cue 公式
发牌镜头证据 02:src/aifl/live/SceneFlyIn.tsx:51、src/aifl/live/SceneFlyIn.tsx:58、src/aifl/live/SceneFlyIn.tsx:67、src/aifl/live/SceneFlyIn.tsx:79、src/aifl/live/SceneFlyIn.tsx:97。这一组行号说明 deck-deal 不是简单 grid 动画:它先定义真实槽位和额外行,再用确定性 cue、堆叠高度、相机 key 和两段缓动,把“内容源源不断汇入”的语义落成可复现帧序列。
51const grid = [ 52 ...cards.map((c) => ({ file: c.file, x: c.x, y: c.y, w: c.w, h: c.h, title: c.title })), 53 ...extras, 54] 55 .sort((a, b) => a.y - b.y || a.x - b.x) 56 .map((c, k) => ({ 57 ...c, 58 cue: DEAL_START + 4 * k - 0.0792 * k * (k - 1), 59 // pile pose: tiny alternating offsets + roll, stacked top-down by k 60 px: PILE.x + (((k * 7) % 9) - 4) * 2, 61 py: PILE.y + (((k * 5) % 7) - 3) * 2, 62 protZ: ((k * 11) % 7) - 3, 63 pz: (N_CARDS - k) * STACK_STEP, // physical stack height; k=0 rides on top 64 })); 65 66// ---- the search target: nano-lab slides up to the first-row slot on filter ---- 67const nanoIdx = grid.findIndex((c) => c.title.includes('nano-lab')); 68const nano = grid[nanoIdx]; 69const NANO_TO = { x: 408, y: 247 }; // first-row slot the filtered card slides into 70const CLICK_C = { x: 586, y: 391 }; // click point on the settled result card 71 72// filter departure rank for the 19 non-target cards (reading order) 73const leaveRank = new Map<number, number>(); 74grid.forEach((_, i) => { 75 if (i !== nanoIdx) leaveRank.set(i, leaveRank.size); 76}); 77 78// search box (page-space CSS px) + typing beats 79const SEARCH = { x: 408, y: 130, w: 1016, h: 44 }; 80const QUERY = 'nano-lab'; 81const TYPE_START = 128; // 3 frames per character → last char at ≈149 82const FILTER_START = 160; // beat of rest after typing, then the grid filters 83
▸相机 key 与输入节奏
发牌镜头证据 03:src/aifl/live/SceneFlyIn.tsx:109、src/aifl/live/SceneFlyIn.tsx:117、src/aifl/live/SceneFlyIn.tsx:127、src/aifl/live/SceneFlyIn.tsx:139、src/aifl/live/SceneFlyIn.tsx:164。这一组行号说明 deck-deal 不是简单 grid 动画:它先定义真实槽位和额外行,再用确定性 cue、堆叠高度、相机 key 和两段缓动,把“内容源源不断汇入”的语义落成可复现帧序列。
97const CAM_KEYS: CamKey[] = [ 98 { frame: 0, cx: PILE_CX - 30, cy: PILE_CY + 60, zoom: 1.95, rotX: 46, rotY: -30, rotZ: 9, persp: 1100 }, // low side view of the stack, left-oblique 99 { frame: 34, cx: PILE_CX + 30, cy: PILE_CY + 40, zoom: 1.85, rotX: 42, rotY: 26, rotZ: -7, persp: 1100 }, // orbit around the pile to a right-oblique 100 { frame: 62, cx: 960, cy: 900, zoom: 0.88, rotX: 26, rotY: 0, rotZ: 2, persp: 1300 }, // pull back — dashboard revealed, dealing underway 101 { frame: 82, cx: 950, cy: 1900, zoom: 0.78, rotX: 14, rotY: 0, rotZ: 0, persp: 1300 }, // speeding up (~50 px/f) 102 { frame: 98, cx: 960, cy: 3032, zoom: 0.72, rotX: 0, rotY: 0, rotZ: 0, persp: 1300 }, // fastest leg (~70 px/f), arrives straight at the paper's flush bottom 103 { frame: 113, cx: 960, cy: 3032, zoom: 0.72, rotX: 0, rotY: 0, rotZ: 0, persp: 1300 }, // 0.5s REST on the full board 104 { frame: 124, cx: 960, cy: 380, zoom: 0.9, rotX: 0, rotY: 0, rotZ: 0, persp: 1300 }, // swoosh back up to the search/filter header 105 { frame: 174, cx: 960, cy: 380, zoom: 0.9, rotX: 0, rotY: 0, rotZ: 0, persp: 1300 }, // hold through typing, breath, filter, slide 106 { frame: 190, cx: CLICK_C.x, cy: CLICK_C.y, zoom: 2.2, rotX: 0, rotY: 0, rotZ: 0, persp: 1300 }, // push into the clicked card, carried through the white flash 107]; 108 109export const SceneFlyIn: React.FC = () => { 110 const frame = useCurrentFrame(); 111 112 // DOF fades out over the straightening leg of the scroll (82 → 98) 113 const dofStrength = interpolate(frame, [82, 98], [5, 0], { 114 extrapolateLeft: 'clamp', extrapolateRight: 'clamp', 115 }); 116 117 // typed characters: one every 3 frames from TYPE_START (unhurried typing) 118 const typedCount = frame < TYPE_START 119 ? 0 120 : Math.min(QUERY.length, Math.floor((frame - TYPE_START) / 3) + 1); 121 // caret: solid while typing, then blinks on an 8-frame cycle until the click 122 const caretOn = 123 frame >= TYPE_START - 2 && 124 frame <= 185 && 125 (frame <= TYPE_START + 24 || Math.floor((frame - (TYPE_START + 24)) / 8) % 2 === 0); 126 127 return ( 128 <PageCam 129 src="textures/live/projects-empty.png" 130 pageH={PAGE_H} 131 keys={CAM_KEYS} 132 ease={Easing.bezier(0.33, 0, 0.15, 1)} 133 dof={dofStrength > 0.1 ? { focusY: 260, strength: dofStrength } : undefined} 134 >
关键流程
▸从牌堆到点击推近
发牌镜头证据 04:src/aifl/live/SceneFlyIn.tsx:201、src/aifl/live/SceneFlyIn.tsx:218、src/aifl/live/SceneFlyIn.tsx:223、src/aifl/live/SceneFlyIn.tsx:239、src/aifl/live/SceneFlyIn.tsx:257。这一组行号说明 deck-deal 不是简单 grid 动画:它先定义真实槽位和额外行,再用确定性 cue、堆叠高度、相机 key 和两段缓动,把“内容源源不断汇入”的语义落成可复现帧序列。
201 {grid.map((c, i) => { 202 const { cue } = c; 203 const radius = 16; 204 const isNano = i === nanoIdx; 205 206 // --- filter fade-out for the 25 non-target cards (staggered, 5f) --- 207 const outCue = isNano ? Infinity : FILTER_START + leaveRank.get(i)! * 0.4; 208 if (frame >= outCue + 5) return null; 209 const outT = isNano 210 ? 0 211 : interpolate(frame, [outCue, outCue + 5], [0, 1], { 212 extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.quad), 213 }); 214 215 // --- beat 1: deal (cue → cue+8): from the pile pose (px,py, stack 216 // height pz, small roll) to the hover pose over the slot. The flight 217 // arcs UP first (z bulge) like a card snapped off the top of a deck. --- 218 const diveT = interpolate(frame, [cue, cue + 8], [0, 1], { 219 extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: DIVE_EASE, 220 }); 221 222 // --- beat 2: settle (cue+8 → cue+12): Z from HOVER_H down to 0 --- 223 const settleT = interpolate(frame, [cue + 8, cue + 12], [0, 1], { 224 extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: SETTLE_EASE, 225 }); 226 227 // in-flight pose: pile → slot, roll resolving to 0 by touchdown-hover. 228 const dx = (c.px - c.x) * (1 - diveT); 229 const dy = (c.py - c.y) * (1 - diveT); 230 const rotFlight = c.protZ * (1 - diveT); 231 232 // depth: stack height → arc bulge (+90 at mid-flight) → hover → settle 0. 233 const arc = Math.sin(diveT * Math.PI) * 90; 234 const zDive = interpolate(diveT, [0, 1], [c.pz, HOVER_H]) + arc; 235 const z = frame < cue ? c.pz : zDive * (1 - settleT); // pile height before the deal 236 237 // scale: in the pile 1, snap up 1.06 mid-flight, settle back to 1 238 const dealScale = 1 + Math.sin(diveT * Math.PI) * 0.06; 239 const press = interpolate(frame, [cue + 10, cue + 11, cue + 12], [1, 0.996, 1], { 240 extrapolateLeft: 'clamp', extrapolateRight: 'clamp', 241 }); 242 const scale = dealScale * press; 243 244 // --- nano-lab slide (104 → 114): glide from (408,1402) to the first-row 245 // slot with a light float (scale 1→1.02→1, small Z lift, wider shadow) --- 246 const slideT = isNano 247 ? interpolate(frame, [FILTER_START, FILTER_START + 10], [0, 1], { 248 extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: SLIDE_EASE, 249 }) 250 : 0; 251 const slideDy = (NANO_TO.y - nano.y) * slideT; 252 const slideDx = (NANO_TO.x - nano.x) * slideT; 253 const float = Math.sin(slideT * Math.PI); // 0→1→0 across the slide 254 const slideScale = 1 + 0.02 * float; 255 const slideZ = 18 * float; 256 257 // once fully settled, force an exact identity so the card sits flush 258 // with its slot on the tilted board (no residual sub-px drift). 259 const landed = frame >= cue + 12; 260 const inPile = frame < cue;
边界 / 失败模式
- cue 公式如果改成均匀间隔,硬加速的“发牌”隐喻会退化成普通 PPT 入场。
- 如果 nanoIdx 找不到目标卡,筛选滑动路径就没有真实第一行槽位,点击推近也失去叙事焦点。
- 如果滤除和点击时长不跟 Main.tsx 的 SFX 钉帧同步,画面动作与声音会错位。