Motion · Data

Cycle Glass Node Morph

A single subject shrinks into a mechanism diagram during a diagonal wipe, three looping-cycle labels establish along an arc, then on a continuous push-in three glass nodes rise from below to take over the original labels, ending with staggered diagnostic markers pinning the system state.

1920×108016:930 fps8.62s · 259 framesremotionLicense: Apache-2.0#shotcraft#remotion#data#interaction#camera

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

When to use

The perceive-reason-act loop of AI agents/automation/control systems; an explainer shot for "one object with three mechanisms behind it"; technical B-roll transitioning from a real scene into an abstract system diagram.

Intention

A cycle diagram easily collapses into "draw three circles and two arrows." This card grounds the mechanism explanation in one continuous subject: instead of being replaced by a separate grayscale copy, the subject shrinks into the system's center in sync with a diagonal wipe; the cycle text first appears as black concept labels, and once the camera pushes in, glass nodes rise up from beneath them, turning the very same label into a white node title. What viewers read is an object in the scene being explained as an operating mechanism, not a cut to another slide.

Duration & energy

  • Duration: About 8.6s (257f@30fps; final state held for 34f).
  • Energy: Medium-high (scene transition up front, cycle establishment in the middle, push-in with continuous three-node lift-off at the end).

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

意图

循环图最容易沦为“画三个圆加两支箭头”。本卡把机制解释建立在一个连续主体上: 主体不是被另一份灰阶副本替换,而是在对角擦除发生时同步缩入系统中心;循环文字先作为 黑色概念标签出现,推近后玻璃节点从它们下方升起,把同一标签转为白色节点标题。 观众因此读到的是场景里的对象被解释成一套运行机制,而不是切到另一页 PPT。

动效核心

  • f0–61:大主体与上下文条完整静置;先让观众看清“被解释的对象”,不要一开场就缩小
  • f62–94:主体用 inOutCubic 连续缩小并向系统位置靠拢;f66–90 同时由左上到右下

推进对角擦除,揭出浅色机制场;两者共用同一主体 DOM,不做双主体交叉淡化

  • f66–110:背景内容圆从 .3× 长到 1×;f84–90 机制标题短促落位
  • f124/129/134:三段标签按 5f 错峰,用七帧 亮→灭→灭→亮→暗→亮→暗→锁定

的可控闪现建立;它们继续沿弧线滑到宽机位的最终位置,而不是闪完就停

  • f127–176:上下两条弧线同步描边生长,箭头头部随线端行进;到 f176 才形成闭环
  • f176–198:整组做一次连续推近,关键状态 scale .61→.659→.935→1;中间关键帧

只约束路径,不允许停顿或二次起步

  • f184–208:三枚 64px 玻璃节点每隔 8f 从下方 86px 依次顶起,带一次 18px 量级的

outBack 越位后落定;节点覆盖处把标签由黑转白

  • f208–222:左、上、右三个三角诊断标记独立错峰落位;f222–256 全部静止

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 场景 hold | f0–61 | 至少留 1.5s 看清主体;缩短后会像片头缓冲失败 | | 主体缩入 | f62–94,宽 332→154px | 命门:始终是同一个主体对象;复制一份灰阶主体叠化会破坏连续性 | | 对角擦除 | f66–90,135° 硬边 gradient stop -40%→140% | 擦除线必须越过两个对角并与主体缩入同时发生;提前结束会露出“双世界”接缝 | | 标签节奏 | f124/129/134 起,七帧闪现后锁定 | 闪现只是“系统识别”口吻;全程随机 glitch 会牺牲可读性 | | 弧线建立 | f127–176 | 约 1.6s 的持续生长让观众能顺着关系读;压到 20f 只剩装饰扫线 | | 连续推近 | f176–198,scale .61→1 | 中间关键帧不得做 hold;分三段 easing 后速度归零会产生三次启停 | | 节点顶起 | f184/192/200 起,各 8f,rise 86px | 每枚只允许一次越位回落;三枚同时弹出会丢掉阅读顺序 | | 诊断标记 | f208/213/217 起,5–6f | 标记是收尾证据,不与节点共享 transform,避免“整组一起弹” | | 最终 hold | f222–256 | 34f 是三段机制通读时间;若标签更长,优先延长总时长 |

