Motion · Câmera

Graze face tour

A steep-angle close-up skims low over the UI surface, text initially hovers above the interface with a matching soft shadow, and as the camera moves each piece accelerates down to settle into place.

1920×108016:930 fps5s · 150 quadrosremotionLicença: Apache-2.0#shotcraft#remotion#camera#ui-entrance

Fonte: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Composição Remotion (React/TSX) — vídeo de prévia, sem player no navegador

When to use

A tour of feature areas (treating the UI as terrain to skim over); pairs with a dark scene and neon rim light for a "product's inner world" segment; interface content entering region by region.

Intention

Shoot the UI as terrain: a steep-angle close-up turns the sidebar tree, navigation, and list rows into passing landscape, with shallow depth of field reinforcing the "skimming the surface" feel. The dramatic beat is text landing from the air: text/components don't fade in place — they hover above the interface (raised along the 3D z-axis), casting a matching soft shadow onto the interface while airborne, then accelerate down one after another as the camera advances, the shadow converging and vanishing as height decreases — the audience watches the interface "complete itself" as the camera passes. The shadow is the perceptible crux: without it, hovering just reads as an ordinary positional entrance; with it, the spatial relationship is instantly established.

Duration & energy

  • Duration: Single segment 4–5s; can chain multiple segments to extend
  • Energy: Medium-high (sustained camera advance plus continuous element landings, high information density)

Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)

意图

把 UI 拍成地形:大倾角特写让侧栏树、导航、列表行变成掠过的地貌, 浅景深强化"贴面"。戏眼是文字空中贴落:文字/组件不是原地淡入, 而是悬浮在界面上空(3D z 轴抬高),在空中时往界面投同形软影, 随镜头行进先后加速贴落、影子随高度收敛消失——观众看见界面在 镜头经过时"完成自己"。影子是可感命门:没有影子,悬浮读作 普通位移入场;有影子,空间关系瞬间成立。

动效核心

  • 机位:CSS perspective + rotateX/rotateY 大倾角(面几乎侧掠),

沿界面一条路径持续推进;浅景深=远处 blur 渐强

  • 悬浮元素:初始悬浮高度 H≈120–180px(沿面法线方向抬起),

easeFall 加速下落+软着陆(落地不回弹或极微回弹)

  • 同形软影:元素原位渲染模糊压暗投影,blur/offset/opacity 全部

随当前悬浮高度变化(高=影大而糊而淡,落地=影与本体重合消失)

  • 错峰贴落:各元素落地时刻按镜头行进方向排布(先经过的先落),

界面在镜头前"依次完成"

  • 长镜可拆段接力:段间用相邻段交叉淡化(判例:透明度窗口不重叠

会产生黑闪帧)

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 悬浮高度 | 120–180px | <80px 影子和位移都读不出;>250px 落地砸得太重 | | 影子参数 | blur/offset/opacity 随高度线性收敛 | 影子恒定不随高度变=贴纸感;落地影必须归零 | | 贴落错峰 | 按镜头行进方向 0.2→0.85 归一化排布 | 同帧齐落读作开关;乱序落读作 bug;错峰=错开起点但下落过程重叠并行,串行等待(一个落完另一个才开始)被裁(runway 判例) | | 下落曲线 | easeFall 加速+软着陆 | ease-out 下落读作气球飘落,重力感靠加速 | | 段间接力 | 相邻段交叉淡化 ≥8f | 透明度窗口不搭接会黑闪(返工判例 f100 纯黑帧) | | 景深 | 远处 blur 渐强 | 全清晰读作平面滚动,浅景深才有"贴面"体感 |

已知坑

  • demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿,

首次实战须以真实素材回验

  • 悬空元素别被环境压黑带吞掉(邻候选 runway 判例):空中元素

需追光/提亮 ~1.3x,否则悬浮状态不可感等于没做

  • 与 space-camera-moves/tension-camera-moves 分工:那两张是相机

语言本身;本卡=贴面机位 + 元素贴落 的组合签名,缺一半就 改用相机卡

  • 贴落节奏语义判例(与 neon-frame-orbit-drop 互为反例):

