video-shotcraft 把 Agent 变成 Remotion 产品片工作室
它不是一个单镜头 demo 集,而是技能入口、镜头配方、截图采集、2.5D 相机、完整模板和审美 QA 共同组成的生产管线。源码里最值得拆的是 template 如何把 36.2 秒产品片变成可替换素材的确定性工程。
它解决什么,凭什么值得拆
video-shotcraft 面向 Claude Code / Codex 这类 coding agent,把产品宣传片拆成可执行技能。README 宣称 106 张 shot recipe cards、161 个 motion previews 和一支生产模板;源码侧真正能支撑这个承诺的是模板工程而不是文案。
主模板把成片注册为 1920×1080、30fps、1085 帧,见 src/Root.tsx:6、src/Root.tsx:9、src/Root.tsx:10、src/Root.tsx:11。这一层让后续所有镜头、字幕和音效都落到同一个帧坐标,避免 Agent 在自然语言计划里漂移。
▸核心数据
源码地图:不是素材库,而是交付管线
▸从 Composition 到镜头表
证据组 01:src/Root.tsx:6、src/Root.tsx:9、src/Root.tsx:10、src/Root.tsx:11、src/Root.tsx:12。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
1import { Composition } from 'remotion'; 2import { AiflMain, AIFL_TOTAL } from './aifl/Main'; 3 4export const Root: React.FC = () => { 5 return ( 6 <Composition 7 id="AiflPromo" 8 component={AiflMain} 9 durationInFrames={AIFL_TOTAL} 10 fps={30} 11 width={1920} 12 height={1080} 13 /> 14 ); 15};
▸主时间线是单一事实源
证据组 02:src/aifl/Main.tsx:14、src/aifl/Main.tsx:27、src/aifl/Main.tsx:30、src/aifl/Main.tsx:43、src/aifl/Main.tsx:90。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
12// ~36.2s @ 30fps — paper-and-ink morning reading room (10 shots). Both wordmark 13// moments (brand open, outro sign-off) hold a full second once fully on. 14export const AIFL_SHOTS = { 15 morning: { from: 0, duration: 220 }, // 0–7.3s brand ~46f + 30f hold + 138f macro 16 card1: { from: 220, duration: 55 }, // 7.3–9.2s "All your team's research, one place to go." 17 table: { from: 275, duration: 190 }, // 9.2–15.5s pile close-up → deal → scroll → search → filter → click 18 macro: { from: 465, duration: 100 }, // 15.5–18.8s macro card detail, rows reveal 19 card2: { from: 565, duration: 55 }, // 18.8–20.7s "Paper Radar, tailored for your morning reading." 20 chart: { from: 620, duration: 105 }, // 20.7–24.2s papers stack radar 21 cardWbr: { from: 725, duration: 50 }, // 24.2–25.8s "Every project, linked to your weekly report." 22 wbr: { from: 775, duration: 110 }, // 25.8–29.5s weekly report / collaborative write 23 card3: { from: 885, duration: 55 }, // 29.5–31.3s "The whole team, on the same page." 24 outro: { from: 940, duration: 145 }, // 31.3–36.2s defocus + letterpress sign-off (+1s hold) 25} as const; // sum = 1085 26 27export const AIFL_TOTAL = 1085;
▸声音和字幕也进入帧级编排
证据组 03:src/aifl/Main.tsx:94、src/aifl/Main.tsx:105、src/aifl/Main.tsx:114、src/aifl/Main.tsx:150、src/aifl/Main.tsx:156。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
40// sound design pinned to animation beats (absolute frames, Mixkit license). 41// Classic product-promo vocabulary: whooshes on camera moves, cinematic 42// impacts on landings, a riser into the finale, sparkle on the reveal. 43const SFX: { from: number; src: string; volume: number }[] = [ 44 // brand open: soft transition as the lockup lands, whoosh into the console 45 { from: 12, src: 'transition-soft.mp3', volume: 0.4 }, 46 { from: 78, src: 'whoosh-fast.mp3', volume: 0.45 }, // brand → dashboard 47 // hero card: whoosh up on the pop, sparkle on the beam scan, impact reseat 48 { from: 127, src: 'whoosh-big.mp3', volume: 0.5 }, 49 { from: 141, src: 'sparkle.mp3', volume: 0.35 }, 50 { from: 204, src: 'transition-snap.mp3', volume: 0.5 }, 51 // title cards ride a quick swoosh 52 { from: 220, src: 'swoosh-quick.mp3', volume: 0.4 }, 53 // deck shot: soft transition into the pile close-up, whoosh as the orbit 54 // pulls back and dealing starts, fast whooshes as the deal accelerates, 55 // then the scroll rest → big whoosh back up to the search bar 56 { from: 277, src: 'transition-soft.mp3', volume: 0.4 }, // pile close-up 57 { from: 308, src: 'whoosh-big.mp3', volume: 0.5 }, // pull-back + first deals 58 { from: 340, src: 'whoosh-fast.mp3', volume: 0.4 }, // dealing accelerates 59 { from: 356, src: 'whoosh-fast.mp3', volume: 0.32 }, // full flurry 60 { from: 388, src: 'whoosh-big.mp3', volume: 0.5 }, // swoosh back to header 61 // typing (slower now, 3f/char) + a breath + filter + click 62 { from: 401, src: 'keyboard.mp3', volume: 0.4 }, // trimmed by sequence length 63 { from: 435, src: 'whoosh-fast.mp3', volume: 0.4 }, // grid filters away
PageCam:真实页面镜头的地基
▸相机 keyframe 契约
证据组 04:src/aifl/live/PageCam.tsx:4、src/aifl/live/PageCam.tsx:42、src/aifl/live/PageCam.tsx:50、src/aifl/live/PageCam.tsx:62、src/aifl/live/PageCam.tsx:64。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
4 frame: number; 5 cx: number; 6 cy: number; 7 zoom: number; 8 rotX?: number; // deg, tilt about the horizontal axis (positive = top leans away, like looking at a table) 9 rotY?: number; // deg, tilt about the vertical axis (positive = right edge recedes, i.e. seen from the LEFT) 10 rotZ?: number; // deg, in-plane roll 11 persp?: number; // px, perspective strength (default 1400; smaller = stronger) 12}; 13 14const lerp = (a: number, b: number, t: number) => a + (b - a) * t; 15 16/** 17 * 2.5D camera over a full-page screenshot. (cx, cy) is the page-space CSS 18 * point centered in the 1920x1080 viewport; zoom is scale (1 = 1 CSS px 19 * -> 1 output px). Page textures are 2x, rendered at CSS size via width. 20 * 21 * Optional 3D: keys may carry rotX/rotZ/persp to tilt the page like a plane 22 * seen from an oblique camera. When NO key declares any 3D field, the markup 23 * degrades to the original flat pan/zoom and renders pixel-identical. 24 *
▸布局级 zoom 解决 3D 文字糊
证据组 05:src/aifl/live/PageCam.tsx:82、src/aifl/live/PageCam.tsx:95、src/aifl/live/PageCam.tsx:99、src/aifl/live/PageCam.tsx:119、src/aifl/live/PageCam.tsx:126。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
82 const persp = lerp(a.persp ?? 1400, b.persp ?? 1400, t); 83 84 return ( 85 <AbsoluteFill style={{ overflow: 'hidden', backgroundColor: '#faf7f2' }}> 86 <div 87 style={{ 88 position: 'absolute', inset: 0, 89 perspective: `${persp * zoom}px`, 90 perspectiveOrigin: '960px 540px', 91 }} 92 > 93 {/* LAYOUT-SCALE zoom: instead of scale(zoom) in the transform chain (which 94 makes Chromium rasterize the 3D-composited layer at 1920-wide LAYOUT 95 size and then GPU-upscale by zoom — everything inside gets downsampled 96 before magnification, hence blurry text), we apply the magnification as 97 the CSS `zoom` property. `zoom` enlarges the layout box itself, so the 98 page + card textures rasterize at the ENLARGED device size and sample 99 down from their hi-res sources → sharp glyph edges under perspective. 100 101 Coordinate math: `zoom` scales this element's local coordinate space by 102 `zoom`, so a page point (cx,cy) renders at (cx*zoom, cy*zoom) device px 103 from the box origin, and a `translate(Tx px)` renders as Tx*zoom device 104 px. To land the focal point (cx,cy) at viewport centre (960,540): 105 cx*zoom + Tx*zoom = 960 ⟹ Tx = 960/zoom - cx (likewise Ty). 106 Rotations pivot about transform-origin (cx,cy) = the focal point, so 107 they leave its screen position unchanged. With rot=0 this reduces to 108 translate(960/zoom - cx, 540/zoom - cy) under zoom — identical framing 109 to the old scale-based transform, just rasterized at layout scale. */} 110 <div 111 style={{ 112 position: 'absolute', width: 1920, height: pageH, 113 zoom, 114 transform: `translate(${960 / zoom - cx}px, ${540 / zoom - cy}px) rotateY(${rotY}deg) rotateX(${rotX}deg) rotateZ(${rotZ}deg)`, 115 transformOrigin: `${cx}px ${cy}px`, 116 transformStyle: 'preserve-3d', 117 filter: filters.length ? filters.join(' ') : undefined, 118 }} 119 > 120 <Img src={staticFile(src)} style={{ position: 'absolute', width: 1920, height: pageH }} /> 121 {children} 122 </div> 123 </div> 124 125 {/* Depth-of-field approximation: a top-band gradient blur (far part of a 126 tilted page reads soft). Screen-space, over the page. */}
素材采集:三件套把真实页面变成可动画资产
▸为什么要真实截图
虽然 capture 脚本位于仓库根的 assets 目录,最终模板消费的是 src/aifl/live/PageCam.tsx:30 的 staticFile 纹理、src/aifl/live/PageCam.tsx:33 的 page-space children,以及 src/aifl/live/SceneFlyIn.tsx:5 读取的 layout 坐标。这个设计把“看起来像产品”的手搓 UI 降级为兜底,把截图和坐标表变成主流程。
▸坐标即动画接口
在发牌场景里,真实卡片槽位来自 src/aifl/live/SceneFlyIn.tsx:5、页面高度来自 src/aifl/live/SceneFlyIn.tsx:6,额外行位由 src/aifl/live/SceneFlyIn.tsx:20、src/aifl/live/SceneFlyIn.tsx:22、src/aifl/live/SceneFlyIn.tsx:23 补齐。Agent 适配新产品时主要替换 layout 和截图,动画语法保持。
时间线:镜头、字幕、SFX 和 FlashCut 同表管理
▸AIFL_SHOTS 是全片骨架
证据组 12:src/aifl/Main.tsx:155、src/aifl/Main.tsx:156、src/aifl/Main.tsx:157、src/aifl/Main.tsx:158、src/aifl/Main.tsx:160。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
90export const AiflMain: React.FC = () => { 91 return ( 92 <AbsoluteFill style={{ backgroundColor: '#f2eee6' }}> 93 {/* beat-pinned promo sound effects (no music bed — SFX only) */} 94 {SFX.map((s, i) => ( 95 <Sequence 96 key={`sfx-${i}`} 97 from={s.from} 98 // keyboard: 24f for the short search-box typing, 44f under the wbr 99 // writing reveals; everything else plays out (≤3s assets) 100 durationInFrames={s.src === 'keyboard.mp3' ? (s.from > 700 ? 44 : 24) : 90} 101 > 102 <Audio src={staticFile(`audio/${s.src}`)} volume={s.volume} /> 103 </Sequence> 104 ))} 105 <Sequence from={AIFL_SHOTS.morning.from} durationInFrames={AIFL_SHOTS.morning.duration}> 106 <SceneOpen /> 107 </Sequence> 108 <Sequence from={AIFL_SHOTS.card1.from} durationInFrames={AIFL_SHOTS.card1.duration}> 109 <PaperTitleCard 110 duration={AIFL_SHOTS.card1.duration} 111 words={[{ text: 'All' }, { text: 'your' }, { text: 'team’s' }, { text: 'research,' }, { text: 'one', accent: true }, { text: 'place' }, { text: 'to' }, { text: 'go.' }]} 112 /> 113 </Sequence> 114 <Sequence from={AIFL_SHOTS.table.from} durationInFrames={AIFL_SHOTS.table.duration}> 115 <SceneFlyIn /> 116 </Sequence> 117 <Sequence from={AIFL_SHOTS.macro.from} durationInFrames={AIFL_SHOTS.macro.duration}> 118 <SceneDetail /> 119 </Sequence> 120 <Sequence from={AIFL_SHOTS.card2.from} durationInFrames={AIFL_SHOTS.card2.duration}> 121 <PaperTitleCard 122 duration={AIFL_SHOTS.card2.duration} 123 words={[{ text: 'Paper' }, { text: 'Radar,', accent: true }, { text: 'tailored' }, { text: 'for' }, { text: 'your' }, { text: 'morning' }, { text: 'reading.' }]} 124 sub="of 31 fetched today" 125 subDigits="5" 126 /> 127 </Sequence> 128 <Sequence from={AIFL_SHOTS.chart.from} durationInFrames={AIFL_SHOTS.chart.duration}> 129 <ScenePapers /> 130 </Sequence> 131 <Sequence from={AIFL_SHOTS.cardWbr.from} durationInFrames={AIFL_SHOTS.cardWbr.duration}> 132 <PaperTitleCard 133 duration={AIFL_SHOTS.cardWbr.duration} 134 words={[{ text: 'Every' }, { text: 'project,' }, { text: 'linked', accent: true }, { text: 'to' }, { text: 'your' }, { text: 'weekly' }, { text: 'report.' }]} 135 /> 136 </Sequence> 137 <Sequence from={AIFL_SHOTS.wbr.from} durationInFrames={AIFL_SHOTS.wbr.duration}> 138 <SceneWbr /> 139 </Sequence> 140 <Sequence from={AIFL_SHOTS.card3.from} durationInFrames={AIFL_SHOTS.card3.duration}> 141 <PaperTitleCard 142 duration={AIFL_SHOTS.card3.duration} 143 words={[{ text: 'The' }, { text: 'whole' }, { text: 'team,' }, { text: 'on' }, { text: 'the' }, { text: 'same', accent: true }, { text: 'page.' }]} 144 /> 145 </Sequence> 146 <Sequence from={AIFL_SHOTS.outro.from} durationInFrames={AIFL_SHOTS.outro.duration}> 147 <SceneOutroLive /> 148 </Sequence> 149 {/* narration captions over the live shots (under the flash cuts) */} 150 {CAPTIONS.map((c) => ( 151 <Sequence key={c.from} from={c.from} durationInFrames={c.duration}> 152 <Caption text={c.text} duration={c.duration} /> 153 </Sequence> 154 ))} 155 {/* warm flash cuts straddling scene changes */} 156 {[AIFL_SHOTS.table.from, AIFL_SHOTS.macro.from, AIFL_SHOTS.chart.from, AIFL_SHOTS.wbr.from].map((cut) => ( 157 <Sequence key={cut} from={cut - 5} durationInFrames={10}> 158 <FlashCut duration={10} /> 159 </Sequence> 160 ))} 161 </AbsoluteFill> 162 ); 163};
▸绝对帧号让反馈可定位
字幕用 src/aifl/Main.tsx:30 的数组描述,SFX 用 src/aifl/Main.tsx:43 的数组描述,渲染时都通过 src/aifl/Main.tsx:94 和 src/aifl/Main.tsx:150 转成 Sequence。它让“第几秒的字幕太快”变成“某个 from/duration 需要移动”,这比让 Agent 搜全仓库改动画常量稳得多。
发牌镜头:把配方卡落实为硬加速物理隐喻
▸确定性卡堆
证据组 06:src/aifl/live/SceneFlyIn.tsx:17、src/aifl/live/SceneFlyIn.tsx:25、src/aifl/live/SceneFlyIn.tsx:34、src/aifl/live/SceneFlyIn.tsx:51、src/aifl/live/SceneFlyIn.tsx:58。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
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 25const extras = Array.from({ length: 16 }, (_, i) => ({ 26 file: `card${((i * 3 + 2) % 10) + 1}.png`, // deterministic, neighbors differ 27 x: i === 0 ? COLS[2] : COLS[(i - 1) % 3], 28 y: i === 0 ? 1402 : EXTRA_ROWS[Math.floor((i - 1) / 3)], 29 w: CARD_W, 30 h: 312, 31 title: '', 32})); 33 34// ---- the DECK: all 26 cards start stacked in one pile at the page's top 35// right. The shot opens on a rotating 3D close-up of the pile (0–35), pulls 36// back to reveal the page (35–62), and the cards deal themselves to their 37// slots in reading order (y, then x) on a hard-accelerating cadence — the gap 38// between departures shrinks from 4f to 0.2f (gap_k = 4 − 0.1584k), so the 39// dealing goes from deliberate to a blur. First departs at 36 (mid pull-back), 40// last ≈88, lands ≈96. Pile poses carry a small deterministic jitter and each 41// card keeps a stack height (top of pile = first to leave). ---- 42const PILE = { x: 1430, y: 240 }; 43const N_CARDS = 26; 44const DEAL_START = 36; 45const STACK_STEP = 3; // px of physical height per card — the pile reads as a real stack 46 47// dark-metal backdrop for the opening close-up: covers everything in the page 48// plane, with a warm spotlight pooled on the pile; fades out with the pull-back 49const METAL_FADE = [34, 56] as const; 50 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 }));
▸相机追逐与 typed filter
证据组 07:src/aifl/live/SceneFlyIn.tsx:97、src/aifl/live/SceneFlyIn.tsx:117、src/aifl/live/SceneFlyIn.tsx:127、src/aifl/live/SceneFlyIn.tsx:201、src/aifl/live/SceneFlyIn.tsx:218。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
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 >
字卡、字幕与闪切:信息呼吸位不是装饰
▸PaperTitleCard 逐词压印
证据组 09:src/aifl/PaperTitleCard.tsx:11、src/aifl/PaperTitleCard.tsx:22、src/aifl/PaperTitleCard.tsx:50、src/aifl/PaperTitleCard.tsx:80、src/aifl/DigitRoll.tsx:23。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
11export const PaperTitleCard: React.FC<{ 12 duration: number; 13 words: { text: string; accent?: boolean }[]; 14 sub?: string; 15 subDigits?: string; 16}> = ({ duration, words, sub, subDigits }) => { 17 const frame = useCurrentFrame(); 18 const fadeOut = interpolate(frame, [duration - 8, duration], [1, 0], { 19 extrapolateLeft: 'clamp', 20 extrapolateRight: 'clamp', 21 }); 22 const underline = interpolate(frame, [16, 34], [0, 1], { 23 extrapolateLeft: 'clamp', 24 extrapolateRight: 'clamp', 25 easing: Easing.bezier(0.3, 0, 0.2, 1), 26 }); 27 const subT = interpolate(frame, [10, 22], [0, 1], { 28 extrapolateLeft: 'clamp', 29 extrapolateRight: 'clamp', 30 }); 31 32 return ( 33 <AbsoluteFill 34 style={{ 35 backgroundColor: 'oklch(97.5% 0.008 82)', 36 justifyContent: 'center', 37 alignItems: 'center', 38 opacity: fadeOut, 39 backgroundImage: 'radial-gradient(1100px 750px at 50% 42%, oklch(99.3% 0.014 88 / 0.85), transparent 65%)', 40 }} 41 > 42 <div style={{ textAlign: 'center', maxWidth: 1500 }}> 43 <div 44 style={{ 45 fontFamily: SERIF, fontSize: 116, fontWeight: 600, lineHeight: 1.14, 46 color: 'oklch(18% 0.006 82)', letterSpacing: '-0.012em', 47 display: 'flex', flexWrap: 'wrap', justifyContent: 'center', columnGap: '0.26em', 48 }} 49 > 50 {words.map((w, i) => { 51 const delay = 4 + i * 4; 52 const t = interpolate(frame, [delay, delay + 9], [0, 1], { 53 extrapolateLeft: 'clamp', 54 extrapolateRight: 'clamp', 55 easing: Easing.bezier(0.2, 0.75, 0.3, 1), 56 }); 57 return ( 58 <span 59 key={i} 60 style={{ 61 opacity: t, 62 transform: `scale(${1.28 - 0.28 * t})`, 63 filter: `blur(${(1 - t) * 7}px)`, 64 display: 'inline-block', 65 fontStyle: w.accent ? 'italic' : 'normal', 66 color: w.accent ? 'oklch(52% 0.115 65)' : undefined, 67 }} 68 > 69 {w.text} 70 </span> 71 ); 72 })} 73 </div> 74 <div 75 style={{ 76 height: 6, width: 220, margin: '38px auto 0', borderRadius: 3, 77 background: 'oklch(52% 0.115 65)', transform: `scaleX(${underline})`, 78 }} 79 /> 80 {sub ? ( 81 <div style={{ fontFamily: MONO, fontSize: 26, letterSpacing: '0.12em', color: 'oklch(50% 0.006 82)', marginTop: 34, opacity: subT, textTransform: 'uppercase', display: 'flex', justifyContent: 'center', alignItems: 'baseline', gap: '0.5em' }}> 82 {subDigits ? <DigitRoll value={subDigits} delay={12} fontSize={26} /> : null} 83 <span>{sub}</span> 84 </div> 85 ) : null} 86 </div> 87 </AbsoluteFill> 88 ); 89};
▸Caption 与 FlashCut 是帧级 overlay
证据组 10:src/aifl/Caption.tsx:9、src/aifl/Caption.tsx:15、src/aifl/Caption.tsx:24、src/aifl/FlashCut.tsx:5、src/aifl/FlashCut.tsx:12。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
9export const Caption: React.FC<{ text: string; duration: number; bottom?: number }> = ({ 10 text, 11 duration, 12 bottom = 72, 13}) => { 14 const frame = useCurrentFrame(); 15 const inT = interpolate(frame, [0, 8], [0, 1], { 16 extrapolateLeft: 'clamp', 17 extrapolateRight: 'clamp', 18 }); 19 const outT = interpolate(frame, [duration - 8, duration], [1, 0], { 20 extrapolateLeft: 'clamp', 21 extrapolateRight: 'clamp', 22 }); 23 24 return ( 25 <div 26 style={{ 27 position: 'absolute', 28 left: 0, 29 right: 0, 30 bottom, 31 display: 'flex', 32 justifyContent: 'center', 33 alignItems: 'baseline', 34 gap: 14, 35 fontFamily: MONO, 36 fontSize: 22, 37 letterSpacing: '0.14em', 38 textTransform: 'uppercase', 39 color: 'oklch(45% 0.006 82)', 40 opacity: inT * outT, 41 transform: `translateY(${(1 - inT) * 8}px)`, 42 pointerEvents: 'none', 43 }} 44 > 45 <span style={{ width: 6, height: 6, background: AMBER, display: 'inline-block' }} /> 46 <span>{text}</span> 47 </div> 48 ); 49};
验收:源码里沉淀的是可复现质感
▸锐度和景深的边界
证据组 11:src/aifl/live/SceneFlyIn.tsx:239、src/aifl/live/SceneFlyIn.tsx:257、src/aifl/live/PageCam.tsx:133、src/aifl/live/PageCam.tsx:141、src/aifl/live/PageCam.tsx:145。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
▸开场 hero card 的完整动作弧
证据组 08:src/aifl/live/SceneOpen.tsx:34、src/aifl/live/SceneOpen.tsx:60、src/aifl/live/SceneOpen.tsx:100、src/aifl/live/SceneOpen.tsx:135、src/aifl/live/SceneOpen.tsx:184。这里的关键不是文件数量,而是这些行共同形成了可复现的成片管线:Composition 固定规格,时间线集中管理,页面相机负责真实截图空间,场景文件只声明镜头行为,字幕、SFX 与闪白转场都按绝对帧钉住,因此 Agent 能在同一套约束下替换素材而不是重写电影语言。
34const CAM_KEYS: CamKey[] = [ 35 { frame: 82, cx: 960, cy: 540, zoom: 0.78, rotX: 0, rotY: 0, rotZ: 0, persp: 1200 }, 36 { frame: 114, cx: 960, cy: 540, zoom: 0.78, rotX: 0, rotY: 0, rotZ: 0, persp: 1200 }, 37 { frame: 130, cx: MCX - 30, cy: MCY, zoom: 2.6, rotX: 8, rotY: 34, rotZ: 2, persp: 1200 }, 38 { frame: 220, cx: MCX - 30, cy: MCY, zoom: 2.58, rotX: 8, rotY: 34, rotZ: 2, persp: 1200 }, 39]; 40const PUSH_EASE = Easing.bezier(0.35, 0, 0.2, 1); 41 42const POP_EASE = Easing.bezier(0.2, 1.25, 0.3, 1); // spring with overshoot 43const RESEAT_EASE = Easing.bezier(0.4, 0, 0.3, 1.05); // compress on touchdown 44const PATCH = 'oklch(97.5% 0.008 82)'; 45const SLOT_AMBER = 'oklch(58% 0.13 65)'; 46const BEAM_CORE = 'rgba(255,248,232,0.98)'; 47 48/** Brand open (0–83): an invisible pen draws an amber crosshair, "AI 49 * Foundation Lab" letterpresses in glyph by glyph (all glyphs down by ~46), a mono kicker 50 * types itself out, then the finished lockup RESTS fully-on for a second 51 * (46–76) before dissolving and handing to the console. 52 * 53 * Single-card macro (82–220): a warm spotlight roves the dashboard, then locks 54 * onto ONE card (pool shrinks, surroundings dim); the camera pushes in and 55 * SWINGS to a left-side view (rotY-dominant: left edge near, right edge far) 56 * until the card nearly fills the frame, a crisp 4x capture crossfading in over 57 * the soft page texture; the card springs up off the plane and hovers ~54f 58 * while a beam runs two laps around its rounded outline; then it settles flush 59 * back into its slot (lock→touchdown ≈ 3.3s). */ 60export const SceneOpen: React.FC = () => {
怎么复用:改素材,不先改运动语法
▸替换点
实际复用时,最该先动的是截图、layout 和文案:src/aifl/live/SceneFlyIn.tsx:129 的 projects-empty、src/aifl/live/SceneOpen.tsx:202 的 projects-full、src/aifl/PaperTitleCard.tsx:50 的 words、src/aifl/Main.tsx:30 的 captions。先替这些,比直接改 easing 更接近作者的设计意图。
▸不该轻易改的点
不建议一开始就动 src/aifl/live/SceneFlyIn.tsx:58 的发牌 cue、src/aifl/live/SceneFlyIn.tsx:97 的相机 key、src/aifl/live/PageCam.tsx:119 的 zoom 策略、src/aifl/Main.tsx:43 的 SFX 表结构。它们承担质感和可复现性,改错会导致页面不像产品片而像随机 demo。
技术亮点之外,别忽略这些
▸风险卡片
深度阅读入口
▸模板与相机
先读子页面一,它围绕 src/aifl/live/PageCam.tsx:42、src/aifl/live/PageCam.tsx:62、src/aifl/live/PageCam.tsx:95 和 src/aifl/live/PageCam.tsx:119 展开,解释为什么 PageCam 是真实页面产品片的底座。
▸发牌镜头
再读子页面二,它围绕 src/aifl/live/SceneFlyIn.tsx:25、src/aifl/live/SceneFlyIn.tsx:58、src/aifl/live/SceneFlyIn.tsx:97 和 src/aifl/live/SceneFlyIn.tsx:218 展开,解释硬加速发牌如何从配方卡落到帧级实现。