Motion · Intro

Spotlight hero card reveal

A spotlight sweeps the page and locks onto one card; it pushes in at 45°, the card lifts and hovers, a light beam circles its outline twice, then it settles back in place.

1920×108016:930 fps5.33s · 160 quadrosremotionLicença: Apache-2.0#shotcraft#remotion#intro#opening#effects#camera

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

"Single hero" style product openings; establishing one core object (a card/entry/module) as the whole film's protagonist

Intention

The opening says only one thing: this one card is the atomic unit of the product. The spotlight does the eye-guiding for the viewer; the lift and hover give it volume and weight; the outline light beam is a metaphor for "scanning/inspecting"; settling back in place says it belongs to this page.

Duration & energy

  • Duration: ~4.6s (82–220f)
  • Energy: Medium (the most textured shot, slow and steady pacing)

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

意图

开场只讲一件事:这一张卡就是产品的原子单位。聚光灯替观众做视线引导,弹起悬浮给它体积和重量,轮廓光束是"扫描/检视"的隐喻,最后贴回原位表示它属于这个页面。

动效核心

  • 游走聚光灯经几个中间站后锁定卡心,光池收拢 + 锁定瞬间脉冲,外部 vignette 压暗
  • 相机从全页正视推进到斜侧特写(左侧机位主导),焦点略偏让卡落在画面偏右
  • 卡片 rise(带过冲)→ 悬停 sin bob → reseat(落地微压)
  • SVG rounded-rect 轮廓光束跑两圈:第一圈快而亮、第二圈慢而弱
  • 卡起飞后原位铺页面底色补丁 + 强调色(模板片为琥珀)呼吸描边,落地瞬间增亮消失
  • 可选加配 3D 悬空注记(hover-3d-annotation 手法):悬停期在卡左侧浮现大衬线两行注记(字号 37、宽 230px),translateZ 92 + sin 3px bob(周期 44f,与卡 40f 相近但不同步——"共感"而非镜像),关键词背后 12f 长出马克笔荧光条 oklch(88% 0.095 85),向页面投椭圆软影;注记必须活在同一 3D 空间、同一台相机透视(C3)——第一版平面叠字被否决后重做(平面叠字破坏 3D 空间统一);出场早于卡片 reseat

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 选卡 | cards[3],中心 x 恰为 960(页面中心) | 主角卡先按"推进后构图居中"选,不是随便挑一张 | | 相机 | 静止全页 zoom 0.78(82–114f)→ 16f 推进 zoom 2.6;rotY 34° 主导 + rotX 仅 8°,persp 1200;焦点取卡心左移 30px;reseat 触地后 cx/cy/zoom/rot 全锁死 ≥15f | 侧向水平机位读感优于俯拍("从左侧拍摄而不是从下方",Q6);收尾禁用 zoom 2.6→2.58 一类尾漂,呼吸必须是真静止(R1) | | 聚光灯 | 4 个中间站(86→130f),光池半径 620→420→360 收拢,锁定 +6% 脉冲;vignette 0.16→0.42 | 中间站让"随机照射"可信;直奔目标读作程序化 | | 动作弧 | rise 10f(bezier(0.2,1.25,0.3,1) 过冲)→ 悬停 54f(sin bob 振幅 4px 周期 40f,z=110px·lift)→ reseat 18f,落地 press 0.997 | 锁定→落地 ≈98f≈3.3s——质感镜头要"放慢到 3 秒"(R3),初版几乎总是偏快 | | 轮廓光束 | lap1 142→156f(strokeWidth 5+2.5 双层)、lap2 162→182f(3.5+1.75、整体 opacity 0.62);strokeDasharray "0.14 1" 走 dashoffset | 两圈快慢有别才读作"持续扫描",一圈是眨眼;光束只给主角一次(Q4) | | 双层影 | 0 8·lift px …, 0 46·lift px 90·lift px 随高度生长 | 影子不随高度长,悬浮就不成立 |