已知坑

  • 对角擦除边界必须从画外进入并越过另一侧画外;只插值 0%→100% 会在起止帧残留旧场景角落
  • 主体、背景圆、弧线和标签的 z-index 必须明确:主体在圆上、弧线在圆上但不盖主体、节点最后压过标签
  • 玻璃节点需要真实 backdrop-filter、内高光与外阴影三层质感;单一半透明灰填充会像普通圆形按钮
  • 标签变白必须只发生在玻璃圆覆盖区域。demo 用节点内同文白字模拟;落地到长标签时应使用圆形 mask 裁切同一文字层,避免双文本错位
  • 推近作用于机制组,不作用于开场背景和对角擦除层;把整画面一起缩放会让转场边界漂移
  • demo 为原创通用 DOM/SVG 重构,不含参考研究中的车辆、品牌、道路或 UI 资产

参考实现

demos/data/cycle-glass-node-morph/ (CycleGlassNodeMorph.tsx)

Reference implementation (Remotion)

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

Prompt

Use the video-shotcraft shot recipe "Cycle Glass Node Morph" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/cycle-glass-node-morph.json
Raw TSX: https://gimgs.net/motion/cycle-glass-node-morph/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/data/cycle-glass-node-morph/CycleGlassNodeMorph.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 8.62s

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

11,806 chars · Apache-2.0Open raw template ↗
// ===== demos/data/cycle-glass-node-morph/CycleGlassNodeMorph.tsx =====
// cycle-glass-node-morph — 单主体缩入机制图,循环标签随后被上升的玻璃节点接管。
// 运动顺序:上下文主体 → 对角擦除 → 循环箭头与标签 → 连续推近 → 节点托起 → 诊断标记。
import React from 'react';
import { Easing, interpolate, useCurrentFrame } from 'remotion';
import { DesignStage, E, lerp } from '../../_fixtures/Motion';

export const CYCLE_GLASS_NODE_MORPH_DURATION = 257;

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

const frameSeg = (frame: number, start: number, end: number, ease: (t: number) => number = E.linear) =>
  ease(Math.min(1, Math.max(0, (frame - start) / Math.max(1, end - start))));

// Monotone cubic Hermite interpolation keeps the recorded camera key states while
// sharing one non-zero velocity at each interior keyframe. This avoids the visible
// stop/restart that three separately eased interpolate() calls would introduce.
const smoothCamera = (frame: number, values: readonly number[]) => {
  const frames = [176, 180, 190, 198] as const;
  if (frame <= frames[0]) return values[0];
  if (frame >= frames[frames.length - 1]) return values[values.length - 1];

  const widths = frames.slice(1).map((value, index) => value - frames[index]);
  const secants = widths.map((width, index) => (values[index + 1] - values[index]) / width);
  const tangents = [secants[0]];
  for (let index = 1; index < values.length - 1; index++) {
    const before = secants[index - 1];
    const after = secants[index];
    if (before * after <= 0) {
      tangents.push(0);
      continue;
    }
    const beforeWidth = widths[index - 1];
    const afterWidth = widths[index];
    const w1 = 2 * afterWidth + beforeWidth;
    const w2 = afterWidth + 2 * beforeWidth;
    tangents.push((w1 + w2) / (w1 / before + w2 / after));
  }
  tangents.push(secants[secants.length - 1]);

  const segment = frame <= frames[1] ? 0 : frame <= frames[2] ? 1 : 2;
  const width = widths[segment];
  const t = (frame - frames[segment]) / width;
  const t2 = t * t;
  const t3 = t2 * t;
  const h00 = 2 * t3 - 3 * t2 + 1;
  const h10 = t3 - 2 * t2 + t;
  const h01 = -2 * t3 + 3 * t2;
  const h11 = t3 - t2;
  return (
    h00 * values[segment] +
    h10 * width * tangents[segment] +
    h01 * values[segment + 1] +
    h11 * width * tangents[segment + 1]
  );
};

const quadraticPoint = (
  from: readonly [number, number],
  control: readonly [number, number],
  to: readonly [number, number],
  t: number,
) => {
  const oneMinus = 1 - t;
  const x = oneMinus * oneMinus * from[0] + 2 * oneMinus * t * control[0] + t * t * to[0];
  const y = oneMinus * oneMinus * from[1] + 2 * oneMinus * t * control[1] + t * t * to[1];
  const dx = 2 * (oneMinus * (control[0] - from[0]) + t * (to[0] - control[0]));
  const dy = 2 * (oneMinus * (control[1] - from[1]) + t * (to[1] - control[1]));
  return { x, y, angle: (Math.atan2(dy, dx) * 180) / Math.PI };
};

const cycleLabelOpacity = (frame: number, start: number) => {
  const local = frame - start;
  if (local < 0) return 0;
  if (local === 0 || local === 3) return 1;
  if (local === 1 || local === 2) return 0;
  if (local === 4 || local === 6) return 0.42;
  return 1;
};