本卡巡礼镜=错峰并行;整体登场镜必须全体同帧齐落(那张卡的 错峰版被裁)。选错语法两边都会被裁

  • 原片(clickup-30)是一条连续上行长镜;复刻用三段接力,段落

切换藏在交叉淡化里——实战若做真单长镜,路径规划成本高得多

参考实现

demos/camera/graze-face-tour/ (GrazeFaceTour.tsx) 原片出处:clickup-30.mp4

Reference implementation (Remotion)

  • demos/camera/graze-face-tour/GrazeFaceTour.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/camera/graze-face-tour/GrazeFaceTour.tsx

The component imports shared fixtures from demos/_fixtures and helpers from assets/lib in the repo; SKILL.md there documents the render workflow.

Tudo nesta página também é legível por máquina. Aponte seu agente para o JSON ou adicione o servidor MCP do gimgs e peça para buscar templates de motion.

  1. Baixe o JSON — contém a receita do plano (timing, easing, tabela de parâmetros, armadilhas) e o código TSX completo.
  2. Clone o repositório video-shotcraft; o componente importa fixtures de demos/_fixtures e helpers de assets/lib.
  3. Registre o componente como <Composition> em um projeto Remotion e renderize com `npx remotion render`, ou reimplemente a receita no seu próprio engine.
curl -s https://gimgs.net/motion/graze-face-tour.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/camera/graze-face-tour/GrazeFaceTour.tsx
npx remotion render <CompositionId> out.mp4
claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("graze-face-tour")

Prompt

Use the video-shotcraft shot recipe "Graze face tour" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/graze-face-tour.json
Raw TSX: https://gimgs.net/motion/graze-face-tour/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/camera/graze-face-tour/GrazeFaceTour.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 5s

1. Read the JSON and follow its "prompt" section (when to use, intention, parameter table, known pitfalls).
2. Adapt it for: <describe what you want>.
3. Either clone the repo, register the component as a <Composition> in a Remotion project and run npx remotion render <CompositionId> out.mp4 — or re-implement the recipe in your own engine (HTML/WAAPI works: keep the same timing and easing).

JSON · Markdown · llms.txt

21,414 chars · Apache-2.0Abrir template bruto ↗
// ===== demos/camera/graze-face-tour/GrazeFaceTour.tsx =====
// graze-face-tour v2 —— 源片 clickup-30.mp4 约 28.5–33s:
// 相机大倾角贴着 UI 表面游走特写,三段接力:侧栏树 → 顶部 tab 条 → 列表行。
// v2(用户意见):页面文字初始悬浮在界面上空(3D 抬高),空中时在 UI 面上
// 投模糊同形软影;随镜头推进先后落贴回界面,影子随高度收敛消失。
import React from 'react';
import { AbsoluteFill, interpolate, useCurrentFrame, Easing } from 'remotion';

const FONT = 'Helvetica, Arial, sans-serif';
const INK = '#3a3a40';
const MID = '#8b8b92';
const FAINT = '#c8c8ce';

const easeIO = Easing.bezier(0.45, 0, 0.25, 1);
const easeFall = Easing.bezier(0.5, 0.05, 0.6, 1); // 加速下落、末端软着陆

/* ---------- 悬浮 + 同形软影 ----------
 * h=悬浮高度(px)。文字整体向上(屏幕上-左)抬起,
 * 原位置留一份模糊压暗的同形投影;h→0 时两者重合、影子消失。 */
const FloatWrap: React.FC<{ h: number; children: React.ReactNode }> = ({ h, children }) => (
  <div style={{ position: 'relative' }}>
    {h > 1.5 && (
      <div style={{
        position: 'absolute', inset: 0,
        transform: `translate(${h * 0.22}px, ${h * 0.42}px) scale(${1 + h * 0.0011})`,
        filter: `blur(${3.5 + h * 0.085}px) brightness(0.32) saturate(0.4)`,
        opacity: Math.min(0.38, 0.16 + h * 0.004),
        pointerEvents: 'none',
      }}>{children}</div>
    )}
    <div style={{ transform: `translate(${-h * 0.34}px, ${-h * 0.78}px)` }}>{children}</div>
  </div>
);