声音

弹起钉 whoosh-big(模板片钉在 f127),光束扫描钉 sparkle(f141),reseat 钉 transition-snap(f204)——弹起/光效/落定各有专属音(S2、S4)。

已知坑

  • 开场多卡群舞撑不起第一印象(Q5)——开场曾反复推倒多次才收敛到单卡;直接从单主角+完整动作弧起稿
  • 推进特写下卡上文字会糊,根因是纹理栅格化分辨率不是 DoF——配套技法展开见审美准则 Q2
  • 逐卡 glint 闪烁被两次否决("不需要每个卡片都闪烁一下",Q4),光效严格只给主角

参考实现

demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx(原 template/src/aifl/live/SceneOpen.tsx 82–220 段)

Reference implementation (Remotion)

  • demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/opening/spotlight-hero-card/SpotlightHeroCard.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/spotlight-hero-card.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx
npx remotion render <CompositionId> out.mp4
claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("spotlight-hero-card")

Prompt

Use the video-shotcraft shot recipe "Spotlight hero card reveal" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/spotlight-hero-card.json
Raw TSX: https://gimgs.net/motion/spotlight-hero-card/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 5.33s

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

14,173 chars · Apache-2.0Abrir template bruto ↗
// ===== demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx =====
// spotlight-hero-card —— 聚光灯锁定一张卡 → 斜 45° 推进 → 卡片弹起悬浮 →
// 轮廓光束两圈 → 贴回原位。单一主角式产品开场,把核心对象立成全片主角。
// 参考实现从 template SceneOpen 的 82–220 段剥离,self-contained:
// 游走聚光灯经 4 个中间站后锁定卡心(光池收拢 + 锁定脉冲 + vignette 压暗);
// 相机全页正视 → 16f 斜侧推进(rotY 34° 主导 + rotX 8°);卡 rise(带过冲)→
// 悬停 sin bob(54f)→ reseat(18f 落地微压);SVG rounded-rect 轮廓光束两圈
// (lap1 快而亮 / lap2 慢而弱);悬停期左侧 3D 注记 + 马克笔荧光条。
// 主角卡 = layout.projects.cards[3],中心 x 恰为 960(页面中心)。
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';
import { PageCam2D, CamKey2D } from '../../_fixtures/PageCam2D';
import layout from '../../_textures/live-layout.json';

export const SPOTLIGHT_HERO_CARD_DURATION = 139; // 82–220f,落在 shot 内 offset 0

const SERIF = 'ui-serif, Georgia, "Times New Roman", serif';
const INK = 'oklch(18% 0.006 82)';
const AMBER = 'oklch(52% 0.115 65)';
const SLOT_AMBER = 'oklch(58% 0.13 65)';
const BEAM_CORE = 'rgba(255,248,232,0.98)';
const PATCH = 'oklch(97.5% 0.008 82)';

const PAGE_H = layout.projects.pageH;
const MAIN = 3;
const CARD = layout.projects.cards[MAIN];
const MCX = CARD.x + CARD.w / 2; // ≈ 960
const MCY = CARD.y + CARD.h / 2; // 772
const RADIUS = 16;

// Camera: straight-on full page (0→32, static while the spotlight roves and
// locks), then a 16-frame push-in swinging to a LEFT-side view — rotY dominant,
// card's left edge near, right edge recedes; slight downward tilt; then held to
// the end with an almost-imperceptible pull. Focal point sits a touch LEFT of
// the card centre so the card lands slightly right of screen-centre.
const CAM_KEYS: CamKey2D[] = [
  { frame: 0, cx: 960, cy: 540, zoom: 0.78, rotX: 0, rotY: 0, rotZ: 0, persp: 1200 },
  { frame: 32, cx: 960, cy: 540, zoom: 0.78, rotX: 0, rotY: 0, rotZ: 0, persp: 1200 },
  { frame: 48, cx: MCX - 30, cy: MCY, zoom: 2.6, rotX: 8, rotY: 34, rotZ: 2, persp: 1200 },
  { frame: 138, cx: MCX - 30, cy: MCY, zoom: 2.6, rotX: 8, rotY: 34, rotZ: 2, persp: 1200 },
];
const PUSH_EASE = Easing.bezier(0.35, 0, 0.2, 1);
const POP_EASE = Easing.bezier(0.2, 1.25, 0.3, 1);
const RESEAT_EASE = Easing.bezier(0.4, 0, 0.3, 1.05);

