Motion · Camera

Chuyển động máy quay cảm xúc

Bốn kiểu chuyển động máy quay cảm xúc — bullet-time đóng băng xoay quanh, dutch-roll lật về ngang, slow-push đẩy chậm dồn nén, pull-back kéo xa cô lập; máy quay khiến khán giả "cảm" thay vì "nhìn".

1920×108016:930 fps5.06s · 152 frameremotionGiấy phép: Apache-2.0#shotcraft#remotion#camera

Nguồn: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Composition Remotion (React/TSX) — có video preview, không có player trong trình duyệt

Biến thể

bullet-time-freeze-orbitbullet-time-freeze-orbit · Đóng băng xoay quanh
Biểu đồ đang lớn nửa chừng đóng băng hoàn toàn, máy quay xoay quanh mặt phẳng UI đang lơ lửng quét 55° rotateY rồi quay lại, thời gian phục hồi và biểu đồ lớn tiếp
Khoảnh khắc đăng quang cho số liệu/biểu đồ cốt lõi; ô "đáng dừng đồng hồ để xem"
dutch-roll-to-leveldutch-roll-to-level · Lật về ngang
Đoạn điểm đau giữ nghiêng -10° suốt khung hình (kèm trôi nhẹ), đoạn giải pháp lăn về ngang trong một nhịp có một lần overshoot
Nhịp lật từ điểm đau sang giải pháp; ngữ nghĩa sửa lệch
slow-push-inslow-push-in · Đẩy chậm dồn nén
Đẩy tăng tốc đều 4s từ 1.00→1.14 + vignette tối dần, đỉnh điểm căng thẳng cắt cứng không chuyển tiếp sang cảnh sáng
Dồn lực trước con số lớn/tuyên ngôn; điểm bùng nổ chương từ tối sang sáng
pull-back-isolationpull-back-isolation · Kéo xa cô lập
Kéo xa từ cận cảnh thẻ chính đang phát sáng, các thẻ anh em tắt dần lệch nhịp theo khoảng cách, nền chìm đen, thẻ cô đơn lơ lửng giữa nền tối
Thu gọn kết phim; màn hạ "cả phim chỉ vì con số này"

Khi nào dùng

Ngôn ngữ máy quay cho các điểm nút cảm xúc (chấn động/sửa lệch/dồn nén/thu gọn); bổ trợ cho "động tác lớn phô diễn" của space-camera-moves — bốn kiểu này động tác nhỏ, cảm xúc nặng

Ý đồ

Ba kiểu của space-camera-moves là phô diễn cao trào "quay trang như một khối 3D thực"; bốn kiểu này ngược lại — bản thân động tác tiết chế, toàn bộ lực dồn vào ngữ nghĩa cảm xúc: A nói "khoảnh khắc này đáng để dừng thời gian lại xem" (phần tử đang chuyển động bị đóng băng, máy quay xoay quanh xem xét); B nói "vấn đề đã được giải quyết, thế giới được chỉnh lại ngay ngắn" (góc nghiêng khó chịu lăn về ngang đúng nhịp); C nói "có điều gì đó sắp xảy ra" (đẩy chậm tới mức không nhận ra dồn nén căng thẳng, đỉnh điểm cắt cứng giải phóng); D nói "cuối cùng chỉ còn lại điều này" (kéo xa trong lúc xung quanh tắt dần từng lớp, một điểm cô lập thu gọn cả phim). Bốn kiểu mỗi kiểu chiếm một ô cảm xúc, là từ vựng trực tiếp cho câu hỏi "đoạn này khán giả nên cảm thấy gì" ở giai đoạn phân cảnh.

Thời lượng & năng lượng

  • Thời lượng: Mỗi kiểu 4–5s; toàn phim dùng tổng ≤2 kiểu (mỗi kiểu ≤1 lần)
  • Năng lượng: A cao / B trung bình / C thấp dâng dần / D thấp thu gọn

Công thức cảnh quay (bản gốc tiếng Trung — timing, easing, bảng tham số, lỗi hay gặp)

意图

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)

Mã nguồn tham chiếu (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

Component import fixture chung từ demos/_fixtures và helper từ assets/lib của repo; xem SKILL.md trong repo để biết quy trình render.

Mọi thứ trên trang này đều có bản máy đọc. Đưa agent link JSON, hoặc thêm MCP server gimgs rồi bảo nó tìm motion template.

  1. Tải JSON — trong đó có công thức cảnh quay (timing, easing, bảng tham số, lỗi hay gặp) và toàn bộ source TSX.
  2. Clone repo video-shotcraft; component import fixture chung từ demos/_fixtures và helper từ assets/lib.
  3. Đăng ký component thành <Composition> trong project Remotion rồi render bằng `npx remotion render`, hoặc dựng lại công thức bằng engine của bạn.
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 "Chuyển động máy quay cảm xúc" (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.0Mở template gốc ↗
// ===== 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
—

Cùng danh mục