/* 每行的悬浮高度:land = 该行贴回完成的段内时刻(0..1),之前从 H 高度加速落下 */
const liftOf = (t: number, land: number, H = 120) => {
  const FALL = 0.34;
  const p = Math.min(1, Math.max(0, (t - (land - FALL)) / FALL));
  return (1 - easeFall(p)) * H;
};

/* ---------- 灰阶自绘 UI 素材 ---------- */

const Chip: React.FC<{ letter: string; tone: string }> = ({ letter, tone }) => (
  <div style={{
    width: 64, height: 64, borderRadius: 16, background: tone,
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    fontFamily: FONT, fontWeight: 700, fontSize: 36, color: '#666',
  }}>{letter}</div>
);

const Tri: React.FC<{ open?: boolean }> = ({ open }) => (
  <div style={{
    width: 0, height: 0,
    borderLeft: open ? '16px solid transparent' : '22px solid #9a9aa0',
    borderRight: open ? '16px solid transparent' : '0 solid transparent',
    borderTop: open ? '22px solid #9a9aa0' : '14px solid transparent',
    borderBottom: open ? '0' : '14px solid transparent',
  }} />
);

const DocIcon: React.FC = () => (
  <div style={{ width: 44, height: 54, border: '5px solid #9a9aa0', borderRadius: 8, position: 'relative' }}>
    <div style={{ position: 'absolute', left: 7, top: 10, width: 22, height: 5, background: '#b6b6bc' }} />
    <div style={{ position: 'absolute', left: 7, top: 22, width: 22, height: 5, background: '#b6b6bc' }} />
  </div>
);

const FolderIcon: React.FC = () => (
  <div style={{ width: 54, height: 42, background: '#8f8f95', borderRadius: 7, position: 'relative' }}>
    <div style={{ position: 'absolute', left: 0, top: -10, width: 24, height: 12, background: '#8f8f95', borderRadius: '6px 6px 0 0' }} />
  </div>
);

const TreeRow: React.FC<{
  depth: number; label: string; icon?: 'tri' | 'triOpen' | 'doc' | 'folder' | 'dash' | 'chip';
  chip?: string; count?: string; size?: number; dim?: boolean;
}> = ({ depth, label, icon, chip, count, size = 58, dim }) => (
  <div style={{
    display: 'flex', alignItems: 'center', gap: 30, paddingLeft: 40 + depth * 90,
    height: size * 2.1, opacity: dim ? 0.35 : 1,
  }}>
    {icon === 'tri' && <Tri />}
    {icon === 'triOpen' && <Tri open />}
    {icon === 'doc' && <DocIcon />}
    {icon === 'folder' && <FolderIcon />}
    {icon === 'dash' && <div style={{ width: 40, height: 40, border: '6px dashed #a2a2a8', borderRadius: 10 }} />}
    {chip && <Chip letter={chip} tone="#d4d4da" />}
    <div style={{ fontFamily: FONT, fontSize: size, color: INK, fontWeight: 500 }}>{label}</div>
    {count && <div style={{ marginLeft: 'auto', marginRight: 80, fontFamily: FONT, fontSize: size * 0.85, color: MID }}>{count}</div>}
  </div>
);

const RecentCard: React.FC<{ title: string; sub: string; w?: number }> = ({ title, sub, w = 880 }) => (
  <div style={{
    width: w, border: `4px solid ${FAINT}`, borderRadius: 24, padding: '36px 44px',
    display: 'flex', flexDirection: 'column', gap: 18, background: '#fbfbfa',
  }}>
    <div style={{ display: 'flex', alignItems: 'center', gap: 26 }}>
      <div style={{ width: 44, height: 44, border: '6px solid #85858b', borderRadius: 8 }} />
      <div style={{ fontFamily: FONT, fontSize: 52, color: INK, fontWeight: 600 }}>{title}</div>
    </div>
    <div style={{ fontFamily: FONT, fontSize: 42, color: MID, paddingLeft: 70 }}>{sub}</div>
  </div>
);

/* 场景 A:侧栏 SPACES 树 + 右侧 Recent 卡(对标截图 1/2/4)
 * v2:树行/卡片按镜头行进方向(自上而下)先后从空中落贴回界面 */
