videoShotcraft
§ 00 · Source-level Observatory

video-shotcraft 把 Agent 变成 Remotion 产品片工作室

它不是一个单镜头 demo 集,而是技能入口、镜头配方、截图采集、2.5D 相机、完整模板和审美 QA 共同组成的生产管线。源码里最值得拆的是 template 如何把 36.2 秒产品片变成可替换素材的确定性工程。

★ 978
GitHub Stars
26.9K
TS/JS LOC
686
Files
2026-07-23
Last commit
Skill prompt -> shot cards -> real screenshots -> PageCam -> Remotion timeline -> QA frames
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

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 在自然语言计划里漂移。

▸核心数据

架构模式
Remotion template + shot-card library + capture pipeline。
核心抽象
PageCam 将全页截图、页面坐标和 2.5D 运镜绑定。
性能/精度数据
1920x1080、30fps、1085 帧;截图按 2x/4x 纹理策略保锐度。
下游依赖
Remotion、Chromium/Puppeteer、真实页面截图、Mixkit SFX。
§ 01 · SOURCE MAP

源码地图:不是素材库,而是交付管线

▸从 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 能在同一套约束下替换素材而不是重写电影语言。

src/Root.tsx · L1-15TSX
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 能在同一套约束下替换素材而不是重写电影语言。

src/aifl/Main.tsx · L12-27TSX
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 能在同一套约束下替换素材而不是重写电影语言。

src/aifl/Main.tsx · L40-63TSX
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
§ 02 · PAGE CAMERA

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 能在同一套约束下替换素材而不是重写电影语言。

src/aifl/live/PageCam.tsx · L4-24TSX
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 能在同一套约束下替换素材而不是重写电影语言。

src/aifl/live/PageCam.tsx · L82-126TSX
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. */}
深度阅读 · 模板与 PageCam
§ 03 · CAPTURE

素材采集:三件套把真实页面变成可动画资产

▸为什么要真实截图

虽然 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 和截图,动画语法保持。

§ 04 · TIMELINE

时间线:镜头、字幕、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 能在同一套约束下替换素材而不是重写电影语言。

src/aifl/Main.tsx · L90-163TSX
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 搜全仓库改动画常量稳得多。

§ 05 · DEAL FLOW

发牌镜头:把配方卡落实为硬加速物理隐喻

▸确定性卡堆

证据组 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 能在同一套约束下替换素材而不是重写电影语言。

src/aifl/live/SceneFlyIn.tsx · L17-64TSX
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 能在同一套约束下替换素材而不是重写电影语言。

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    >
深度阅读 · SceneFlyIn 发牌镜头
§ 06 · TITLE SYSTEM

字卡、字幕与闪切:信息呼吸位不是装饰

▸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 能在同一套约束下替换素材而不是重写电影语言。

src/aifl/PaperTitleCard.tsx · L11-89TSX
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 能在同一套约束下替换素材而不是重写电影语言。

src/aifl/Caption.tsx · L9-49TSX
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};
§ 07 · QA

验收:源码里沉淀的是可复现质感

▸锐度和景深的边界

证据组 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 能在同一套约束下替换素材而不是重写电影语言。

src/aifl/live/SceneOpen.tsx · L34-60TSX
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 = () => {
§ 08 · REUSE

怎么复用:改素材,不先改运动语法

▸替换点

实际复用时,最该先动的是截图、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。

§ 09 · RISKS

技术亮点之外,别忽略这些

▸风险卡片

素材合规
仓库强调演示数据和截图替换,但真正使用时仍需要确认产品、客户和个人数据边界。
模板依赖
Ink Press 很强,但过度套用会让不同产品长得一样;需要先从产品设计系统重蒙皮。
Remotion 成本
高分辨率截图、3D 透视和 SFX 钉帧适合短片,不适合无约束批量生成长视频。
Agent 误改
如果 Agent 不读 shot card 和 demo 源码,容易只复刻名称而丢掉调校参数。
§ 10 · DEEP READS

深度阅读入口

▸模板与相机

先读子页面一,它围绕 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 展开,解释硬加速发牌如何从配方卡落到帧级实现。

Generated · 2026-07-24Source · Vincentwei1021/video-shotcraft全部分析