const GlassNode: React.FC<{
  frame: number;
  start: number;
  end: number;
  left: number;
  top: number;
  label: string;
}> = ({ frame, start, end, left, top, label }) => {
  const k = frameSeg(frame, start, end, E.outBack);
  const opacity = frameSeg(frame, start, start + 2, E.outQuad);
  return (
    <div
      style={{
        position: 'absolute',
        left,
        top,
        width: 64,
        height: 64,
        borderRadius: '50%',
        background: 'rgba(64,64,68,.88)',
        border: '1px solid rgba(255,255,255,.48)',
        boxShadow: '0 10px 24px rgba(18,18,20,.18), inset 0 1px 6px rgba(255,255,255,.28)',
        backdropFilter: 'blur(10px)',
        opacity,
        transform: `translate(-50%, ${lerp(k, 86, -32)}px) scale(${lerp(k, 0.82, 1)})`,
        display: 'grid',
        placeItems: 'center',
        color: '#fff',
        fontFamily: 'Arial, sans-serif',
        fontSize: 8.5,
        fontWeight: 850,
        letterSpacing: 0.2,
      }}
    >
      {label}
    </div>
  );
};

const Marker: React.FC<{ frame: number; start: number; end: number; left: number; top: number; rotate: number }> = ({
  frame,
  start,
  end,
  left,
  top,
  rotate,
}) => {
  const k = frameSeg(frame, start, end, E.outBack);
  return (
    <div
      style={{
        position: 'absolute',
        left,
        top,
        width: 0,
        height: 0,
        borderLeft: '4px solid transparent',
        borderRight: '4px solid transparent',
        borderTop: '7px solid #5a5a5e',
        opacity: Math.min(1, k * 2),
        transform: `translateY(${lerp(k, -12, 0)}px) rotate(${lerp(k, rotate - 22, rotate)}deg) scale(${lerp(
          k,
          0.72,
          1,
        )})`,
      }}
    />
  );
};