const SceneTree: React.FC<{ t?: number }> = ({ t = 1 }) => {
  // 树行落地时刻:镜头由树顶滑向树底,行 index 越大越晚落
  const L = (i: number, n = 14) => liftOf(t, 0.22 + (i / n) * 0.62, 130);
  const rows: [number, string, 'tri' | 'triOpen' | 'doc' | 'folder' | 'dash' | undefined, string | undefined, string | undefined][] = [
    [0, 'People & Teams', 'doc', undefined, undefined],
    [0, 'Goals', 'doc', undefined, undefined],
    [0, 'Docs', 'doc', undefined, undefined],
    [0, 'More', 'dash', undefined, undefined],
    [0, 'EPD', 'tri', 'E', undefined],
    [0, 'Product roadmap', 'tri', 'P', undefined],
    [0, 'Design', 'triOpen', 'D', undefined],
    [1, 'Designer handbook', 'doc', undefined, undefined],
    [1, '3.0', 'folder', undefined, undefined],
    [1, 'Design system', 'folder', undefined, undefined],
    [2, 'Design system', 'doc', undefined, undefined],
    [2, 'Components', 'dash', undefined, '56'],
    [2, 'Patterns', 'dash', undefined, '8'],
    [2, 'Tokens', 'dash', undefined, '256'],
  ];
  return (
    <div style={{ width: 2900, height: 2400, background: '#f5f5f4', display: 'flex' }}>
      <div style={{ width: 1500, borderRight: `4px solid ${FAINT}`, paddingTop: 60, background: '#f2f2f1' }}>
        {rows.slice(0, 4).map((r, i) => (
          <FloatWrap key={r[1] + i} h={L(i)}>
            <TreeRow depth={r[0]} label={r[1]} icon={r[2]} chip={r[3]} count={r[4]} />
          </FloatWrap>
        ))}
        <div style={{ height: 90 }} />
        <FloatWrap h={L(4)}>
          <div style={{ paddingLeft: 48, fontFamily: FONT, fontSize: 46, letterSpacing: 6, color: MID, fontWeight: 600 }}>SPACES</div>
        </FloatWrap>
        <div style={{ height: 30 }} />
        {rows.slice(4).map((r, i) => (
          <FloatWrap key={r[1] + i} h={L(i + 4.6)}>
            <TreeRow depth={r[0]} label={r[1]} icon={r[2]} chip={r[3]} count={r[4]} />
          </FloatWrap>
        ))}
      </div>
      <div style={{ flex: 1, paddingTop: 100, paddingLeft: 110 }}>
        <FloatWrap h={liftOf(t, 0.3, 150)}>
          <div style={{ display: 'flex', gap: 90, fontFamily: FONT, fontSize: 52 }}>
            <div style={{ color: INK, fontWeight: 700 }}>Recent</div>
            <div style={{ color: MID }}>Favorites</div>
          </div>
        </FloatWrap>
        <div style={{ height: 64 }} />
        <div style={{ display: 'flex', flexDirection: 'column', gap: 44 }}>
          <FloatWrap h={liftOf(t, 0.42, 170)}>
            <RecentCard title="Logo" sub="Brand refresh" />
          </FloatWrap>
          <FloatWrap h={liftOf(t, 0.55, 170)}>
            <RecentCard title="Split.io Access for Oleg" sub="Team credentials" />
          </FloatWrap>
        </div>
        <div style={{ height: 110 }} />
        <FloatWrap h={liftOf(t, 0.68, 150)}>
          <div style={{ display: 'flex', gap: 90, fontFamily: FONT, fontSize: 50 }}>
            <div style={{ color: INK, fontWeight: 700 }}>Todo</div>
            <div style={{ color: MID }}>Comments</div>
            <div style={{ color: MID }}>Done</div>
          </div>
        </FloatWrap>
        <div style={{ height: 50 }} />
        <FloatWrap h={liftOf(t, 0.8, 150)}>
          <div style={{ display: 'flex', gap: 40, alignItems: 'center' }}>
            <div style={{
              padding: '22px 46px', background: '#e3e3ec', borderRadius: 18,
              fontFamily: FONT, fontSize: 46, color: INK, fontWeight: 600,
            }}>≡ List</div>
            <div style={{ fontFamily: FONT, fontSize: 46, color: MID }}>▦ Gallery</div>
          </div>
        </FloatWrap>
      </div>
    </div>
  );
};

