// ===== demos/interaction/canvas-materialize-moves/DiagramCascadeBuild.tsx ===== // diagram-cascade-build —— miro-promo 104–116s // prompt 行打字 → 图表节点自上而下逐层级联弹出(根→2 子→4 孙), // 连线跟随节点生长(SVG path 描线),成树后整体呼吸一拍。 import React from 'react'; import { AbsoluteFill, useCurrentFrame, interpolate, spring, useVideoConfig, Easing } from 'remotion'; import { G } from '../../_fixtures/Fixtures'; const PROMPT = 'Generate an entity-relationship diagram'; const TYPE_START = 6; const TYPE_CPS = 1.1; // 字符/帧 // 树布局(画布坐标,节点中心) const NODE_W = 300; const NODE_H = 110; const NODES = [ // level 0 { id: 0, x: 960, y: 330, level: 0, parent: -1 }, // level 1 { id: 1, x: 620, y: 570, level: 1, parent: 0 }, { id: 2, x: 1300, y: 570, level: 1, parent: 0 }, // level 2 { id: 3, x: 400, y: 820, level: 2, parent: 1 }, { id: 4, x: 810, y: 820, level: 2, parent: 1 }, { id: 5, x: 1110, y: 820, level: 2, parent: 2 }, { id: 6, x: 1520, y: 820, level: 2, parent: 2 }, ]; const CASCADE_START = 52; // 根节点弹出时刻 const LEVEL_GAP = 20; // 层间隔 const SIBLING_STAGGER = 6; const nodeStart = (n: (typeof NODES)[number]) => { if (n.level === 0) return CASCADE_START; const siblingIdx = NODES.filter((m) => m.level === n.level && m.id < n.id).length; return CASCADE_START + n.level * LEVEL_GAP + siblingIdx * SIBLING_STAGGER; }; // 折角连线 path:父底边中点 → 垂直下探 → 水平 → 垂直进子顶边 const edgePath = (p: (typeof NODES)[number], c: (typeof NODES)[number]) => { const x1 = p.x; const y1 = p.y + NODE_H / 2; const x2 = c.x; const y2 = c.y - NODE_H / 2; const my = (y1 + y2) / 2; return `M ${x1} ${y1} L ${x1} ${my} L ${x2} ${my} L ${x2} ${y2}`; }; const edgeLen = (p: (typeof NODES)[number], c: (typeof NODES)[number]) => { const y1 = p.y + NODE_H / 2; const y2 = c.y - NODE_H / 2; return Math.abs(y2 - y1) + Math.abs(c.x - p.x); }; export const DiagramCascadeBuild: React.FC = () => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const typed = Math.min(PROMPT.length, Math.max(0, Math.floor((frame - TYPE_START) * TYPE_CPS))); const caretOn = Math.floor(frame / 8) % 2 === 0; const promptDone = typed >= PROMPT.length; // 成树后整体呼吸一拍 const lastStart = nodeStart(NODES[NODES.length - 1]); const breathe = interpolate(frame, [lastStart + 22, lastStart + 34, lastStart + 50], [1, 1.035, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.sin), }); return ( {/* prompt 条 */}
{PROMPT.slice(0, typed)} |
{/* 树(呼吸缩放以树心为原点) */}
{/* 连线层 */} {NODES.filter((n) => n.parent >= 0).map((n) => { const p = NODES[n.parent]; const start = nodeStart(n) - 8; // 线比子节点先长 const len = edgeLen(p, n); const grow = interpolate(frame, [start, start + 16], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic), }); if (grow <= 0) return null; return ( ); })} {/* 节点层 */} {NODES.map((n) => { const start = nodeStart(n); const pop = spring({ frame: frame - start, fps, config: { damping: 11, stiffness: 170 } }); if (frame < start) return null; return (
); })}
); }; // ===== demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.tsx ===== // panel-to-canvas-materialize —— miro-promo 84–92s // 侧面板表格行复选框自动逐个打勾 → 按钮按下 → 三行内容飞出面板、 // 物化成画布上三张独立卡片落位(行→卡跨容器形态迁移,尺寸/形状插值)。 import React from 'react'; import { AbsoluteFill, useCurrentFrame, interpolate, spring, useVideoConfig, Easing } from 'remotion'; import { G } from '../../_fixtures/Fixtures'; const PANEL_X = 1210; const PANEL_Y = 90; const PANEL_W = 620; const ROW_H = 92; const ROWS_TOP = 210; // 面板内第一行的画面 y // 行 → 卡的目标位(画布左侧区域) const TARGETS = [ { x: 150, y: 150, rot: -2 }, { x: 480, y: 420, rot: 1.5 }, { x: 180, y: 660, rot: 2 }, ]; const CARD_W = 480; const CARD_H = 240; const CHECK_FRAMES = [12, 22, 32]; // 三个复选框打勾时刻 const BUTTON_FRAME = 46; // 按钮按下 const FLY_START = [54, 60, 66]; // 三行错峰起飞 export const PanelToCanvasMaterialize: React.FC = () => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const btnPress = interpolate(frame, [BUTTON_FRAME, BUTTON_FRAME + 3, BUTTON_FRAME + 9], [0, 1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }); return ( {/* 画布点阵底 */} {/* 侧面板 */}
{/* 面板标题 */}
{/* 表头 */}
{/* 行槽位(行飞走后留白) */} {[0, 1, 2].map((i) => ( ))} {/* 面板底部按钮 */}
0 ? G.ink : G.side, transform: `scale(${1 - btnPress * 0.06})`, display: 'flex', alignItems: 'center', justifyContent: 'center', }} >
Add all to canvas
{/* 飞行中/落位的三张卡(行→卡形态插值) */} {[0, 1, 2].map((i) => ( ))} {/* 光标 */} ); }; // 面板内的一行:复选框自动打勾;起飞后槽位塌陷成虚线留白 const RowSlot: React.FC<{ idx: number; frame: number; fps: number }> = ({ idx, frame, fps }) => { const checkF = CHECK_FRAMES[idx]; const flyF = FLY_START[idx]; const checked = frame >= checkF; const checkPop = spring({ frame: frame - checkF, fps, config: { damping: 10, stiffness: 260 } }); const flown = frame >= flyF; return (
{!flown && ( <> {/* 复选框 */}
{checked && ( )}
)}
); }; // 行→卡:位置沿贝塞尔弧线飞、尺寸/圆角/内容布局同步插值 const FlyingCard: React.FC<{ idx: number; frame: number; fps: number }> = ({ idx, frame, fps }) => { const flyF = FLY_START[idx]; if (frame < flyF) return null; const t = spring({ frame: frame - flyF, fps, config: { damping: 16, stiffness: 60 }, durationInFrames: 34 }); // 起点:面板内该行的屏幕位置/尺寸 const sx = PANEL_X + 30; const sy = ROWS_TOP + idx * ROW_H; const sw = PANEL_W - 60; const sh = ROW_H - 12; const tgt = TARGETS[idx]; // 弧线:中点向上抬,像被"倒"出来 const mx = (sx + tgt.x) / 2; const my = Math.min(sy, tgt.y) - 170; const u = t; const x = (1 - u) * (1 - u) * sx + 2 * (1 - u) * u * mx + u * u * tgt.x; const y = (1 - u) * (1 - u) * sy + 2 * (1 - u) * u * my + u * u * tgt.y; const w = sw + (CARD_W - sw) * u; const h = sh + (CARD_H - sh) * u; const rot = tgt.rot * u; const radius = 10 + 8 * u; const shadow = interpolate(u, [0, 1], [0.08, 0.16]); // 行内容(单行水平) → 卡内容(标题+行+footer) 交叉淡化 const rowOp = Math.max(0, 1 - u * 2.2); const cardOp = Math.max(0, (u - 0.45) / 0.55); return (
{/* 行形态内容 */}
{/* 卡形态内容 */}
); }; const Cursor: React.FC<{ frame: number }> = ({ frame }) => { // 光标:从画面中部移到按钮上并停留按下 const bx = PANEL_X + PANEL_W / 2; const by = PANEL_Y + 900 - 60; const x = interpolate(frame, [8, BUTTON_FRAME - 4], [900, bx], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.quad), }); const y = interpolate(frame, [8, BUTTON_FRAME - 4], [560, by], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.quad), }); const press = interpolate(frame, [BUTTON_FRAME, BUTTON_FRAME + 3, BUTTON_FRAME + 8], [1, 0.78, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }); return ( ); };