Motion · Ánh sáng & nhấn

Khung quét quét đọc tài liệu

Tài liệu khung xương bật vào giữa, ngoặc chữ L rơi xuống bốn góc, một vệt sáng quét qua lại 5 lượt trong khi tài liệu đứng yên.

1920×108016:930 fps5.5s · 165 frameremotionGiấy phép: Apache-2.0#shotcraft#remotion#effects

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

Khi nào dùng

Cảnh quá trình "đang phân tích/kiểm tra nội dung này"; trình diễn năng lực cho sản phẩm tài liệu; đoạn giữa của chuỗi tải lên → phân tích

Ý đồ

Để người xem hiểu "thứ này đang được máy đọc qua từng dòng". Ngoặc khung ngắm khoanh vùng mục tiêu trước (công bố đối tượng kiểm tra), rồi vệt sáng quét qua lại toàn bộ tài liệu (công bố quá trình kiểm tra). Mấu chốt là bản thân tài liệu phải đứng yên tuyệt đối — hễ tài liệu cũng động, người xem sẽ không phân biệt được đó là "đang bị kiểm tra" hay "đang tải".

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

  • Thời lượng: Khoảng 5.0s (150f@30fps)
  • Năng lượng: Trung bình thấp (cơ khí, tiết chế, nhịp điệu nằm hoàn toàn ở hơi thở của cú quét qua lại)

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)

意图

让观众看懂"这份东西正在被机器逐行读过"。取景括号先把目标框住(宣布检查 对象),扫描光带再往复走完全篇(宣布检查过程)。命门是文档本身完全静止: 一旦文档也在动,观众就分不清是"被检查"还是"在加载"。

动效核心

  • 文档 seg(0→0.11, outCubic) 从 scale 0.86 弹到 1,opacity 走 dp*3

提前满亮,落位比缩放早——先"在了"再"稳了"

  • 四角 34px 的 L 形括号按 i*0.022 stagger 依次落位,各 0.055 走完,

沿内外对角带 8px 位移收敛((1-p)*8*dx/dy)

  • 扫描窗口 t 0.17→0.95 严格 linear 切成 5 趟,趟内 inOutSine

两端慢中间快,末尾留 12% 完全停顿(local=(raw-pi)/0.88)

  • 拖尾 82px、rgba(20,20,22,.5)→0,方向随 dir 翻转:下行时挂在光带

上方(top:-82px)、上行时挂在下方(top:2.5px),渐变同步反向

  • 整条光带裁在与文档同 10px 圆角的 overflow:hidden 层里,

0.16→0.20 淡入、0.93→0.99 淡出

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 文档弹入 | scale 0.86→1,t 0→0.11 outCubic | 起始值低于 0.8 就成了"从远处飞来",弹入要小幅度才像 UI | | 取景括号 | 臂长 34px、描边 2px、外扩 −7px;stagger 0.022 / 各 0.055 | 四角同时出现读作静态边框;差 0.022 就够读出"依次落下" | | 扫描趟数 | PASSES=5,窗口 t 0.17→0.95 linear | 3 趟不够读作"往复",7 趟以上在 5s 内每趟太快 | | 趟内缓动 | inOutSine + 末尾 12% 停顿 | 停顿是"看清了这一趟"的换气位;去掉就成了匀速来回摆 | | 光带 | 2.5px 实线 + 82px 拖尾 rgba(20,20,22,.5)→0 | 线太粗(>4px)读作遮罩边缘而不是光 | | 拖尾方向 | 下行 top:−82px / 上行 top:2.5px,渐变一并翻转 | 只翻其一 → 拖尾跑到运动前方,读作"光在往回吸" | | 裁切层 | 与文档同 10px 圆角 overflow:hidden | 漏到文档外就是"扫画面"不是"扫文档" |

已知坑

  • 光带必须裁在文档 bbox 内,且圆角与文档一致——漏出一点就丢掉"扫描

这份文档"的语义

  • 拖尾的 top 和 gradient 方向是两处独立写法,改扫描方向时必须同改,

漏一处就出现"尾迹在前"的反物理观感

  • 括号的 34px 臂长与 −7px 外扩是按 300×178 的文档尺寸配的;换真实截图

要按新尺寸重算,臂长超过短边 1/4 就压住内容

  • INK 是唯一的颜色变量(括号 + 光带 + 拖尾共用),换肤只改它

参考实现

