Motion · Camera

Emotional camera moves

Four emotional camera moves — bullet-time freeze-orbit, dutch-roll to level, slow-push-in, and pull-back isolation; the camera makes the viewer "feel" rather than "see."

1920×108016:930 fps5.06s · 152 framesremotionLicense: Apache-2.0#shotcraft#remotion#camera

Source: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Remotion composition (React/TSX) — preview video, no in-browser player

Variants

bullet-time-freeze-orbitbullet-time-freeze-orbit · Freeze-orbit
A chart mid-growth freezes completely, the camera orbits the hovering UI plane with a 55° rotateY sweep and back, then time resumes and the growth finishes
The coronation moment for a core stat/chart; the beat that's "worth stopping the clock for"
dutch-roll-to-leveldutch-roll-to-level · Roll to level
The pain-point passage holds a -10° dutch tilt for the whole frame (with a slight drift); the solution beat rolls back level in one motion with a single overshoot
The pain-point-to-solution flip beat; a course-correction meaning
slow-push-inslow-push-in · Slow push, building pressure
A 4s constant-acceleration push from 1.00 to 1.14 plus deepening vignette, releasing with an abrupt hard cut to a bright shot at the tension's peak
Building up before a big number/statement; a dark-to-bright chapter payoff
pull-back-isolationpull-back-isolation · Pull back, isolate
Pulling back from a close-up on the glowing hero card, sibling cards dim out in staggered order by distance, the background sinks to black, leaving the lone card suspended center-frame in the dark
Ending closure; the curtain call for "the whole film was for this one number"

When to use

Camera language for emotional beats (awe/correction/pressure build/closure); complements space-camera-moves' "flashy big moves" — these four are small in motion, heavy in emotion

Intention

space-camera-moves' three styles are highlight flair — "shooting the page as a 3D solid." These four are the opposite — the motion itself stays restrained, and all the force goes into emotional meaning: A says "this moment deserves to have time stopped for it" (an element mid-motion freezes, the camera orbits and inspects); B says "the problem is solved, the world has been righted" (a tilted, uneasy angle rolls back level on the beat); C says "something is about to happen" (an imperceptibly slow push-in builds tension, releasing with a hard cut at the peak); D says "in the end only this one thing remains" (pulling back as everything around dims layer by layer, one point left isolated to close the film). Each of the four occupies its own emotional slot — direct vocabulary for "what should the viewer feel here" at the storyboard stage.

Duration & energy

  • Duration: Single style 4–5s; at most 2 styles per film (each used ≤1 time)
  • Energy: A high / B medium / C low-building / D low-closing

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

意图

space-camera-moves 三式是"把页面当 3D 实体拍"的高光炫技;这四式相反—— 动作本身克制,全部劲道在情绪语义上:A 说"这一刻值得停下时间看" (运动中的元素被冻住、相机绕行审视);B 说"问题解决了,世界被扶正" (斜角悬着的难受感在节拍上滚回水平);C 说"有什么要发生了"(慢到 不自觉的推近积压张力,顶点硬切释放);D 说"最后只剩这一件事" (后拉中周围逐层熄灭,孤悬一点收束全片)。四式各占一个情绪格, 是分镜阶段"这一段观众该感到什么"的直接词汇。

四式选型

