Motion · Dữ liệu
Node kính biến hình theo chu trình
Một chủ thể duy nhất thu nhỏ vào sơ đồ cơ chế trong lúc xóa chéo, ba nhãn chu trình lần lượt dựng lên theo cung, khi đẩy cận liên tục ba node kính từ dưới trồi lên thay thế nhãn gốc, kết thúc bằng các dấu chẩn đoán so le ghim trạng thái hệ thống.
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
Vòng lặp cảm nhận-suy luận-hành động của AI agent/tự động hóa/hệ thống điều khiển; cảnh giải thích "một đối tượng ẩn sau ba cơ chế"; B-roll kỹ thuật chuyển từ cảnh thực sang sơ đồ hệ thống trừu tượng.
Ý đồ
Sơ đồ chu trình rất dễ thoái hóa thành "vẽ ba vòng tròn thêm hai mũi tên". Thẻ này đặt lời giải thích cơ chế trên một chủ thể liên tục: chủ thể không bị thay bằng một bản sao xám khác, mà thu nhỏ vào tâm hệ thống đồng bộ với lúc xóa chéo; chữ chu trình trước tiên xuất hiện dưới dạng nhãn khái niệm màu đen, đẩy cận xong các node kính trồi lên từ bên dưới, biến chính nhãn đó thành tiêu đề node màu trắng. Người xem vì thế đọc được đối tượng trong cảnh được giải thích thành một cơ chế vận hành, chứ không phải cắt sang một trang PPT khác.
Thời lượng & năng lượng
- Thời lượng: Khoảng 8.6s (257f@30fps; trạng thái cuối giữ 34f).
- Năng lượng: Trung bình cao (đầu đoạn chuyển cảnh, giữa đoạn dựng chu trình, cuối đoạn đẩy cận và ba node liên tục trồi lê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)
意图
循环图最容易沦为“画三个圆加两支箭头”。本卡把机制解释建立在一个连续主体上: 主体不是被另一份灰阶副本替换,而是在对角擦除发生时同步缩入系统中心;循环文字先作为 黑色概念标签出现,推近后玻璃节点从它们下方升起,把同一标签转为白色节点标题。 观众因此读到的是场景里的对象被解释成一套运行机制,而不是切到另一页 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)
Mã nguồn tham chiếu (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
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.
- 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.
- Clone repo video-shotcraft; component import fixture chung từ demos/_fixtures và helper từ assets/lib.
- Đă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/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 "Node kính biến hình theo chu trình" (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 |
|---|---|---|---|
| — | |||
Cùng danh mục

Lia dọc trục thời gian
Lia dọc trục thời gian — máy quay tăng tốc dọc theo trục thước ngang lướt qua các mốc phiên bản, mỗi mốc thẻ bật đứng dừng ngắn, mốc cuối phanh gấp đẩy máy vào.

Cuộn phanh gấp danh sách dài
Hai kiểu phanh cuộn dài: changelog-scroll-brake giảm tốc dừng chính xác; brake-reticle-lock thêm khóa tâm ngắm trên cùng khung hình.

Vòng đồng tâm hé lộ chú thích
Chủ thể toàn màn hình bị khung tròn thu lại thành sơ đồ vòng đồng tâm, rồi cả nhóm dịch trái thu nhỏ để lộ chú thích.

Va chạm ăn mừng bằng hạt
Hai kiểu hạt ăn mừng: confetti-crossfire bắn pháo hai bên, và counter-tick-sparks bắn tia lửa mỗi khi bộ đếm vượt mốc nghìn.