/* 场景 B:顶部 tab 条 + 左上侧栏导航(对标截图 6/7)
 * v2:tab 条、logo、Home 行、侧栏项先后从空中贴落 */
const SceneTopNav: React.FC<{ t?: number }> = ({ t = 1 }) => (
  <div style={{ width: 3000, height: 2100, background: '#f5f5f4', borderRadius: 48 }}>
    <div style={{
      height: 150, borderBottom: `4px solid ${FAINT}`, display: 'flex', alignItems: 'center',
      gap: 120, paddingLeft: 90, fontFamily: FONT, fontSize: 54, color: INK,
    }}>
      {['Product analytics', 'ClickUp 3.0', 'Widget brainstorm', 'Design system'].map((tb, i) => (
        <FloatWrap key={tb} h={liftOf(t, 0.2 + i * 0.1, 140)}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 26, fontWeight: i === 1 ? 700 : 400, opacity: i > 1 ? 0.75 : 1 }}>
            <div style={{
              width: 40, height: 40, borderRadius: i === 1 ? 14 : 8,
              background: i === 1 ? '#7d7d84' : 'transparent',
              border: i === 1 ? 'none' : '5px solid #9a9aa0',
            }} />
            {tb}
          </div>
        </FloatWrap>
      ))}
    </div>
    <div style={{ display: 'flex' }}>
      <div style={{ width: 1250, padding: '70px 70px 0' }}>
        <FloatWrap h={liftOf(t, 0.34, 150)}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 30 }}>
            <div style={{ width: 74, height: 74, borderRadius: 20, background: 'linear-gradient(135deg,#a9a9af,#6f6f76)' }} />
            <div style={{ fontFamily: FONT, fontSize: 66, fontWeight: 800, color: INK }}>ClickUp</div>
            <div style={{ marginLeft: 'auto', width: 130, height: 90, border: `4px solid ${FAINT}`, borderRadius: 22, background: '#fff' }} />
          </div>
        </FloatWrap>
        <div style={{ height: 60 }} />
        <FloatWrap h={liftOf(t, 0.46, 160)}>
          <div style={{
            display: 'flex', alignItems: 'center', gap: 34, background: '#e6e6f0',
            border: '4px solid #c5c5d4', borderRadius: 24, padding: '30px 44px',
          }}>
            <div style={{ width: 48, height: 44, border: '6px solid #5f5f66', borderBottom: 'none', borderRadius: '10px 10px 0 0' }} />
            <div style={{ fontFamily: FONT, fontSize: 56, color: '#4a4a55', fontWeight: 600 }}>Home</div>
            <div style={{
              marginLeft: 'auto', width: 62, height: 62, borderRadius: 31, background: '#a5a5ab',
              color: '#fff', fontFamily: FONT, fontSize: 38, fontWeight: 700,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
            }}>3</div>
          </div>
        </FloatWrap>
        {['Inbox', 'Company', 'People & Teams', 'Goals', 'Docs'].map((tb, i) => (
          <FloatWrap key={tb} h={liftOf(t, 0.55 + i * 0.08, 140)}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 34, height: 128, paddingLeft: 44 }}>
              <div style={{ width: 44, height: 44, border: '6px solid #93939a', borderRadius: 10 }} />
              <div style={{ fontFamily: FONT, fontSize: 54, color: INK }}>{tb}</div>
            </div>
          </FloatWrap>
        ))}
      </div>
      <div style={{ flex: 1, borderLeft: `4px solid ${FAINT}`, padding: '70px 90px 0' }}>
        <FloatWrap h={liftOf(t, 0.4, 150)}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 40, color: MID, fontFamily: FONT, fontSize: 52 }}>
            <div>‹</div><div>›</div>
            <div style={{ width: 44, height: 40, border: '6px solid #93939a', borderBottom: 'none', borderRadius: '10px 10px 0 0' }} />
            <div style={{ color: INK }}>Home</div>
          </div>
        </FloatWrap>
        <div style={{ height: 80 }} />
        <FloatWrap h={liftOf(t, 0.58, 180)}>
          <div style={{ fontFamily: FONT, fontSize: 130, fontWeight: 750, color: INK }}>Home</div>
        </FloatWrap>
        <div style={{ height: 70 }} />
        <FloatWrap h={liftOf(t, 0.74, 160)}>
          <div style={{
            display: 'flex', alignItems: 'center', gap: 36, border: `4px solid ${FAINT}`,
            borderRadius: 26, padding: '34px 46px', background: '#fff', width: 1100,
          }}>
            <div style={{ width: 44, height: 44, borderRadius: 22, border: '6px solid #9a9aa0' }} />
            <div style={{ fontFamily: FONT, fontSize: 48, color: MID }}>Search by app, filetype…</div>
          </div>
        </FloatWrap>
      </div>
    </div>
  </div>
);