| 式 | 做法 | 适用 | |----|------|------| | A bullet-time-freeze-orbit 冻结环绕 | 图表生长到一半全冻住,相机绕悬停的 UI 平面 rotateY 扫 55° 再回,时间恢复接着长完 | 核心数据/图表的加冕时刻;"值得停表看"的一格 | | B dutch-roll-to-level 斜角滚正 | 痛点段整帧 -10° 斜角悬着(叠微漂移),解决方案一拍带单次过冲滚回水平 | 痛点→方案的翻转节拍;纠偏语义 | | C slow-push-in 慢推压迫 | 4s 匀加速推近 1.00→1.14 + 暗角渐深,张力顶点无过渡硬切亮场 | 大数字/宣言前的蓄力;暗→亮的章节爆点 | | D pull-back-isolation 拉远孤立 | 从发光主卡特写后拉,兄弟卡按距离错峰熄灭、背景沉黑,孤卡悬在暗场中央 | 结尾收束;"全片只为这一个数字"的谢幕 |

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | A 双时钟 | 内容动画用 effFrame = f<F1 ? f : f<F2 ? F1 : F1+(f-F2) 驱动,相机用真实 f 驱动 | 命门:冻结期内容必须肉眼可见地完全静止,相机却在动——两个时钟各管各的 | | A 环绕 | perspective 1600 下 rotateY 0→55°→0(两段 inOut cubic,顶点悬停 ~10f)+ scale 1→1.12→1 | 55° 是"绕到侧面看"的下限;>70° 面板变纸片穿帮 | | B 斜置 | rotate -10° + ±0.8° 长周期正弦漂移 + 2px 纵漂;scale 1.15 防露边 | 漂移是"悬着难受"的活感,纯静止斜角读作构图错误 | | B 滚正 | 14f ease-out 冲过 0 到 +1.2° → 10f 收回 0(单次过冲不振荡);scale 同步 1.15→1.08 | 过冲即"扶正的手劲";振荡两次以上读作弹簧玩具 | | C 推近 | scale [0,120f]→[1.00,1.14] Easing.in(quad) + 暗角 opacity 0→0.5 同曲线 | 加速曲线是本体——匀速推读作普通 zoom;幅度 >1.2 变成普通推镜头 | | C 硬切 | 顶点帧零过渡直切下一景;暗景 A→亮景 B 反差要大 | 切的一拍才是释放;加任何过渡等于把弓拉满又慢慢松掉 | | D 后拉 | scale 2.2→0.62 / 110f Easing.out(cubic),origin 锁主卡中心 | 前 20f 主卡仍占满画面,开场即特写不需要另加 hold | | D 熄灭 | 兄弟卡按 hypot 距离排序,帧 30 起每 8f 一张,16f 内 opacity→0 + brightness→0.3;背景 60–110f 沉入 #141414 | 由近到远熄灭 = "世界向外塌暗";乱序熄灭读作故障 | | D 光晕 | 主卡双层白 box-shadow 60–100f 淡入 | 光晕是"只剩它"的视觉证词,与沉黑同步才成立 | | 收尾 | 四式动作完毕真静止 ≥30f | R1;D 式是全片句号,静止给足 40f+ |

已知坑

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

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

  • C 式"前 2 秒不可察"是设计意图不是缺陷——裁决/验收看完整播放,抽帧

diff 前段变化极小属正常(已确认成立)

  • A 式与 speed-ramp-freeze/impact-feedback 同为时间操纵,同一镜头别叠用

(帧 remap 互相打架);A 的冻结是"停给你看"+相机动,speed-ramp 的 定格是"停下来标注"——选语义不选形似

  • B 式斜角只压"痛点"段——全片常态斜角是 MV 语言,产品片里读作镜头没扶稳
  • C 式硬切后的亮景必须信息完整可读(它承接全部积压的注意力);

切进另一个铺垫段等于白蓄力

  • D 式放结尾时后接 outro-group-photo-launch 会打架(一收一放),二选一

参考实现

demos/camera/tension-camera-moves/ (BulletTimeFreezeOrbit.tsx / DutchRollToLevel.tsx / PullBackIsolation.tsx / SlowPushIn.tsx)

Reference implementation (Remotion)

  • demos/camera/tension-camera-moves/BulletTimeFreezeOrbit.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/camera/tension-camera-moves/BulletTimeFreezeOrbit.tsx
  • demos/camera/tension-camera-moves/DutchRollToLevel.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/camera/tension-camera-moves/DutchRollToLevel.tsx
  • demos/camera/tension-camera-moves/PullBackIsolation.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/camera/tension-camera-moves/PullBackIsolation.tsx
  • demos/camera/tension-camera-moves/SlowPushIn.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/camera/tension-camera-moves/SlowPushIn.tsx

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