demos/effects/scan-bracket-sweep/ (ScanBracketSweep.tsx)

Mã nguồn tham chiếu (Remotion)

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

Prompt

Use the video-shotcraft shot recipe "Khung quét quét đọc tài liệu" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/scan-bracket-sweep.json
Raw TSX: https://gimgs.net/motion/scan-bracket-sweep/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/effects/scan-bracket-sweep/ScanBracketSweep.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 5.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

7,453 chars · Apache-2.0Mở template gốc ↗
// ===== demos/effects/scan-bracket-sweep/ScanBracketSweep.tsx =====
// scan-bracket-sweep — Scan Bracket 取景括号扫描光带(motion-lab 定稿转原生 Remotion)
// 骨架文档弹到画面中央,四角落下黑色 L 形取景括号(向内位移 8px),随后一条 2.5px
// 黑实线带着 80px 深灰→透明拖尾在文档上往复扫 5 趟,两端慢中间快,尾迹方向始终朝
// 运动来向,文档本身完全静止。设计坐标 480×270(DesignStage 等比放大)。
import React from 'react';
import { DesignStage, E, lerp, rand, seg, useT } from '../../_fixtures/Motion';

export const SCAN_BRACKET_SWEEP_DURATION = 150; // 5000ms @30fps

// —— b09 批共用量(源自 motion-lab/fx/b09.js,按规则内联,不 export)——
const BG = '#F1F1F3'; // 页面浅灰
const INK = '#0B0B0C'; // 纯黑
const LINE = '#E6E6EA'; // 描边
const SKEL = '#DEDEE3'; // 骨架灰

const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const inOutSin = (x: number) => 0.5 - Math.cos(Math.PI * clamp01(x)) / 2;

// 文档几何:440×240 定尺画布内居中的 300×178 骨架文档
const DW = 300;
const DH = 178;
const DX = (440 - DW) / 2;
const DY = (240 - DH) / 2;

// 骨架列参数(与 effect.js mkDoc 完全一致:4 列 ×7 行,rand 种子相同可逐条复现)
const COLS = 4;
const COL_W = (DW - 28 - (COLS - 1) * 10) / COLS;
const SKEL_COLS = Array.from({ length: COLS }, (_, c) => ({
  x: 14 + c * (COL_W + 10),
  rows: Array.from({ length: 7 }, (_, r) => ({
    top: 58 + r * 13,
    w: Number((COL_W * (0.55 + rand(c * 13 + r * 7) * 0.45)).toFixed(1)),
  })),
}));

// 四角取景括号:L 形两边 2px 黑边,落位时从外侧 8px 位移收进来
const CS = 34;
const CB = `2px solid ${INK}`;
const CORNERS: { pos: React.CSSProperties; dx: number; dy: number }[] = [
  { pos: { left: -7, top: -7, borderLeft: CB, borderTop: CB }, dx: 1, dy: 1 },
  { pos: { right: -7, top: -7, borderRight: CB, borderTop: CB }, dx: -1, dy: 1 },
  { pos: { right: -7, bottom: -7, borderRight: CB, borderBottom: CB }, dx: -1, dy: -1 },
  { pos: { left: -7, bottom: -7, borderLeft: CB, borderBottom: CB }, dx: 1, dy: -1 },
];

const PASSES = 5;