/* 场景 C:列表行(对标截图 4 右下 TODAY/TASK NAME 区)
 * v2:Todo 头/TODAY 徽章/任务行自上而下先后贴落 */
const SceneListRows: React.FC<{ t?: number }> = ({ t = 1 }) => (
  <div style={{ width: 2900, height: 2200, background: '#f6f6f5', paddingTop: 60 }}>
    <FloatWrap h={liftOf(t, 0.22, 150)}>
      <div style={{ display: 'flex', gap: 100, paddingLeft: 120, fontFamily: FONT, fontSize: 54 }}>
        <div style={{ color: INK, fontWeight: 700 }}>Todo</div>
        <div style={{ color: MID }}>Comments</div>
        <div style={{ color: MID }}>Done</div>
        <div style={{ color: MID }}>Delegated</div>
      </div>
    </FloatWrap>
    <div style={{ height: 56 }} />
    <FloatWrap h={liftOf(t, 0.32, 150)}>
      <div style={{ display: 'flex', gap: 44, alignItems: 'center', paddingLeft: 120 }}>
        <div style={{
          padding: '24px 52px', background: '#e2e2ea', borderRadius: 20,
          fontFamily: FONT, fontSize: 50, color: INK, fontWeight: 600,
        }}>≡ List</div>
        <div style={{ fontFamily: FONT, fontSize: 50, color: MID }}>▦ Gallery</div>
        <div style={{ marginLeft: 500, display: 'flex', gap: 80, color: MID, fontFamily: FONT, fontSize: 46 }}>
          <div>Filter</div><div>Group</div><div>Sort</div>
        </div>
      </div>
    </FloatWrap>
    <div style={{ height: 40, borderBottom: `4px solid ${FAINT}`, marginLeft: 120, marginRight: 120 }} />
    <div style={{ height: 60 }} />
    <FloatWrap h={liftOf(t, 0.44, 160)}>
      <div style={{
        marginLeft: 120, display: 'inline-block', padding: '20px 48px', background: '#e4e4e3',
        borderRadius: 16, fontFamily: FONT, fontSize: 44, letterSpacing: 4, color: '#6f6f75', fontWeight: 600,
      }}>TODAY</div>
    </FloatWrap>
    <div style={{ height: 60 }} />
    <FloatWrap h={liftOf(t, 0.54, 150)}>
      <div style={{ paddingLeft: 120, fontFamily: FONT, fontSize: 42, letterSpacing: 5, color: MID }}>TASK NAME</div>
    </FloatWrap>
    <div style={{ height: 30 }} />
    {['New Bugs Per Week', 'Designer handbook', 'Mobile screens', 'Product roadmap'].map((tb, i) => (
      <FloatWrap key={tb} h={liftOf(t, 0.62 + i * 0.09, 160)}>
        <div style={{
          display: 'flex', alignItems: 'center', gap: 44, height: 170, marginLeft: 120, marginRight: 120,
          borderBottom: '3px solid #e6e6e4',
        }}>
          <div style={{ width: 40, height: 40, borderRadius: 12, background: '#88888e' }} />
          <div style={{ fontFamily: FONT, fontSize: 58, color: INK, fontWeight: 550 }}>{tb}</div>
          <div style={{ marginLeft: 'auto', width: 220, height: 20, background: '#e2e2e8', borderRadius: 10 }} />
          <div style={{ color: MID, fontSize: 60 }}>…</div>
        </div>
      </FloatWrap>
    ))}
  </div>
);