Everything on this page is also machine-readable. Point your agent at the JSON, or add the gimgs MCP server and ask it to search motion templates.

  1. Fetch the JSON — it holds the shot recipe (timing, easing, parameter table, pitfalls) and the full TSX source.
  2. Clone the video-shotcraft repo; the component imports shared fixtures from demos/_fixtures and helpers from assets/lib.
  3. Register the component as a <Composition> in a Remotion project and render with `npx remotion render`, or re-implement the recipe in your own engine.
curl -s https://gimgs.net/motion/tension-camera-moves.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/camera/tension-camera-moves/BulletTimeFreezeOrbit.tsx, demos/camera/tension-camera-moves/DutchRollToLevel.tsx, demos/camera/tension-camera-moves/PullBackIsolation.tsx, demos/camera/tension-camera-moves/SlowPushIn.tsx
npx remotion render <CompositionId> out.mp4
claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("tension-camera-moves")

Prompt

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

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

16,387 chars · Apache-2.0Open raw template ↗
// ===== demos/camera/tension-camera-moves/BulletTimeFreezeOrbit.tsx =====
// 子弹时间冻结环绕(bullet-time-freeze-orbit)——The Matrix bullet time。
// 中央 900×560 面板内 5 根柱状图错峰生长(动画时钟 effFrame 驱动)。
// 关键帧:0–20 hold 读布景;20–45 柱子正常生长;45–105 时钟咬死(柱子完全静止),
// 相机 perspective(1600px) 下 rotateY 0→55°(45–72)→顶点悬停(72–82)→回 0(82–105),
// 同步 scale 1→1.12→1 + translateX 摆动增强绕行感;105–120 时钟恢复柱子长完;
// 118–128 数字标签浮现;128–150 全静止收尾。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G, TitleBlock } from '../../_fixtures/Fixtures';

const h = (n: number) => {
  const s = Math.sin(n * 127.3) * 43758.5453;
  return s - Math.floor(s);
};

