{
  "slug": "cycle-glass-node-morph",
  "title": "Cycle Glass Node Morph",
  "tagline": "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.",
  "description": "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 thre",
  "category": "data",
  "tags": [
    "shotcraft",
    "remotion",
    "data",
    "interaction",
    "camera"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 1920,
    "height": 1080,
    "fps": 30,
    "duration": 8.62,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/cycle-glass-node-morph/poster-3db0bae0e312.jpg",
    "video": "https://gimgs.net/motion/media/cycle-glass-node-morph/video-834abb8a06d0.mp4"
  },
  "variants": [
    {
      "key": "cycle-glass-node-morph",
      "label": "cycle-glass-node-morph",
      "description": "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.",
      "use": "",
      "video": "https://gimgs.net/motion/media/cycle-glass-node-morph/video-834abb8a06d0.mp4",
      "poster": "https://gimgs.net/motion/media/cycle-glass-node-morph/poster-3db0bae0e312.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/cycle-glass-node-morph.html",
  "json_url": "https://gimgs.net/motion/cycle-glass-node-morph.json",
  "template_url": "https://gimgs.net/motion/cycle-glass-node-morph/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/cycle-glass-node-morph.md",
  "updated_at": "2026-09-24T07:39:53.711Z",
  "prompt": "## When to use\n\nThe 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.\n\n## Intention\n\nA 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.\n\n## Duration & energy\n\n- Duration: About 8.6s (257f@30fps; final state held for 34f).\n- Energy: Medium-high (scene transition up front, cycle establishment in the middle, push-in with continuous three-node lift-off at the end).\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n循环图最容易沦为“画三个圆加两支箭头”。本卡把机制解释建立在一个连续主体上：\n主体不是被另一份灰阶副本替换，而是在对角擦除发生时同步缩入系统中心；循环文字先作为\n黑色概念标签出现，推近后玻璃节点从它们下方升起，把同一标签转为白色节点标题。\n观众因此读到的是**场景里的对象被解释成一套运行机制**，而不是切到另一页 PPT。\n\n## 动效核心\n- f0–61：大主体与上下文条完整静置；先让观众看清“被解释的对象”，不要一开场就缩小\n- f62–94：主体用 `inOutCubic` 连续缩小并向系统位置靠拢；f66–90 同时由左上到右下\n  推进对角擦除，揭出浅色机制场；两者共用同一主体 DOM，不做双主体交叉淡化\n- f66–110：背景内容圆从 .3× 长到 1×；f84–90 机制标题短促落位\n- f124/129/134：三段标签按 5f 错峰，用七帧 `亮→灭→灭→亮→暗→亮→暗→锁定`\n  的可控闪现建立；它们继续沿弧线滑到宽机位的最终位置，而不是闪完就停\n- f127–176：上下两条弧线同步描边生长，箭头头部随线端行进；到 f176 才形成闭环\n- f176–198：整组做一次连续推近，关键状态 scale `.61→.659→.935→1`；中间关键帧\n  只约束路径，不允许停顿或二次起步\n- f184–208：三枚 64px 玻璃节点每隔 8f 从下方 86px 依次顶起，带一次 18px 量级的\n  outBack 越位后落定；节点覆盖处把标签由黑转白\n- f208–222：左、上、右三个三角诊断标记独立错峰落位；f222–256 全部静止\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| 场景 hold | f0–61 | 至少留 1.5s 看清主体；缩短后会像片头缓冲失败 |\n| 主体缩入 | f62–94，宽 332→154px | **命门**：始终是同一个主体对象；复制一份灰阶主体叠化会破坏连续性 |\n| 对角擦除 | f66–90，135° 硬边 gradient stop -40%→140% | 擦除线必须越过两个对角并与主体缩入同时发生；提前结束会露出“双世界”接缝 |\n| 标签节奏 | f124/129/134 起，七帧闪现后锁定 | 闪现只是“系统识别”口吻；全程随机 glitch 会牺牲可读性 |\n| 弧线建立 | f127–176 | 约 1.6s 的持续生长让观众能顺着关系读；压到 20f 只剩装饰扫线 |\n| 连续推近 | f176–198，scale .61→1 | 中间关键帧不得做 hold；分三段 easing 后速度归零会产生三次启停 |\n| 节点顶起 | f184/192/200 起，各 8f，rise 86px | 每枚只允许一次越位回落；三枚同时弹出会丢掉阅读顺序 |\n| 诊断标记 | f208/213/217 起，5–6f | 标记是收尾证据，不与节点共享 transform，避免“整组一起弹” |\n| 最终 hold | f222–256 | 34f 是三段机制通读时间；若标签更长，优先延长总时长 |\n\n## 已知坑\n- 对角擦除边界必须从画外进入并越过另一侧画外；只插值 0%→100% 会在起止帧残留旧场景角落\n- 主体、背景圆、弧线和标签的 z-index 必须明确：主体在圆上、弧线在圆上但不盖主体、节点最后压过标签\n- 玻璃节点需要真实 `backdrop-filter`、内高光与外阴影三层质感；单一半透明灰填充会像普通圆形按钮\n- 标签变白必须只发生在玻璃圆覆盖区域。demo 用节点内同文白字模拟；落地到长标签时应使用圆形 mask 裁切同一文字层，避免双文本错位\n- 推近作用于机制组，不作用于开场背景和对角擦除层；把整画面一起缩放会让转场边界漂移\n- demo 为原创通用 DOM/SVG 重构，不含参考研究中的车辆、品牌、道路或 UI 资产\n\n## 参考实现\ndemos/data/cycle-glass-node-morph/\n（CycleGlassNodeMorph.tsx）\n\n## Reference implementation (Remotion)\n\n- demos/data/cycle-glass-node-morph/CycleGlassNodeMorph.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/data/cycle-glass-node-morph/CycleGlassNodeMorph.tsx\n\nThe component imports shared fixtures from `demos/_fixtures` and helpers from `assets/lib` in the repo; SKILL.md there documents the render workflow.\n",
  "flow": [
    {
      "step": "Read the recipe",
      "note": "Prompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points."
    },
    {
      "step": "Get the code",
      "tool": "git",
      "cmd": "git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install"
    },
    {
      "step": "Register + render",
      "tool": "remotion",
      "cmd": "npx remotion render <CompositionId> out.mp4   # component: demos/data/cycle-glass-node-morph/CycleGlassNodeMorph.tsx"
    },
    {
      "step": "Or re-implement",
      "note": "Port the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing."
    }
  ],
  "files": [
    {
      "path": "demos/data/cycle-glass-node-morph/CycleGlassNodeMorph.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/data/cycle-glass-node-morph/CycleGlassNodeMorph.tsx"
    }
  ],
  "contract": "React/Remotion composition (TSX) from the open-source video-shotcraft library (Apache-2.0). It is not a browser-playable template: clone the repo, register the component in a Remotion project and render with `npx remotion render`. The recipe (prompt) describes timing, easing and parameters so you can also re-implement the shot in any engine.",
  "render": {
    "engine": "remotion",
    "frames": 259,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/data/cycle-glass-node-morph/CycleGlassNodeMorph.tsx"
    ],
    "note": "Clone https://github.com/Vincentwei1021/video-shotcraft (npm install). The component lives under demos/{category}/{name}/ and imports shared fixtures from demos/_fixtures and helpers from assets/lib. Copy it (with those imports) into a Remotion project, register a <Composition> with the template's width/height/fps/duration in Root.tsx, then `npx remotion render <CompositionId> out.mp4`. SKILL.md in the repo documents the full Claude Code / Codex workflow. The prompt below is the shot recipe (timing, easing, parameter table, pitfalls) — enough to re-implement the shot in HTML/WAAPI or another engine if you prefer."
  },
  "source": "// ===== demos/data/cycle-glass-node-morph/CycleGlassNodeMorph.tsx =====\n// cycle-glass-node-morph — 单主体缩入机制图，循环标签随后被上升的玻璃节点接管。\n// 运动顺序：上下文主体 → 对角擦除 → 循环箭头与标签 → 连续推近 → 节点托起 → 诊断标记。\nimport React from 'react';\nimport { Easing, interpolate, useCurrentFrame } from 'remotion';\nimport { DesignStage, E, lerp } from '../../_fixtures/Motion';\n\nexport const CYCLE_GLASS_NODE_MORPH_DURATION = 257;\n\nconst CLAMP = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const;\n\nconst frameSeg = (frame: number, start: number, end: number, ease: (t: number) => number = E.linear) =>\n  ease(Math.min(1, Math.max(0, (frame - start) / Math.max(1, end - start))));\n\n// Monotone cubic Hermite interpolation keeps the recorded camera key states while\n// sharing one non-zero velocity at each interior keyframe. This avoids the visible\n// stop/restart that three separately eased interpolate() calls would introduce.\nconst smoothCamera = (frame: number, values: readonly number[]) => {\n  const frames = [176, 180, 190, 198] as const;\n  if (frame <= frames[0]) return values[0];\n  if (frame >= frames[frames.length - 1]) return values[values.length - 1];\n\n  const widths = frames.slice(1).map((value, index) => value - frames[index]);\n  const secants = widths.map((width, index) => (values[index + 1] - values[index]) / width);\n  const tangents = [secants[0]];\n  for (let index = 1; index < values.length - 1; index++) {\n    const before = secants[index - 1];\n    const after = secants[index];\n    if (before * after <= 0) {\n      tangents.push(0);\n      continue;\n    }\n    const beforeWidth = widths[index - 1];\n    const afterWidth = widths[index];\n    const w1 = 2 * afterWidth + beforeWidth;\n    const w2 = afterWidth + 2 * beforeWidth;\n    tangents.push((w1 + w2) / (w1 / before + w2 / after));\n  }\n  tangents.push(secants[secants.length - 1]);\n\n  const segment = frame <= frames[1] ? 0 : frame <= frames[2] ? 1 : 2;\n  const width = widths[segment];\n  const t = (frame - frames[segment]) / width;\n  const t2 = t * t;\n  const t3 = t2 * t;\n  const h00 = 2 * t3 - 3 * t2 + 1;\n  const h10 = t3 - 2 * t2 + t;\n  const h01 = -2 * t3 + 3 * t2;\n  const h11 = t3 - t2;\n  return (\n    h00 * values[segment] +\n    h10 * width * tangents[segment] +\n    h01 * values[segment + 1] +\n    h11 * width * tangents[segment + 1]\n  );\n};\n\nconst quadraticPoint = (\n  from: readonly [number, number],\n  control: readonly [number, number],\n  to: readonly [number, number],\n  t: number,\n) => {\n  const oneMinus = 1 - t;\n  const x = oneMinus * oneMinus * from[0] + 2 * oneMinus * t * control[0] + t * t * to[0];\n  const y = oneMinus * oneMinus * from[1] + 2 * oneMinus * t * control[1] + t * t * to[1];\n  const dx = 2 * (oneMinus * (control[0] - from[0]) + t * (to[0] - control[0]));\n  const dy = 2 * (oneMinus * (control[1] - from[1]) + t * (to[1] - control[1]));\n  return { x, y, angle: (Math.atan2(dy, dx) * 180) / Math.PI };\n};\n\nconst cycleLabelOpacity = (frame: number, start: number) => {\n  const local = frame - start;\n  if (local < 0) return 0;\n  if (local === 0 || local === 3) return 1;\n  if (local === 1 || local === 2) return 0;\n  if (local === 4 || local === 6) return 0.42;\n  return 1;\n};\n\nconst GlassNode: React.FC<{\n  frame: number;\n  start: number;\n  end: number;\n  left: number;\n  top: number;\n  label: string;\n}> = ({ frame, start, end, left, top, label }) => {\n  const k = frameSeg(frame, start, end, E.outBack);\n  const opacity = frameSeg(frame, start, start + 2, E.outQuad);\n  return (\n    <div\n      style={{\n        position: 'absolute',\n        left,\n        top,\n        width: 64,\n        height: 64,\n        borderRadius: '50%',\n        background: 'rgba(64,64,68,.88)',\n        border: '1px solid rgba(255,255,255,.48)',\n        boxShadow: '0 10px 24px rgba(18,18,20,.18), inset 0 1px 6px rgba(255,255,255,.28)',\n        backdropFilter: 'blur(10px)',\n        opacity,\n        transform: `translate(-50%, ${lerp(k, 86, -32)}px) scale(${lerp(k, 0.82, 1)})`,\n        display: 'grid',\n        placeItems: 'center',\n        color: '#fff',\n        fontFamily: 'Arial, sans-serif',\n        fontSize: 8.5,\n        fontWeight: 850,\n        letterSpacing: 0.2,\n      }}\n    >\n      {label}\n    </div>\n  );\n};\n\nconst Marker: React.FC<{ frame: number; start: number; end: number; left: number; top: number; rotate: number }> = ({\n  frame,\n  start,\n  end,\n  left,\n  top,\n  rotate,\n}) => {\n  const k = frameSeg(frame, start, end, E.outBack);\n  return (\n    <div\n      style={{\n        position: 'absolute',\n        left,\n        top,\n        width: 0,\n        height: 0,\n        borderLeft: '4px solid transparent',\n        borderRight: '4px solid transparent',\n        borderTop: '7px solid #5a5a5e',\n        opacity: Math.min(1, k * 2),\n        transform: `translateY(${lerp(k, -12, 0)}px) rotate(${lerp(k, rotate - 22, rotate)}deg) scale(${lerp(\n          k,\n          0.72,\n          1,\n        )})`,\n      }}\n    />\n  );\n};\n\nexport const CycleGlassNodeMorph: React.FC = () => {\n  const frame = useCurrentFrame();\n  const wipe = frameSeg(frame, 66, 90, E.inOutCubic);\n  const vehicle = frameSeg(frame, 62, 94, E.inOutCubic);\n  const disk = frameSeg(frame, 66, 110, E.outCubic);\n  const title = frameSeg(frame, 84, 90, E.outCubic);\n  const arrows = frameSeg(frame, 127, 176, E.inOutCubic);\n  const camScale = smoothCamera(frame, [0.61, 0.659, 0.935, 1]);\n  const camX = smoothCamera(frame, [6, 5.5, 1, 0]);\n  const camY = smoothCamera(frame, [-51, -44, -9, 0]);\n  const topArrow = quadraticPoint([68, 98], [185, 4], [302, 98], arrows);\n  const bottomArrow = quadraticPoint([302, 100], [185, 190], [68, 100], arrows);\n  const wipeStop = interpolate(wipe, [0, 1], [-40, 140]);\n\n  const labelData = [\n    { label: 'SENSE', left: 147, top: 92, start: 124 },\n    { label: 'MODEL', left: 240, top: 63, start: 129 },\n    { label: 'ACT', left: 333, top: 92, start: 134 },\n  ];\n\n  return (\n    <DesignStage bg=\"#f5f5f2\" raster=\"zoom\">\n      <div\n        style={{\n          position: 'absolute',\n          inset: 0,\n          overflow: 'hidden',\n          isolation: 'isolate',\n          background: `linear-gradient(135deg, #f5f5f2 0%, #f5f5f2 ${wipeStop}%, #c8c8c5 ${\n            wipeStop + 0.2\n          }%, #c8c8c5 100%)`,\n          fontFamily: 'Arial, sans-serif',\n        }}\n      >\n        <div\n          style={{\n            position: 'absolute',\n            left: 36,\n            right: 36,\n            top: 13,\n            height: 47,\n            background: '#a4a4a1',\n            display: 'grid',\n            placeItems: 'center',\n            color: '#f8f8f6',\n            fontSize: 15,\n            fontWeight: 850,\n            letterSpacing: 1,\n            opacity: 1 - wipe,\n          }}\n        >\n          CONTEXT\n        </div>\n\n        <div\n          style={{\n            position: 'absolute',\n            left: 240,\n            top: 143,\n            width: 218,\n            height: 218,\n            borderRadius: '50%',\n            background: '#cececb',\n            opacity: disk,\n            transform: `translate(-50%,-50%) scale(${lerp(disk, 0.3, 1)})`,\n            transformOrigin: '50% 50%',\n            zIndex: 2,\n          }}\n        />\n\n        <div\n          style={{\n            position: 'absolute',\n            left: 240,\n            top: 112,\n            width: 118,\n            height: 14,\n            background: '#999996',\n            opacity: title * (1 - frameSeg(frame, 176, 192, E.outQuad)),\n            transform: `translate(-50%, ${lerp(title, 8, 0)}px)`,\n            display: 'grid',\n            placeItems: 'center',\n            color: '#f7f7f5',\n            fontSize: 8.5,\n            fontWeight: 850,\n            letterSpacing: 0.8,\n            zIndex: 4,\n          }}\n        >\n          SYSTEM LOOP\n        </div>\n\n        <svg\n          viewBox=\"0 0 332 134\"\n          style={{\n            position: 'absolute',\n            left: 240,\n            top: 154,\n            width: interpolate(vehicle, [0, 1], [332, 154]),\n            height: interpolate(vehicle, [0, 1], [134, 62]),\n            transform: `translate(-50%, -50%) translateY(${interpolate(vehicle, [0, 1], [25, 15])}px)`,\n            zIndex: 7,\n            overflow: 'visible',\n          }}\n        >\n          <polygon points=\"56,27 96,0 236,0 276,27 332,47 332,134 0,134 0,47\" fill=\"#5a5a5d\" />\n          <text\n            x=\"166\"\n            y=\"82\"\n            textAnchor=\"middle\"\n            dominantBaseline=\"middle\"\n            fill=\"#fff\"\n            fontFamily=\"Arial, sans-serif\"\n            fontSize=\"18\"\n            fontWeight=\"850\"\n          >\n            SUBJECT\n          </text>\n        </svg>\n\n        <div\n          style={{\n            position: 'absolute',\n            left: 240,\n            top: 151,\n            width: 370,\n            height: 226,\n            transform: `translate(-50%,-50%) translate(${camX}px,${camY}px) scale(${camScale})`,\n            transformOrigin: '50% 50%',\n            zIndex: 6,\n          }}\n        >\n          <svg viewBox=\"0 0 370 226\" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>\n            <path\n              d=\"M68 98 Q185 4 302 98\"\n              fill=\"none\"\n              stroke=\"#4d4d50\"\n              strokeWidth=\"1.3\"\n              strokeDasharray=\"300\"\n              strokeDashoffset={300 * (1 - arrows)}\n            />\n            <path\n              d=\"M302 100 Q185 190 68 100\"\n              fill=\"none\"\n              stroke=\"#4d4d50\"\n              strokeWidth=\"1.3\"\n              strokeDasharray=\"300\"\n              strokeDashoffset={300 * (1 - arrows)}\n            />\n            <path\n              d=\"M-9 -5 L0 0 L-9 5\"\n              fill=\"none\"\n              stroke=\"#4d4d50\"\n              strokeWidth=\"1.3\"\n              opacity={frameSeg(arrows, 0.02, 0.08, E.outQuad)}\n              transform={`translate(${topArrow.x} ${topArrow.y}) rotate(${topArrow.angle})`}\n            />\n            <path\n              d=\"M-9 -5 L0 0 L-9 5\"\n              fill=\"none\"\n              stroke=\"#4d4d50\"\n              strokeWidth=\"1.3\"\n              opacity={frameSeg(arrows, 0.02, 0.08, E.outQuad)}\n              transform={`translate(${bottomArrow.x} ${bottomArrow.y}) rotate(${bottomArrow.angle})`}\n            />\n          </svg>\n\n          {labelData.map((item, i) => {\n            const opacity = cycleLabelOpacity(frame, item.start);\n            const drift = frameSeg(frame, item.start, 160, E.outCubic);\n            const nodeTarget = frameSeg(frame, 176, 198, E.inOutCubic);\n            const nodeTakeover = frameSeg(frame, 184 + i * 8, 192 + i * 8, E.outCubic);\n            return (\n              <div\n                key={item.label}\n                style={{\n                  position: 'absolute',\n                  left: item.left - 55 + (i - 1) * lerp(drift, 8, 0),\n                  top: item.top,\n                  width: 110,\n                  textAlign: 'center',\n                  color: '#353538',\n                  fontSize: 10,\n                  fontWeight: 850,\n                  opacity: opacity * (1 - nodeTakeover),\n                  transform: `translateY(${lerp(nodeTarget, 0, -4)}px)`,\n                }}\n              >\n                {item.label}\n              </div>\n            );\n          })}\n\n          <GlassNode frame={frame} start={184} end={192} left={92} top={98} label=\"SENSE\" />\n          <GlassNode frame={frame} start={192} end={200} left={185} top={69} label=\"MODEL\" />\n          <GlassNode frame={frame} start={200} end={208} left={278} top={98} label=\"ACT\" />\n\n          <Marker frame={frame} start={208} end={214} left={48} top={119} rotate={-90} />\n          <Marker frame={frame} start={213} end={218} left={181} top={30} rotate={0} />\n          <Marker frame={frame} start={217} end={222} left={314} top={119} rotate={90} />\n        </div>\n      </div>\n    </DesignStage>\n  );\n};\n\n\n"
}