/* ---------- 3D 摄影 ---------- */

type Cam = {
  rx: number; ry: number; rz: number; scale: number;
  x: [number, number]; y: [number, number];
};

const Plane: React.FC<{
  cam: Cam; t: number; edge?: 'left' | 'top'; children: React.ReactNode;
}> = ({ cam, t, edge = 'left', children }) => {
  const x = interpolate(t, [0, 1], cam.x, { easing: easeIO });
  const y = interpolate(t, [0, 1], cam.y, { easing: easeIO });
  return (
    <AbsoluteFill style={{ perspective: 1050, perspectiveOrigin: '50% 46%' }}>
      <div style={{
        position: 'absolute', left: '50%', top: '50%', width: 0, height: 0,
        transformStyle: 'preserve-3d',
        transform: `scale(${cam.scale}) rotateX(${cam.rx}deg) rotateY(${cam.ry}deg) rotateZ(${cam.rz}deg)`,
      }}>
        <div style={{ position: 'absolute', transform: `translate3d(${x}px, ${y}px, 0)` }}>
          <div style={{ position: 'relative', transform: 'translate(-50%, -50%)' }}>
            {/* 屏幕边缘霓虹晕染 */}
            {edge === 'left' ? (
              <>
                <div style={{
                  position: 'absolute', left: -70, top: -40, width: 110, height: '104%',
                  background: 'linear-gradient(185deg, #ff7ab8, #b06cff 55%, #6d4dff)',
                  filter: 'blur(70px)', opacity: 0.9,
                }} />
                <div style={{
                  position: 'absolute', left: -10, top: 0, width: 8, height: '100%',
                  background: 'linear-gradient(180deg, #ffb0d5, #b78cff)', filter: 'blur(3px)', opacity: 0.95,
                }} />
              </>
            ) : (
              <>
                <div style={{
                  position: 'absolute', left: -40, top: -70, width: '104%', height: 110,
                  background: 'linear-gradient(90deg, #ff7ab8, #b06cff 55%, #6d4dff)',
                  filter: 'blur(70px)', opacity: 0.85,
                }} />
                <div style={{
                  position: 'absolute', left: 0, top: -10, width: '100%', height: 8,
                  background: 'linear-gradient(90deg, #ffb0d5, #b78cff)', filter: 'blur(3px)', opacity: 0.9,
                }} />
              </>
            )}
            {children}
            {/* 远端压暗:贴面透视里远处渐暗 */}
            <div style={{
              position: 'absolute', inset: 0,
              background: edge === 'left'
                ? 'linear-gradient(105deg, rgba(0,0,10,0) 30%, rgba(0,0,10,0.35) 75%, rgba(0,0,10,0.6) 100%)'
                : 'linear-gradient(175deg, rgba(0,0,10,0) 35%, rgba(0,0,10,0.3) 80%, rgba(0,0,10,0.55) 100%)',
              pointerEvents: 'none',
            }} />
          </div>
        </div>
      </div>
    </AbsoluteFill>
  );
};

/* 背景霓虹描边矩形(暗场道具) */
const NeonRects: React.FC<{ drift: number }> = ({ drift }) => (
  <AbsoluteFill style={{ overflow: 'hidden' }}>
    <div style={{
      position: 'absolute', left: -140 + drift * 40, top: 240, width: 620, height: 380,
      border: '4px solid #c04dff', borderRadius: 34, filter: 'blur(7px)', opacity: 0.5,
    }} />
    <div style={{
      position: 'absolute', left: 60 + drift * 25, top: 700, width: 420, height: 260,
      border: '4px solid #ff4da8', borderRadius: 28, filter: 'blur(10px)', opacity: 0.4,
    }} />
    <div style={{
      position: 'absolute', right: -180 - drift * 30, top: -80, width: 560, height: 340,
      border: '4px solid #7b4dff', borderRadius: 30, filter: 'blur(12px)', opacity: 0.35,
    }} />
  </AbsoluteFill>
);