const PANEL_W = 900;
const PANEL_H = 560;
const BAR_COUNT = 5;

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

  // ── 子弹时间时钟:0–45 正常走,45–105 冻结,105 起恢复 ──
  const effFrame =
    frame < 45 ? frame : frame < 105 ? 45 : 45 + (frame - 105);

  // ── 冻结区间的相机环绕(用真实 frame 驱动) ──
  const rotY =
    frame < 72
      ? interpolate(frame, [45, 72], [0, 55], {
          extrapolateLeft: 'clamp',
          extrapolateRight: 'clamp',
          easing: Easing.inOut(Easing.cubic),
        })
      : frame < 82
        ? 55 // 顶点悬停 10f
        : interpolate(frame, [82, 105], [55, 0], {
            extrapolateLeft: 'clamp',
            extrapolateRight: 'clamp',
            easing: Easing.inOut(Easing.cubic),
          });
  const orbitT = rotY / 55; // 0→1→0,复用做 scale / translateX
  const scale = 1 + 0.12 * orbitT;
  const tx = -170 * Math.sin(orbitT * Math.PI * 0.5) - 60 * orbitT; // 绕行横摆
  const ty = -24 * orbitT;

  // ── 柱子:错峰生长,全部由 effFrame 驱动(冻结即静止) ──
  const chartW = PANEL_W - 140;
  const chartH = PANEL_H - 190;
  const barW = 92;
  const gap = (chartW - BAR_COUNT * barW) / (BAR_COUNT - 1);
  const bars = Array.from({ length: BAR_COUNT }).map((_, i) => {
    const full = chartH * (0.42 + h(i + 1) * 0.55); // 目标高度
    const start = 20 + i * 4;
    const end = 48 + i * 3; // 20–60f 区间内错峰
    const p = interpolate(effFrame, [start, end], [0, 1], {
      extrapolateLeft: 'clamp',
      extrapolateRight: 'clamp',
      easing: Easing.out(Easing.cubic),
    });
    const value = Math.round((full / chartH) * 100); // 标签与柱高一致

    return { hNow: full * p, full, value, done: p >= 1 };
  });

  // ── 恢复段:数字标签浮现(118–128),之后全静止 ──
  const labelOp = interpolate(frame, [118, 128], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  // 冻结提示条:冻结期间显示 FREEZE 徽标,帮助读出手法
  const freezeOp = interpolate(frame, [45, 52, 98, 105], [0, 1, 1, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return (
    <div
      style={{
        width: 1920,
        height: 1080,
        background: G.bg,
        position: 'relative',
        overflow: 'hidden',
        fontFamily: 'Helvetica, Arial, sans-serif',
      }}
    >
      <div style={{ position: 'absolute', top: 56, left: 72 }}>
        <TitleBlock text="BULLET TIME" size={44} />
      </div>

      {/* 冻结徽标 */}
      <div
        style={{
          position: 'absolute',
          top: 62,
          right: 84,
          opacity: freezeOp,
          background: G.ink,
          color: G.card,
          fontWeight: 800,
          fontSize: 30,
          letterSpacing: 4,
          padding: '12px 26px',
          borderRadius: 10,
        }}
      >
        FREEZE
      </div>

      {/* 3D 舞台 */}
      <div
        style={{
          position: 'absolute',
          inset: 0,
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          perspective: 1600,
        }}
      >
        <div
          style={{
            width: PANEL_W,
            height: PANEL_H,
            background: G.card,
            border: `2px solid ${G.border}`,
            borderRadius: 18,
            boxShadow: '0 16px 48px rgba(0,0,0,0.14)',
            boxSizing: 'border-box',
            padding: '44px 70px',
            transform: `translateX(${tx}px) translateY(${ty}px) rotateY(${rotY}deg) scale(${scale})`,
            transformStyle: 'preserve-3d',
          }}
        >
          {/* 面板标题条 */}
          <div
            style={{
              height: 20,
              width: 260,
              background: G.bar,
              borderRadius: 10,
              marginBottom: 14,
            }}
          />
          <div
            style={{
              height: 12,
              width: 170,
              background: G.line,
              borderRadius: 6,
              marginBottom: 30,
            }}
          />

          {/* 图表区:横向刻度线 + 柱子 */}
          <div style={{ position: 'relative', width: chartW, height: chartH }}>
            {[0, 1, 2, 3].map((i) => (
              <div
                key={i}
                style={{
                  position: 'absolute',
                  left: 0,
                  right: 0,
                  top: (chartH / 4) * i,
                  height: 2,
                  background: G.line,
                }}
              />
            ))}
            {/* 基线 */}
            <div
              style={{
                position: 'absolute',
                left: 0,
                right: 0,
                bottom: 0,
                height: 3,
                background: G.mid,
              }}
            />
            {bars.map((b, i) => (
              <div key={i}>
                <div
                  style={{
                    position: 'absolute',
                    left: i * (barW + gap),
                    bottom: 3,
                    width: barW,
                    height: b.hNow,
                    background: i % 2 === 0 ? G.bar : G.mid,
                    borderRadius: '8px 8px 0 0',
                  }}
                />
                {/* 数字标签:恢复段浮现 */}
                <div
                  style={{
                    position: 'absolute',
                    left: i * (barW + gap),
                    bottom: 3 + b.full + 14 - 10 * (1 - labelOp),
                    width: barW,
                    textAlign: 'center',
                    fontWeight: 800,
                    fontSize: 28,
                    color: G.ink,
                    opacity: labelOp,
                  }}
                >
                  {b.value}
                </div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};


// ===== demos/camera/tension-camera-moves/DutchRollToLevel.tsx =====
import React from 'react';
import { AbsoluteFill, Easing, interpolate, useCurrentFrame } from 'remotion';
import { FakeDashboard, Card, G } from '../../_fixtures/Fixtures';

// 斜角滚正(dutch-roll-to-level):呈现痛点时整帧带 -10° 斜角悬着(叠极缓慢
// 正弦漂移防止读作静态歪图),帧 70 解决方案的一拍整帧带单次过冲滚回水平
// (-10° → +1.2° → 0),同时警示条淡出、干净卡浮现——"世界被扶正"打在节拍上。
// 帧 0–70 斜置漂移 / 70–84 滚正冲过头 / 84–94 收回 0 / 94–140 真静止。

const ROLL = 70; // 滚正起拍
const LEVEL = 94; // 完全归位帧

export const DutchRollToLevel: React.FC = () => {
  const f = useCurrentFrame();

  // —— 斜置期的缓慢漂移(帧 70 前):±0.8° 长周期正弦 + 2px 纵漂 ——
  const driftT = Math.min(f, ROLL);
  const driftRot = Math.sin(driftT * 0.035) * 0.8;
  const driftY = Math.sin(driftT * 0.05) * 2;
  // 滚正期间漂移随进度淡出
  const driftFade = interpolate(f, [ROLL, ROLL + 6], [1, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  // —— 滚正:-10° 用 14f 冲过 0 到 +1.2°,再 10f 收回 0(单次过冲不振荡) ——
  const baseRot =
    f < ROLL
      ? -10
      : f < ROLL + 14
        ? interpolate(f, [ROLL, ROLL + 14], [-10, 1.2], {
            easing: Easing.out(Easing.cubic),
          })
        : interpolate(f, [ROLL + 14, LEVEL], [1.2, 0], {
            extrapolateRight: 'clamp',
            easing: Easing.inOut(Easing.quad),
          });

  const rot = baseRot + driftRot * driftFade;
  const y = driftY * driftFade;

  // scale 1.15(防旋转露边)→ 滚正同步收到 1.08
  const scale = interpolate(f, [ROLL, LEVEL], [1.15, 1.08], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
    easing: Easing.inOut(Easing.cubic),
  });

  // —— 警示条(痛点):斜置期悬在上方,滚正一拍淡出;干净卡反向淡入 ——
  const alertOpacity = interpolate(f, [ROLL, ROLL + 12], [1, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const cleanOpacity = interpolate(f, [ROLL + 8, ROLL + 22], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return (
    <AbsoluteFill style={{ background: G.bg, overflow: 'hidden' }}>
      <div
        style={{
          position: 'absolute',
          inset: 0,
          transform: `translateY(${y}px) rotate(${rot}deg) scale(${scale})`,
          transformOrigin: '50% 50%',
        }}
      >
        <FakeDashboard variant="B" />

        {/* 痛点警示条:深色横幅压在页面上方(斜着更显歪) */}
        <div
          style={{
            position: 'absolute',
            left: 560,
            top: 120,
            width: 800,
            height: 88,
            background: G.ink,
            borderRadius: 14,
            opacity: alertOpacity * 0.92,
            display: 'flex',
            alignItems: 'center',
            gap: 18,
            padding: '0 28px',
            boxSizing: 'border-box',
            boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
          }}
        >
          {/* 警示三角 */}
          <div
            style={{
              width: 0,
              height: 0,
              borderLeft: '18px solid transparent',
              borderRight: '18px solid transparent',
              borderBottom: '32px solid #f7f7f6',
            }}
          />
          <div style={{ height: 16, width: 420, background: G.mid, borderRadius: 8 }} />
        </div>

        {/* 解决方案:干净卡随滚正浮现在同一位置 */}
        <div style={{ position: 'absolute', left: 560, top: 96, opacity: cleanOpacity }}>
          <Card w={800} h={140} seed={2} style={{ boxShadow: '0 10px 30px rgba(0,0,0,0.12)' }} />
        </div>
      </div>
    </AbsoluteFill>
  );
};


// ===== demos/camera/tension-camera-moves/PullBackIsolation.tsx =====
// 拉远孤立收束(pull-back-isolation)——pull-back shot。
// 相机容器 scale 2.2→0.62(0–110f,Easing.out(cubic)):开场怼在主卡
// "99.9%" 特写上,缓缓后拉露出周围 8 张兄弟卡。帧 30 起兄弟卡按离主卡
// 距离由近到远错峰熄灭(每 8f 一张,opacity→0 + brightness 压暗);
// 背景 60–110f 从 #ececea 沉入 #141414;主卡白光晕 60–100f 淡入。
// 帧 110–150 完全静止:暗场中央孤悬一张发光小卡——全片只为这一个数字。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G, Card } from '../../_fixtures/Fixtures';

// 8 张兄弟卡:相对主卡中心 (960,540) 的偏移 + 尺寸 + seed
const SIBS = [
  { dx: -620, dy: -330, w: 360, h: 240, seed: 3 },
  { dx: 10, dy: -390, w: 420, h: 220, seed: 4 },
  { dx: 620, dy: -320, w: 380, h: 260, seed: 5 },
  { dx: -680, dy: 20, w: 340, h: 230, seed: 6 },
  { dx: 700, dy: 40, w: 360, h: 250, seed: 7 },
  { dx: -600, dy: 360, w: 400, h: 240, seed: 8 },
  { dx: 40, dy: 400, w: 440, h: 220, seed: 9 },
  { dx: 640, dy: 350, w: 370, h: 250, seed: 10 },
].map((s) => ({ ...s, dist: Math.hypot(s.dx, s.dy) }));

// 按离主卡距离排名 → 错峰熄灭顺序(近的先灭)
const RANKED = SIBS.map((s, i) => i).sort((a, b) => SIBS[a].dist - SIBS[b].dist);
const FADE_START = RANKED.reduce<number[]>((acc, idx, rank) => {
  acc[idx] = 30 + rank * 8;
  return acc;
}, []);
const FADE_DUR = 16;

const clamp = { extrapolateLeft: 'clamp' as const, extrapolateRight: 'clamp' as const };

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

  // 相机后拉:2.2(怼脸特写)→ 0.62(大远景孤悬)
  const scale = interpolate(frame, [0, 110], [2.2, 0.62], {
    easing: Easing.out(Easing.cubic),
    ...clamp,
  });

  // 背景沉入黑暗:#ececea → #141414(60–110f)
  const bgT = interpolate(frame, [60, 110], [0, 1], { easing: Easing.inOut(Easing.quad), ...clamp });
  const bgC = Math.round(236 + (20 - 236) * bgT);
  const bg = `rgb(${bgC},${bgC},${bgC})`;

  // 主卡白光晕淡入(60–100f)
  const glow = interpolate(frame, [60, 100], [0, 0.35], { ...clamp });

  return (
    <div style={{ width: 1920, height: 1080, background: bg, overflow: 'hidden', position: 'relative' }}>
      {/* 相机容器:以主卡中心(画面中心)为原点整体缩放 */}
      <div
        style={{
          position: 'absolute',
          inset: 0,
          transform: `scale(${scale})`,
          transformOrigin: '960px 540px',
        }}
      >
        {/* 兄弟卡:错峰熄灭 */}
        {SIBS.map((s, i) => {
          const t0 = FADE_START[i];
          const op = interpolate(frame, [t0, t0 + FADE_DUR], [1, 0], {
            easing: Easing.out(Easing.quad),
            ...clamp,
          });
          const bright = interpolate(frame, [t0, t0 + FADE_DUR], [1, 0.3], { ...clamp });
          return (
            <div
              key={i}
              style={{
                position: 'absolute',
                left: 960 + s.dx - s.w / 2,
                top: 540 + s.dy - s.h / 2,
                opacity: op,
                filter: `brightness(${bright})`,
              }}
            >
              <Card w={s.w} h={s.h} seed={s.seed} />
            </div>
          );
        })}

        {/* 主卡:520×340 居中,叠大数字 + 白光晕 */}
        <div
          style={{
            position: 'absolute',
            left: 960 - 260,
            top: 540 - 170,
            width: 520,
            height: 340,
            borderRadius: 14,
            boxShadow: `0 0 80px rgba(255,255,255,${glow}), 0 0 160px rgba(255,255,255,${glow * 0.6})`,
          }}
        >
          <Card w={520} h={340} seed={2} />
          <div
            style={{
              position: 'absolute',
              inset: 0,
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              fontFamily: 'Helvetica, Arial, sans-serif',
              fontWeight: 800,
              fontSize: 128,
              letterSpacing: -3,
              color: G.ink,
              background: 'rgba(255,255,255,0.72)',
              borderRadius: 14,
            }}
          >
            99.9%
          </div>
        </div>
      </div>
    </div>
  );
};


// ===== demos/camera/tension-camera-moves/SlowPushIn.tsx =====
// 慢推压迫(slow-push-in)——studiobinder camera movements。
// 景 A(帧 0–120):深色底 + 白色大数字 "10x",scale 用 Easing.in(Easing.quad)
// 从 1.00 匀加速推到 1.14——前 2 秒几乎不可察,后段明显可感;同时四角径向
// 暗角 opacity 0→0.5 同步加深,构成压迫感的第二来源。
// 帧 120 无任何过渡硬切景 B:满屏亮色 FakeDashboard(A) 真静止 30f——
// 暗→亮的大反差让"切"这一拍才响。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G, FakeDashboard } from '../../_fixtures/Fixtures';

const CUT = 120; // 硬切帧

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

  // ---- 景 B:帧 120 起,满屏亮面板,完全静止 ----
  if (frame >= CUT) {
    return <FakeDashboard variant="A" />;
  }

  // ---- 景 A:0–120f 慢推 ----
  // 匀加速推近:Easing.in(quad)——前段几乎不可察,后段可感
  const scale = interpolate(frame, [0, CUT], [1.0, 1.14], {
    easing: Easing.in(Easing.quad),
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  // 暗角同步加深:压迫感的第二来源
  const vignette = interpolate(frame, [0, CUT], [0, 0.5], {
    easing: Easing.in(Easing.quad),
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return (
    <div
      style={{
        width: 1920,
        height: 1080,
        background: G.side,
        position: 'relative',
        overflow: 'hidden',
        fontFamily: 'Helvetica, Arial, sans-serif',
      }}
    >
      {/* 被推近的内容层:整体 scale */}
      <div
        style={{
          position: 'absolute',
          inset: 0,
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          transform: `scale(${scale})`,
          transformOrigin: '50% 50%',
        }}
      >
        <div
          style={{
            fontSize: 300,
            fontWeight: 800,
            color: '#ffffff',
            letterSpacing: -6,
            lineHeight: 1,
          }}
        >
          10x
        </div>
        <div
          style={{
            marginTop: 36,
            fontSize: 40,
            fontWeight: 500,
            color: '#b8b8b6',
            letterSpacing: 6,
          }}
        >
          FASTER THAN BASELINE
        </div>
      </div>

      {/* 暗角层:四角径向渐变,随推近同步加深 */}
      <div
        style={{
          position: 'absolute',
          inset: 0,
          opacity: vignette,
          background:
            'radial-gradient(ellipse 62% 55% at 50% 50%, rgba(0,0,0,0) 45%, rgba(0,0,0,0.95) 100%)',
          pointerEvents: 'none',
        }}
      />
    </div>
  );
};


  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/tension-camera-moves/BulletTimeFreezeOrbit.tsx, demos/camera/tension-camera-moves/DutchRollToLevel.tsx, demos/camera/tension-camera-moves/PullBackIsolation.tsx, demos/camera/tension-camera-moves/SlowPushIn.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
—

More in this category