export const ScanBracketSweep: React.FC = () => {
  const t = useT();

  // 文档弹入:scale 0.86→1 + 快速淡入
  const dp = seg(t, 0, 0.11, E.outCubic);

  // 扫描往复:5 趟,每趟末尾留 12% 停顿,两端慢中间快(inOutSine)
  const sp = seg(t, 0.17, 0.95, E.linear);
  const raw = sp * PASSES;
  const pi = Math.min(PASSES - 1, Math.floor(raw));
  const local = clamp01((raw - pi) / 0.88);
  const dir = pi % 2 === 0 ? 1 : -1;
  const prog = inOutSin(local);
  const y = dir > 0 ? prog * DH : DH - prog * DH;
  // 光带整体淡入(0.16–0.2)淡出(0.93–0.99)
  const clipOp = Number((seg(t, 0.16, 0.2) * (1 - seg(t, 0.93, 0.99))).toFixed(3));

  return (
    <DesignStage bg={BG}>
      {/* 440×240 定尺画布(mkSheet),居中于 480×270 设计坐标 */}
      <div
        style={{
          position: 'absolute',
          left: '50%',
          top: '50%',
          width: 440,
          height: 240,
          margin: '-120px 0 0 -220px',
        }}
      >
        {/* holder:文档 + 光带 + 括号共用的定位容器 */}
        <div style={{ position: 'absolute', left: DX, top: DY, width: DW, height: DH }}>
          {/* 骨架文档卡片(原采集页为 content-box:1px 边框长在 300×178 之外) */}
          <div
            style={{
              position: 'absolute',
              left: 0,
              top: 0,
              width: DW,
              height: DH,
              boxSizing: 'content-box',
              background: '#fff',
              border: `1px solid ${LINE}`,
              borderRadius: 10,
              boxShadow: '0 8px 26px rgba(0,0,0,.06)',
              overflow: 'hidden',
              transformOrigin: '50% 50%',
              transform: `scale(${lerp(dp, 0.86, 1)})`,
              opacity: clamp01(dp * 3),
            }}
          >
            <div style={{ position: 'absolute', left: 0, top: 0, right: 0 }}>
              {/* 标题条 + 副标题条 */}
              <div
                style={{
                  position: 'absolute',
                  left: 14,
                  top: 12,
                  width: Math.round(DW * 0.34),
                  height: 7,
                  borderRadius: 3,
                  background: INK,
                  opacity: 0.85,
                }}
              />
              <div
                style={{
                  position: 'absolute',
                  left: 14,
                  top: 25,
                  width: Math.round(DW * 0.2),
                  height: 5,
                  borderRadius: 3,
                  background: SKEL,
                }}
              />
              {/* 4 列骨架:列头 + 7 行随机宽占位条 */}
              {SKEL_COLS.map((col, c) => (
                <React.Fragment key={c}>
                  <div
                    style={{
                      position: 'absolute',
                      left: col.x,
                      top: 44,
                      width: Number((COL_W * 0.72).toFixed(1)),
                      height: 6,
                      borderRadius: 3,
                      background: '#9A9AA2',
                    }}
                  />
                  {col.rows.map((row, r) => (
                    <div
                      key={r}
                      style={{
                        position: 'absolute',
                        left: col.x,
                        top: row.top,
                        width: row.w,
                        height: 5,
                        borderRadius: 2.5,
                        background: SKEL,
                      }}
                    />
                  ))}
                </React.Fragment>
              ))}
            </div>
          </div>

          {/* 扫描光带(裁在文档圆角内):2.5px 黑实线 + 82px 朝来向的渐变拖尾 */}
          <div
            style={{
              position: 'absolute',
              left: 0,
              top: 0,
              width: DW,
              height: DH,
              borderRadius: 10,
              overflow: 'hidden',
              pointerEvents: 'none',
              opacity: clipOp,
            }}
          >
            <div
              style={{
                position: 'absolute',
                left: 0,
                right: 0,
                top: 0,
                height: 0,
                transform: `translateY(${y.toFixed(2)}px)`,
              }}
            >
              <div
                style={{
                  position: 'absolute',
                  left: 0,
                  right: 0,
                  height: 82,
                  top: dir > 0 ? -82 : 2.5,
                  background:
                    dir > 0
                      ? 'linear-gradient(180deg,rgba(20,20,22,0),rgba(20,20,22,.5))'
                      : 'linear-gradient(180deg,rgba(20,20,22,.5),rgba(20,20,22,0))',
                }}
              />
              <div
                style={{
                  position: 'absolute',
                  left: 0,
                  right: 0,
                  top: 0,
                  height: 2.5,
                  background: INK,
                }}
              />
            </div>
          </div>

          {/* 四角取景括号:交错落位,向内收 8px(原页 content-box,2px 边长在 34×34 之外) */}
          {CORNERS.map((c, i) => {
            const p = seg(t, 0.08 + i * 0.022, 0.08 + i * 0.022 + 0.055, E.outCubic);
            return (
              <div
                key={i}
                style={{
                  position: 'absolute',
                  width: CS,
                  height: CS,
                  boxSizing: 'content-box',
                  opacity: p,
                  transform: `translate(${(1 - p) * 8 * c.dx}px,${(1 - p) * 8 * c.dy}px)`,
                  ...c.pos,
                }}
              />
            );
          })}
        </div>
      </div>
    </DesignStage>
  );
};


  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/effects/scan-bracket-sweep/ScanBracketSweep.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