export const SpotlightHeroCard: React.FC = () => {
  const frame = useCurrentFrame();

  // --- macro fades in (0 → 8) ---
  const macroIn = interpolate(frame, [0, 8], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),
  });

  // --- roving spotlight (screen space): waypoints then locks on hero card
  // centre (50%, 67% in the straight-on view), rides the push-in ---
  const spotEase = Easing.bezier(0.4, 0, 0.3, 1);
  const spotX = interpolate(frame, [4, 8, 16, 22, 28, 48], [25, 25, 70, 42, 50, 50], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: spotEase,
  });
  const spotY = interpolate(frame, [4, 8, 16, 22, 28, 48], [30, 30, 45, 60, 67, 50], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: spotEase,
  });
  const spotOn = interpolate(frame, [2, 10], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });

  const poolBase = interpolate(frame, [22, 32, 48], [620, 420, 360], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.4, 0, 0.3, 1),
  });
  const poolPulse = interpolate(frame, [32, 36, 41], [0, 0.06, 0], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const poolRx = poolBase * (1 + poolPulse);
  const poolRy = poolBase * 0.8 * (1 + poolPulse);
  const vignette = interpolate(frame, [22, 32, 48], [0.16, 0.34, 0.42], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  // --- hero card pop-up: rise (48→58 overshoot), hover (58→112, 54f sin bob),
  // reseat (112→130, 18f gentle) — lock(32)→touchdown(130) ≈ 98f ≈ 3.3s ---
  const rise = interpolate(frame, [48, 58], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: POP_EASE,
  });
  const reseat = interpolate(frame, [112, 130], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: RESEAT_EASE,
  });
  const lift = rise * (1 - reseat);
  const bob = Math.sin(((frame - 58) / 40) * Math.PI * 2) * 4 * lift;
  const z = 110 * lift + bob;
  const landed = frame >= 130;
  const press = interpolate(frame, [126, 129, 130], [1, 0.997, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const shadow = `0 ${8 * lift}px ${10 + 12 * lift}px rgba(40,30,20,${0.18 * lift}), 0 ${46 * lift}px ${90 * lift}px rgba(40,30,20,${0.22 * lift})`;

  // vacated-slot amber outline: alive while airborne, brightens as it lands
  const slotVis = Math.min(1, rise * 2) * (1 - reseat);
  const landPulse = interpolate(frame, [126, 130, 134], [0, 1, 0], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const slotEdge = Math.min(1, 0.4 * (1 - reseat)) + landPulse * 0.6;

  // --- perimeter beam: TWO laps. Lap 1 (60→74) fast & bright; lap 2 (80→100)
  // slower & weaker; a faint amber ring lingers and fades before reseat. ---
  const beam1Prog = interpolate(frame, [60, 74], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.linear,
  });
  const beam1On = frame >= 59 && frame <= 75;
  const beam2Prog = interpolate(frame, [80, 100], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.4, 0, 0.4, 1),
  });
  const beam2On = frame >= 79 && frame <= 101;
  const beamTrail = interpolate(frame, [100, 112], [0.35, 0], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const bw = CARD.w + 6;
  const bh = CARD.h + 6;

  // hi-res hero crossfades in as the push-in starts (32→38)
  const hiresIn = interpolate(frame, [32, 38], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  return (
    <AbsoluteFill style={{ backgroundColor: '#faf7f2' }}>
      <AbsoluteFill style={{ opacity: macroIn }}>
        <PageCam2D src="textures/live/projects-full.png" pageH={PAGE_H} keys={CAM_KEYS} ease={PUSH_EASE}>
          {/* rim light along the near (bottom) edge of the tilted plane */}
          <div
            style={{
              position: 'absolute', left: 0, right: 0, bottom: 0, height: 8,
              background: 'rgba(255,255,255,0.85)', filter: 'blur(6px)',
              opacity: 0.6 * Math.min(1, lift + Math.max(0, (frame - 32) / 16)),
              pointerEvents: 'none',
            }}
          />

          {/* hero card: lifts off its slot, hovers, beam runs, settles back */}
          <div style={{ transformStyle: 'preserve-3d' }}>
            {/* vacated-slot patch + breathing amber outline */}
            {slotVis > 0.02 ? (
              <div
                style={{
                  position: 'absolute', left: CARD.x - 2, top: CARD.y - 2,
                  width: CARD.w + 4, height: CARD.h + 4, background: PATCH,
                  borderRadius: RADIUS,
                  boxShadow: `inset 0 0 26px rgba(180,120,50,${0.12 * slotEdge})`,
                  opacity: slotVis,
                }}
              >
                <div
                  style={{
                    position: 'absolute', inset: 0, borderRadius: RADIUS,
                    border: `1.5px solid ${SLOT_AMBER}`, opacity: slotEdge, pointerEvents: 'none',
                  }}
                />
              </div>
            ) : null}

            {/* the levitating card */}
            <div
              style={{
                position: 'absolute', left: CARD.x, top: CARD.y,
                width: CARD.w, height: CARD.h,
                transform: `translateZ(${z}px) scale(${press})`,
                transformOrigin: 'center center', transformStyle: 'preserve-3d',
              }}
            >
              <div
                style={{
                  position: 'absolute', inset: 0, borderRadius: RADIUS, overflow: 'hidden',
                  boxShadow: landed ? 'none' : shadow,
                }}
              >
                <Img
                  src={staticFile(`textures/live/${CARD.file}`)}
                  style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
                />
                {/* 4x hi-res capture layered on top, crisp under push-in zoom */}
                <Img
                  src={staticFile('textures/live/card4-hires.png')}
                  style={{
                    position: 'absolute', inset: 0, width: '100%', height: '100%',
                    display: 'block', opacity: hiresIn,
                  }}
                />
                <div
                  style={{
                    position: 'absolute', inset: 0,
                    background: 'linear-gradient(160deg, rgba(255,255,255,0.5), transparent 40%)',
                    opacity: lift, pointerEvents: 'none',
                  }}
                />
              </div>
              {/* 1px white inner stroke (lit paper edge) */}
              <div
                style={{
                  position: 'absolute', inset: 0, borderRadius: RADIUS,
                  boxShadow: `inset 0 0 0 1px rgba(255,255,255,${0.7 * lift})`, pointerEvents: 'none',
                }}
              />

              {/* perimeter beam: SVG rounded-rect, pathLength=1 travelling arc */}
              {(beam1On || beam2On) && lift > 0.4 ? (
                <svg
                  width={bw} height={bh} viewBox={`0 0 ${bw} ${bh}`}
                  style={{
                    position: 'absolute', left: -3, top: -3, overflow: 'visible', pointerEvents: 'none',
                    opacity: beam1On ? 1 : 0.62,
                    filter: `drop-shadow(0 0 6px ${AMBER}) drop-shadow(0 0 18px rgba(255,240,210,0.55))`,
                  }}
                >
                  <rect
                    x={2} y={2} width={bw - 4} height={bh - 4} rx={RADIUS} fill="none"
                    stroke={AMBER} strokeWidth={beam1On ? 5 : 3.5} strokeLinecap="round"
                    pathLength={1} strokeDasharray="0.14 1"
                    strokeDashoffset={-(beam1On ? beam1Prog : beam2Prog)}
                  />
                  <rect
                    x={2} y={2} width={bw - 4} height={bh - 4} rx={RADIUS} fill="none"
                    stroke={BEAM_CORE} strokeWidth={beam1On ? 2.5 : 1.75} strokeLinecap="round"
                    pathLength={1} strokeDasharray="0.14 1"
                    strokeDashoffset={-(beam1On ? beam1Prog : beam2Prog)}
                  />
                </svg>
              ) : null}

              {/* faint amber ring left behind, fades out */}
              {beamTrail > 0.01 ? (
                <div
                  style={{
                    position: 'absolute', inset: -3, borderRadius: RADIUS + 3,
                    border: `1.5px solid ${AMBER}`, opacity: beamTrail, pointerEvents: 'none',
                  }}
                />
              ) : null}
            </div>
          </div>

          {/* 3D floating annotation LEFT of the hovering card */}
          {frame >= 60 && frame <= 130 ? (() => {
            const noteIn = interpolate(frame, [60, 70], [0, 1], {
              extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.2, 0.75, 0.3, 1),
            });
            const noteOut = interpolate(frame, [116, 126], [1, 0], {
              extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
            });
            const noteVis = noteIn * noteOut;
            const noteZ = 92 + Math.sin(((frame - 60) / 44) * Math.PI * 2) * 3;
            const hl = interpolate(frame, [74, 86], [0, 1], {
              extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),
            });
            return (
              <div style={{ transformStyle: 'preserve-3d', pointerEvents: 'none' }}>
                <div
                  style={{
                    position: 'absolute', left: 566, top: 736, width: 210, height: 74,
                    transform: 'translateZ(2px)',
                    background: 'radial-gradient(ellipse at 50% 50%, rgba(40,30,20,0.3), transparent 70%)',
                    filter: 'blur(12px)', opacity: 0.55 * noteVis,
                  }}
                />
                <div
                  style={{
                    position: 'absolute', left: 556, top: 668, width: 230,
                    transform: `translateZ(${noteZ}px) translateY(${(1 - noteIn) * 26}px)`,
                    opacity: noteVis, filter: `blur(${(1 - noteIn) * 4}px)`,
                  }}
                >
                  <div style={{ fontFamily: SERIF, fontSize: 37, fontWeight: 600, color: INK, lineHeight: 1.16, letterSpacing: '-0.012em' }}>
                    One card,
                  </div>
                  <div style={{ position: 'relative', display: 'inline-block' }}>
                    <div
                      style={{
                        position: 'absolute', left: -5, top: '12%', bottom: '4%',
                        width: `calc(${hl} * (100% + 10px))`,
                        background: 'oklch(88% 0.095 85)', borderRadius: 4,
                      }}
                    />
                    <div style={{ position: 'relative', fontFamily: SERIF, fontStyle: 'italic', fontSize: 37, fontWeight: 600, color: INK, lineHeight: 1.16, letterSpacing: '-0.012em' }}>
                      one project.
                    </div>
                  </div>
                </div>
              </div>
            );
          })() : null}
        </PageCam2D>

        {/* roving / locking spotlight: warm pool + dim outside */}
        <AbsoluteFill
          style={{
            background: `radial-gradient(${poolRx}px ${poolRy}px at ${spotX}% ${spotY}%, rgba(255,241,214,0.42), rgba(255,241,214,0.10) 45%, rgba(70,56,38,${vignette * spotOn}) 100%)`,
            pointerEvents: 'none', opacity: spotOn,
          }}
        />
        <AbsoluteFill
          style={{
            background: `radial-gradient(300px 220px at ${spotX - 6}% ${spotY + 10}%, rgba(255,246,228,0.18), transparent 70%)`,
            pointerEvents: 'none', opacity: spotOn * 0.7,
          }}
        />
      </AbsoluteFill>
    </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/opening/spotlight-hero-card/SpotlightHeroCard.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