// 平移目标以内容坐标 (cx,cy) 给出:translate = (W/2-cx, H/2-cy)
const SEGS: { cam: Cam; edge: 'left' | 'top'; render: (t: number) => React.ReactNode }[] = [
  {
    // 侧栏树:从树顶(SPACES 附近)贴面滑到树底(Components/Patterns/Tokens)
    cam: { rx: 12, ry: 30, rz: -6, scale: 0.95, x: [1450 - 950, 1450 - 880], y: [1200 - 1050, 1200 - 1850] },
    edge: 'left', render: (t) => <SceneTree t={t} />,
  },
  {
    // 顶栏 tab 条 → 右区 Home 大标题
    cam: { rx: 20, ry: -20, rz: 6, scale: 0.95, x: [1500 - 800, 1500 - 2000], y: [1050 - 350, 1050 - 800] },
    edge: 'top', render: (t) => <SceneTopNav t={t} />,
  },
  {
    // 列表行:沿 TASK NAME 行右扫
    cam: { rx: 14, ry: 28, rz: -5, scale: 1.05, x: [1450 - 900, 1450 - 680], y: [1100 - 620, 1100 - 1240] },
    edge: 'left', render: (t) => <SceneListRows t={t} />,
  },
];

const SEG_LEN = 50; // 每段 50 帧,总 150
const FADE = 7;

const Stage: React.FC = () => {
  const frame = useCurrentFrame();
  return (
    <AbsoluteFill style={{ background: '#060608' }}>
      {SEGS.map((s, i) => {
        const start = i * SEG_LEN;
        const local = frame - start;
        if (local < -FADE || local > SEG_LEN + FADE) return null;
        const t = Math.min(1, Math.max(0, local / SEG_LEN));
        // 相邻段交叉淡化:后段在边界前 [-FADE,0] 淡入、前段在 [SEG_LEN-FADE, SEG_LEN] 淡出,边界帧无纯黑
        const opacity = interpolate(local, [-FADE, 0, SEG_LEN - FADE, SEG_LEN], [i === 0 ? 1 : 0, 1, 1, i === SEGS.length - 1 ? 1 : 0]);
        return (
          <AbsoluteFill key={i} style={{ opacity }}>
            <NeonRects drift={t} />
            <Plane cam={s.cam} t={t} edge={s.edge}>{s.render(t)}</Plane>
          </AbsoluteFill>
        );
      })}
    </AbsoluteFill>
  );
};

export const GrazeFaceTour: React.FC = () => {
  const frame = useCurrentFrame();
  // 呼吸感的焦点带(浅景深):焦点椭圆随段落略移
  const seg = Math.min(2, Math.floor(frame / SEG_LEN));
  const focusX = [44, 40, 46][seg];
  const focusY = [46, 40, 50][seg];
  return (
    <AbsoluteFill style={{ background: '#060608' }}>
      <Stage />
      {/* 屏幕空间浅景深:焦点带外整体模糊 */}
      <AbsoluteFill style={{
        filter: 'blur(16px) brightness(0.92)',
        WebkitMaskImage: `radial-gradient(ellipse 58% 52% at ${focusX}% ${focusY}%, transparent 34%, rgba(0,0,0,0.85) 72%, black 92%)`,
        maskImage: `radial-gradient(ellipse 58% 52% at ${focusX}% ${focusY}%, transparent 34%, rgba(0,0,0,0.85) 72%, black 92%)`,
      }}>
        <Stage />
      </AbsoluteFill>
      {/* 暗角 + 冷调压暗 */}
      <AbsoluteFill style={{
        background: 'radial-gradient(ellipse 90% 80% at 50% 45%, transparent 40%, rgba(2,2,6,0.55) 78%, rgba(1,1,4,0.9) 100%)',
        pointerEvents: 'none',
      }} />
    </AbsoluteFill>
  );
};


  1. Read the recipe
    Prompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points.
  2. Get the code
    tool: git
    git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install
  3. Register + render
    tool: remotion
    npx remotion render <CompositionId> out.mp4 # component: demos/camera/graze-face-tour/GrazeFaceTour.tsx
  4. Or re-implement
    Port the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing.
paramtypedefaultdescription
—

Mais nesta categoria