videoShotcraft
Nested detail page

SceneFlyIn 发牌镜头 深度解析

这页拆一个高能量镜头:牌堆特写、硬加速发牌、追逐式滚动、搜索筛选和点击推近。

§ 00 · IMPORT CHAIN

导入链与契约

发牌镜头证据 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 和两段缓动,把“内容源源不断汇入”的语义落成可复现帧序列。

src/aifl/live/SceneFlyIn.tsx · L1-24TSX
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
§ 01 · CORE IMPL

核心实现,逐段拆

▸卡片网格与 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 和两段缓动,把“内容源源不断汇入”的语义落成可复现帧序列。

src/aifl/live/SceneFlyIn.tsx · L51-83TSX
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 和两段缓动,把“内容源源不断汇入”的语义落成可复现帧序列。

src/aifl/live/SceneFlyIn.tsx · L97-134TSX
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    >
§ 02 · KEY FLOW

关键流程

▸从牌堆到点击推近

pile pose -> deal cue -> dive arc -> settle overshoot -> filter fade -> nano slide -> click push-in

发牌镜头证据 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 和两段缓动,把“内容源源不断汇入”的语义落成可复现帧序列。

src/aifl/live/SceneFlyIn.tsx · L201-260TSX
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;
§ 03 · EDGE CASES

边界 / 失败模式

  • cue 公式如果改成均匀间隔,硬加速的“发牌”隐喻会退化成普通 PPT 入场。
  • 如果 nanoIdx 找不到目标卡,筛选滑动路径就没有真实第一行槽位,点击推近也失去叙事焦点。
  • 如果滤除和点击时长不跟 Main.tsx 的 SFX 钉帧同步,画面动作与声音会错位。
Generated · 2026-07-24Source · Vincentwei1021/video-shotcraft全部分析