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.
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.
- Fetch the JSON — it holds the shot recipe (timing, easing, parameter table, pitfalls) and the full TSX source.
- Clone the video-shotcraft repo; the component imports shared fixtures from demos/_fixtures and helpers from assets/lib.
- 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.jsongit clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/data/cycle-glass-node-morph/CycleGlassNodeMorph.tsx
npx remotion render <CompositionId> out.mp4claude 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).
// ===== 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>
);
};
- Read the recipePrompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points.
- Get the codetool: git
git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install - Register + rendertool: remotion
npx remotion render <CompositionId> out.mp4 # component: demos/data/cycle-glass-node-morph/CycleGlassNodeMorph.tsx - Or re-implementPort the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing.
| param | type | default | description |
|---|---|---|---|
| — | |||
More in this category

Timeline travel pan
Timeline travel — the camera accelerates along a horizontal ruler axis past version tick marks, a card springs upright and pauses briefly at each tick, and an abrupt stop pushes in on the final tick.

Scroll brake moves
A long scroll decelerates to a precise stop on the latest item; a variant adds a reticle lock on the same frame.

Ring diagram annotation reveal
A full-screen subject is squeezed into a ring diagram by a circular window, then the group shrinks left to reveal annotations.

Particle celebration hits
Confetti cannons fire from both sides, or sparks fizz off the counter each time it ticks past a thousand.