export const CycleGlassNodeMorph: React.FC = () => {
  const frame = useCurrentFrame();
  const wipe = frameSeg(frame, 66, 90, E.inOutCubic);
  const vehicle = frameSeg(frame, 62, 94, E.inOutCubic);
  const disk = frameSeg(frame, 66, 110, E.outCubic);
  const title = frameSeg(frame, 84, 90, E.outCubic);
  const arrows = frameSeg(frame, 127, 176, E.inOutCubic);
  const camScale = smoothCamera(frame, [0.61, 0.659, 0.935, 1]);
  const camX = smoothCamera(frame, [6, 5.5, 1, 0]);
  const camY = smoothCamera(frame, [-51, -44, -9, 0]);
  const topArrow = quadraticPoint([68, 98], [185, 4], [302, 98], arrows);
  const bottomArrow = quadraticPoint([302, 100], [185, 190], [68, 100], arrows);
  const wipeStop = interpolate(wipe, [0, 1], [-40, 140]);

  const labelData = [
    { label: 'SENSE', left: 147, top: 92, start: 124 },
    { label: 'MODEL', left: 240, top: 63, start: 129 },
    { label: 'ACT', left: 333, top: 92, start: 134 },
  ];

  return (
    <DesignStage bg="#f5f5f2" raster="zoom">
      <div
        style={{
          position: 'absolute',
          inset: 0,
          overflow: 'hidden',
          isolation: 'isolate',
          background: `linear-gradient(135deg, #f5f5f2 0%, #f5f5f2 ${wipeStop}%, #c8c8c5 ${
            wipeStop + 0.2
          }%, #c8c8c5 100%)`,
          fontFamily: 'Arial, sans-serif',
        }}
      >
        <div
          style={{
            position: 'absolute',
            left: 36,
            right: 36,
            top: 13,
            height: 47,
            background: '#a4a4a1',
            display: 'grid',
            placeItems: 'center',
            color: '#f8f8f6',
            fontSize: 15,
            fontWeight: 850,
            letterSpacing: 1,
            opacity: 1 - wipe,
          }}
        >
          CONTEXT
        </div>

        <div
          style={{
            position: 'absolute',
            left: 240,
            top: 143,
            width: 218,
            height: 218,
            borderRadius: '50%',
            background: '#cececb',
            opacity: disk,
            transform: `translate(-50%,-50%) scale(${lerp(disk, 0.3, 1)})`,
            transformOrigin: '50% 50%',
            zIndex: 2,
          }}
        />

        <div
          style={{
            position: 'absolute',
            left: 240,
            top: 112,
            width: 118,
            height: 14,
            background: '#999996',
            opacity: title * (1 - frameSeg(frame, 176, 192, E.outQuad)),
            transform: `translate(-50%, ${lerp(title, 8, 0)}px)`,
            display: 'grid',
            placeItems: 'center',
            color: '#f7f7f5',
            fontSize: 8.5,
            fontWeight: 850,
            letterSpacing: 0.8,
            zIndex: 4,
          }}
        >
          SYSTEM LOOP
        </div>

        <svg
          viewBox="0 0 332 134"
          style={{
            position: 'absolute',
            left: 240,
            top: 154,
            width: interpolate(vehicle, [0, 1], [332, 154]),
            height: interpolate(vehicle, [0, 1], [134, 62]),
            transform: `translate(-50%, -50%) translateY(${interpolate(vehicle, [0, 1], [25, 15])}px)`,
            zIndex: 7,
            overflow: 'visible',
          }}
        >
          <polygon points="56,27 96,0 236,0 276,27 332,47 332,134 0,134 0,47" fill="#5a5a5d" />
          <text
            x="166"
            y="82"
            textAnchor="middle"
            dominantBaseline="middle"
            fill="#fff"
            fontFamily="Arial, sans-serif"
            fontSize="18"
            fontWeight="850"
          >
            SUBJECT
          </text>
        </svg>

        <div
          style={{
            position: 'absolute',
            left: 240,
            top: 151,
            width: 370,
            height: 226,
            transform: `translate(-50%,-50%) translate(${camX}px,${camY}px) scale(${camScale})`,
            transformOrigin: '50% 50%',
            zIndex: 6,
          }}
        >
          <svg viewBox="0 0 370 226" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
            <path
              d="M68 98 Q185 4 302 98"
              fill="none"
              stroke="#4d4d50"
              strokeWidth="1.3"
              strokeDasharray="300"
              strokeDashoffset={300 * (1 - arrows)}
            />
            <path
              d="M302 100 Q185 190 68 100"
              fill="none"
              stroke="#4d4d50"
              strokeWidth="1.3"
              strokeDasharray="300"
              strokeDashoffset={300 * (1 - arrows)}
            />
            <path
              d="M-9 -5 L0 0 L-9 5"
              fill="none"
              stroke="#4d4d50"
              strokeWidth="1.3"
              opacity={frameSeg(arrows, 0.02, 0.08, E.outQuad)}
              transform={`translate(${topArrow.x} ${topArrow.y}) rotate(${topArrow.angle})`}
            />
            <path
              d="M-9 -5 L0 0 L-9 5"
              fill="none"
              stroke="#4d4d50"
              strokeWidth="1.3"
              opacity={frameSeg(arrows, 0.02, 0.08, E.outQuad)}
              transform={`translate(${bottomArrow.x} ${bottomArrow.y}) rotate(${bottomArrow.angle})`}
            />
          </svg>

          {labelData.map((item, i) => {
            const opacity = cycleLabelOpacity(frame, item.start);
            const drift = frameSeg(frame, item.start, 160, E.outCubic);
            const nodeTarget = frameSeg(frame, 176, 198, E.inOutCubic);
            const nodeTakeover = frameSeg(frame, 184 + i * 8, 192 + i * 8, E.outCubic);
            return (
              <div
                key={item.label}
                style={{
                  position: 'absolute',
                  left: item.left - 55 + (i - 1) * lerp(drift, 8, 0),
                  top: item.top,
                  width: 110,
                  textAlign: 'center',
                  color: '#353538',
                  fontSize: 10,
                  fontWeight: 850,
                  opacity: opacity * (1 - nodeTakeover),
                  transform: `translateY(${lerp(nodeTarget, 0, -4)}px)`,
                }}
              >
                {item.label}
              </div>
            );
          })}

          <GlassNode frame={frame} start={184} end={192} left={92} top={98} label="SENSE" />
          <GlassNode frame={frame} start={192} end={200} left={185} top={69} label="MODEL" />
          <GlassNode frame={frame} start={200} end={208} left={278} top={98} label="ACT" />

          <Marker frame={frame} start={208} end={214} left={48} top={119} rotate={-90} />
          <Marker frame={frame} start={213} end={218} left={181} top={30} rotate={0} />
          <Marker frame={frame} start={217} end={222} left={314} top={119} rotate={90} />
        </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/data/cycle-glass-node-morph/CycleGlassNodeMorph.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