GSAP动画制作技能 gsap-animation

这个技能用于集成GSAP(GreenSock Animation Platform)和Remotion框架,实现专业的运动图形视频制作。支持复杂的时间线编排、文本动画、SVG变形、高级缓动效果和可重用预设,适用于前端开发和视频制作场景。关键词:GSAP, Remotion, 动画, 视频制作, 前端开发, 运动图形, 时间线编排, SVG动画, 文本分割, 缓动效果。

前端开发 0 次安装 0 次浏览 更新于 3/7/2026

名称: gsap-animation 描述: GSAP 与 Remotion 集成,用于专业运动图形视频制作。时间线编排、文本分割、SVG变形、高级缓动和可重用效果预设。 元数据: 标签: gsap, remotion, motion-graphics, animation, video, text-animation, svg, timeline, easing

何时使用

当创建需要 GSAP 高级动画功能 的 Remotion 视频合成时使用此技能,超出 Remotion 内置的 interpolate()spring()

使用 GSAP 当您需要:

  • 复杂时间线编排(嵌套、标签、位置参数如 "-=0.5"
  • 文本分割动画(SplitText:字符/单词/行带遮罩显示)
  • SVG 形状变形(MorphSVG)、笔划绘制(DrawSVG)、路径跟随(MotionPath)
  • 高级缓动(来自 SVG 路径的 CustomEase、RoughEase、SlowMo、CustomBounce、CustomWiggle)
  • 带有网格、中心/边缘分布的 stagger 效果
  • 字符混乱/解码效果(ScrambleText)
  • 通过 gsap.registerEffect() 可重用的命名效果

使用 Remotion 原生 interpolate() 当:

  • 简单单属性动画(淡入淡出、滑动、缩放)——不要对这些使用 GSAP
  • 数字计数器/进度条——纯数学,不需要时间线
  • 标准缓动曲线
  • 弹簧物理(spring()

GSAP 许可: 所有插件自 2024 年 Webflow 收购以来 100% 免费(SplitText、MorphSVG、DrawSVG 等)。


设置

# 在 Remotion 项目中
npm install gsap
// src/gsap-setup.ts —— 在入口点导入一次
import gsap from 'gsap';
import { SplitText } from 'gsap/SplitText';
import { MorphSVGPlugin } from 'gsap/MorphSVGPlugin';
import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin';
import { MotionPathPlugin } from 'gsap/MotionPathPlugin';
import { ScrambleTextPlugin } from 'gsap/ScrambleTextPlugin';
import { CustomEase } from 'gsap/CustomEase';
import { CustomBounce } from 'gsap/CustomBounce';
import { CustomWiggle } from 'gsap/CustomWiggle';

gsap.registerPlugin(
  SplitText, MorphSVGPlugin, DrawSVGPlugin, MotionPathPlugin,
  ScrambleTextPlugin, CustomEase, CustomBounce, CustomWiggle,
);

export { gsap };

核心钩子:useGSAPTimeline

GSAP 与 Remotion 之间的桥梁。创建一个暂停的时间线,每帧将其定位到 frame / fps

import { useCurrentFrame, useVideoConfig } from 'remotion';
import gsap from 'gsap';
import { useRef, useEffect } from 'react';

function useGSAPTimeline(
  buildTimeline: (tl: gsap.core.Timeline, container: HTMLDivElement) => void
) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const containerRef = useRef<HTMLDivElement>(null);
  const tlRef = useRef<gsap.core.Timeline | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;
    const ctx = gsap.context(() => {
      const tl = gsap.timeline({ paused: true });
      buildTimeline(tl, containerRef.current!);
      tlRef.current = tl;
    }, containerRef);
    return () => { ctx.revert(); tlRef.current = null; };
  }, []);

  useEffect(() => {
    if (tlRef.current) tlRef.current.seek(frame / fps);
  }, [frame, fps]);

  return containerRef;
}

对于 SplitText(需要字体加载):

import { delayRender, continueRender } from 'remotion';

function useGSAPWithFonts(
  buildTimeline: (tl: gsap.core.Timeline, container: HTMLDivElement) => void
) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const containerRef = useRef<HTMLDivElement>(null);
  const tlRef = useRef<gsap.core.Timeline | null>(null);
  const [handle] = useState(() => delayRender());

  useEffect(() => {
    document.fonts.ready.then(() => {
      if (!containerRef.current) return;
      const ctx = gsap.context(() => {
        const tl = gsap.timeline({ paused: true });
        buildTimeline(tl, containerRef.current!);
        tlRef.current = tl;
      }, containerRef);
      continueRender(handle);
      return () => { ctx.revert(); };
    });
  }, []);

  useEffect(() => {
    if (tlRef.current) tlRef.current.seek(frame / fps);
  }, [frame, fps]);

  return containerRef;
}

1. 文本动画

SplitText 显示(字符/单词/行)

const TextReveal: React.FC<{ text: string }> = ({ text }) => {
  const containerRef = useGSAPWithFonts((tl, container) => {
    const split = SplitText.create(container.querySelector('.heading')!, {
      type: 'chars,words,lines', mask: 'lines',
    });
    tl.from(split.chars, {
      y: 100, opacity: 0, duration: 0.6, stagger: 0.03, ease: 'power2.out',
    });
  });

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef}>
        <h1 className="heading" style={{ fontSize: 80, fontWeight: 'bold' }}>{text}</h1>
      </div>
    </AbsoluteFill>
  );
};

模式:

模式 SplitText 配置 动画
行显示 type: "lines", mask: "lines" from lines: { y: "100%" }
字符级联 type: "chars" from chars: { y: 50, opacity: 0, rotationX: -90 }
单词缩放 type: "words" from words: { scale: 0, opacity: 0 }
字符 + 颜色 type: "chars" .from(chars, { y: 50 }).to(chars, { color: "#f00" })

ScrambleText(解码效果)

确定性警告: ScrambleText 使用内部随机字符选择。渲染时使用 --concurrency=1 以确保跨渲染的帧完美再现性。

const containerRef = useGSAPTimeline((tl, container) => {
  tl.to(container.querySelector('.text')!, {
    duration: 2,
    scrambleText: { text: 'DECODED', chars: '01', revealDelay: 0.5, speed: 0.3 },
  });
});

字符集: "upperCase""lowerCase""upperAndLowerCase""01" 或自定义字符串。

文本高亮框

彩色矩形在特定单词后缩放进入。使用 SplitText 进行单词级定位,然后较低 z-index 的绝对定位 <div> 框。

const TextHighlightBox: React.FC<{
  text: string;
  highlights: Array<{ wordIndex: number; color: string }>;
  highlightDelay?: number;
  highlightStagger?: number;
}> = ({ text, highlights, highlightDelay = 0.5, highlightStagger = 0.3 }) => {
  const containerRef = useGSAPWithFonts((tl, container) => {
    const textEl = container.querySelector('.highlight-text')!;
    const split = SplitText.create(textEl, { type: 'words' });

    // 进入:单词淡入
    tl.from(split.words, {
      y: 20, opacity: 0, duration: 0.5, stagger: 0.05, ease: 'power2.out',
    });

    // 高亮框在目标单词后缩放进入
    highlights.forEach(({ wordIndex, color }, i) => {
      const word = split.words[wordIndex] as HTMLElement;
      if (!word) return;

      const box = document.createElement('div');
      Object.assign(box.style, {
        position: 'absolute',
        left: `${word.offsetLeft - 4}px`,
        top: `${word.offsetTop - 2}px`,
        width: `${word.offsetWidth + 8}px`,
        height: `${word.offsetHeight + 4}px`,
        background: color,
        borderRadius: '4px',
        zIndex: '-1',
        transformOrigin: 'left center',
        transform: 'scaleX(0)',
      });
      textEl.appendChild(box);

      tl.to(box, {
        scaleX: 1, duration: 0.3, ease: 'power2.out',
      }, highlightDelay + i * highlightStagger);
    });
  });

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef}>
        <p className="highlight-text" style={{
          fontSize: 64, fontWeight: 'bold', color: '#fff',
          position: 'relative', maxWidth: '70%', lineHeight: 1.2,
        }}>{text}</p>
      </div>
    </AbsoluteFill>
  );
};

属性: highlights 是一个 { wordIndex, color } 数组,目标特定单词(从 SplitText 的 0 索引开始)。


2. SVG 动画

MorphSVG(形状变形)

const containerRef = useGSAPTimeline((tl, container) => {
  tl.to(container.querySelector('#path')!, {
    morphSVG: { shape: '#target-path', type: 'rotational', map: 'size' },
    duration: 1.5, ease: 'power2.inOut',
  });
});
选项
type "linear"(默认)、"rotational"
map "size""position""complexity"
shapeIndex 用于点对齐偏移的整数

DrawSVG(笔划动画)

const containerRef = useGSAPTimeline((tl, container) => {
  const paths = container.querySelectorAll('.logo-path');
  tl.from(paths, { drawSVG: 0, duration: 1.5, stagger: 0.2, ease: 'power2.inOut' })
    .to(paths, { fill: '#ffffff', duration: 0.5 }, '-=0.3');
});
模式 DrawSVG 值
从无绘制 0
从中心绘制 "50% 50%"
显示段 "20% 80%"
擦除 "100% 100%"

MotionPath(路径跟随)

const containerRef = useGSAPTimeline((tl, container) => {
  tl.to(container.querySelector('.element')!, {
    motionPath: {
      path: container.querySelector('#svg-path') as SVGPathElement,
      align: container.querySelector('#svg-path') as SVGPathElement,
      alignOrigin: [0.5, 0.5],
      autoRotate: true,
    },
    duration: 3, ease: 'power1.inOut',
  });
});

3. 3D 变换模式

性能注意: 每场景限制到 3-4 个同时的 3D 容器。每个 preserve-3d 容器触发 GPU 合成层。

CardFlip3D

const CardFlip3D: React.FC<{
  frontContent: React.ReactNode;
  backContent: React.ReactNode;
  flipDelay?: number;
  flipDuration?: number;
}> = ({ frontContent, backContent, flipDelay = 0.5, flipDuration = 1.2 }) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const card = container.querySelector('.card-3d')!;
    tl.to(card, {
      rotateY: 180, duration: flipDuration, ease: 'power2.inOut',
    }, flipDelay);
  });

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef} style={{ perspective: 800 }}>
        <div className="card-3d" style={{
          width: 500, height: 320, position: 'relative', transformStyle: 'preserve-3d',
        }}>
          {/* 前表面 */}
          <div style={{
            position: 'absolute', inset: 0, backfaceVisibility: 'hidden',
            background: '#1e293b', borderRadius: 16, display: 'flex',
            alignItems: 'center', justifyContent: 'center', padding: 32,
          }}>{frontContent}</div>
          {/* 后表面(预旋转 180 度) */}
          <div style={{
            position: 'absolute', inset: 0, backfaceVisibility: 'hidden',
            background: '#3b82f6', borderRadius: 16, transform: 'rotateY(180deg)',
            display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32,
          }}>{backContent}</div>
        </div>
      </div>
    </AbsoluteFill>
  );
};

PerspectiveEntrance

两个元素从相反侧进入,带 rotateY,汇聚到中心。

const PerspectiveEntrance: React.FC<{
  leftContent: React.ReactNode;
  rightContent: React.ReactNode;
}> = ({ leftContent, rightContent }) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const left = container.querySelector('.pe-left')!;
    const right = container.querySelector('.pe-right')!;

    tl.from(left, { x: -600, rotateY: 60, opacity: 0, duration: 0.8, ease: 'power3.out' })
      .from(right, { x: 600, rotateY: -60, opacity: 0, duration: 0.8, ease: 'power3.out' }, '-=0.5')
      .to(left, { rotateY: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3')
      .to(right, { rotateY: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3');
  });

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', perspective: 800 }}>
      <div ref={containerRef} style={{ display: 'flex', gap: 40, alignItems: 'center' }}>
        <div className="pe-left">{leftContent}</div>
        <div className="pe-right">{rightContent}</div>
      </div>
    </AbsoluteFill>
  );
};

RotateXTextSwap

离开文本向后倾斜,进入文本向前落下。使用 transformOrigin 从正确边缘旋转。

const RotateXTextSwap: React.FC<{
  textOut: string;
  textIn: string;
  swapDelay?: number;
}> = ({ textOut, textIn, swapDelay = 1.0 }) => {
  const containerRef = useGSAPWithFonts((tl, container) => {
    const outEl = container.querySelector('.swap-out')!;
    const inEl = container.querySelector('.swap-in')!;

    // 出:向后倾斜
    tl.to(outEl, {
      rotateX: 90, opacity: 0, duration: 0.5,
      transformOrigin: 'center bottom', ease: 'power2.in',
    }, swapDelay);

    // 入:向前落下
    tl.fromTo(inEl,
      { rotateX: -90, opacity: 0, transformOrigin: 'center top' },
      { rotateX: 0, opacity: 1, duration: 0.6, ease: 'power2.out' },
      `>${-0.15}`
    );
  });

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', perspective: 600 }}>
      <div ref={containerRef} style={{ position: 'relative', textAlign: 'center' }}>
        <h1 className="swap-out" style={{ fontSize: 80, fontWeight: 'bold', color: '#fff' }}>{textOut}</h1>
        <h1 className="swap-in" style={{
          fontSize: 80, fontWeight: 'bold', color: '#3b82f6',
          position: 'absolute', inset: 0,
        }}>{textIn}</h1>
      </div>
    </AbsoluteFill>
  );
};

4. 交互模拟

CursorClick

模拟光标导航到目标并点击。光标滑入,目标凹陷,波纹扩展。

const CursorClick: React.FC<{
  targetSelector: string;
  cursorDelay?: number;
  clickDelay?: number;
  children: React.ReactNode;
}> = ({ targetSelector, cursorDelay = 0.3, clickDelay = 0.8, children }) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const target = container.querySelector(targetSelector)!;
    const cursor = container.querySelector('.sim-cursor')!;
    const ripple = container.querySelector('.sim-ripple')!;

    const rect = target.getBoundingClientRect();
    const containerRect = container.getBoundingClientRect();
    const targetX = rect.left - containerRect.left + rect.width / 2;
    const targetY = rect.top - containerRect.top + rect.height / 2;

    // 光标移动到目标
    tl.fromTo(cursor,
      { x: containerRect.width + 40, y: targetY - 20 },
      { x: targetX, y: targetY, duration: clickDelay, ease: 'power2.inOut' },
      cursorDelay
    );

    // 点击:目标凹陷并释放
    tl.to(target, { scale: 0.95, duration: 0.1, ease: 'power2.in' })
      .to(target, { scale: 1, duration: 0.15, ease: 'power2.out' });

    // 波纹扩展(与点击释放重叠)
    tl.fromTo(ripple,
      { x: targetX, y: targetY, scale: 0, opacity: 1 },
      { scale: 3, opacity: 0, duration: 0.6, ease: 'power2.out' },
      '<-0.1'
    );
  });

  return (
    <AbsoluteFill>
      <div ref={containerRef} style={{ position: 'relative', width: '100%', height: '100%' }}>
        {children}
        {/* 光标 */}
        <svg className="sim-cursor" width="24" height="24" viewBox="0 0 24 24"
          style={{ position: 'absolute', top: 0, left: 0, zIndex: 100, pointerEvents: 'none' }}>
          <path d="M5 3l14 8-6 2-4 6z" fill="#fff" stroke="#000" strokeWidth="1.5" />
        </svg>
        {/* 波纹 */}
        <div className="sim-ripple" style={{
          position: 'absolute', top: 0, left: 0, width: 40, height: 40,
          borderRadius: '50%', border: '2px solid rgba(59,130,246,0.6)',
          transform: 'translate(-50%, -50%) scale(0)', pointerEvents: 'none',
        }} />
      </div>
    </AbsoluteFill>
  );
};

属性: targetSelector 是 CSS 选择器,用于容器内要“点击”的元素。光标从屏幕右侧外进入。


5. 过渡

剪辑路径过渡

性能注意: 复杂的剪辑路径动画(尤其是 polygon)可能减慢 Remotion 无头 Chrome 中的帧生成速度。如果渲染时间高,考虑替换为基于不透明度/变换的替代方案或简化到 circle/inset 形状。

const containerRef = useGSAPTimeline((tl, container) => {
  const scene = container.querySelector('.scene')!;

  // 圆形显示
  tl.fromTo(scene,
    { clipPath: 'circle(0% at 50% 50%)' },
    { clipPath: 'circle(75% at 50% 50%)', duration: 1, ease: 'power2.out' }
  );
});
过渡
圆形显示 circle(0% at 50% 50%) circle(75% at 50% 50%)
向左擦除 polygon(0 0, 0 0, 0 100%, 0 100%) polygon(0 0, 100% 0, 100% 100%, 0 100%)
虹膜 polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%) polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)
百叶窗 inset(0 0 100% 0) inset(0 0 0% 0)

滑动 / 交叉淡入淡出

// 滑动过渡
tl.to(outgoing, { x: '-100%', duration: 0.6, ease: 'power2.inOut' })
  .fromTo(incoming, { x: '100%' }, { x: '0%', duration: 0.6, ease: 'power2.inOut' }, 0);

// 交叉淡入淡出
tl.to(outgoing, { opacity: 0, duration: 1 })
  .fromTo(incoming, { opacity: 0 }, { opacity: 1, duration: 1 }, 0);

6. 模板

下三分之一

const LowerThird: React.FC<{ name: string; title: string; hold?: number }> = ({
  name, title, hold = 4,
}) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const bar = container.querySelector('.lt-bar')!;
    const nameEl = container.querySelector('.lt-name')!;
    const titleEl = container.querySelector('.lt-title')!;

    // 进入
    tl.fromTo(bar, { scaleX: 0, transformOrigin: 'left' }, { scaleX: 1, duration: 0.4, ease: 'power2.out' })
      .from(nameEl, { x: -30, opacity: 0, duration: 0.3 }, '-=0.1')
      .from(titleEl, { x: -20, opacity: 0, duration: 0.3 }, '-=0.1')
    // 保持
      .to({}, { duration: hold })
    // 退出
      .to([bar, nameEl, titleEl], { x: -50, opacity: 0, duration: 0.3, stagger: 0.05, ease: 'power2.in' });
  });

  return (
    <AbsoluteFill>
      <div ref={containerRef} style={{ position: 'absolute', bottom: 80, left: 60 }}>
        <div className="lt-bar" style={{ background: '#3b82f6', padding: '12px 24px', borderRadius: 4 }}>
          <div className="lt-name" style={{ fontSize: 28, fontWeight: 'bold', color: '#fff' }}>{name}</div>
          <div className="lt-title" style={{ fontSize: 18, color: 'rgba(255,255,255,0.8)' }}>{title}</div>
        </div>
      </div>
    </AbsoluteFill>
  );
};

标题卡

const TitleCard: React.FC<{ mainTitle: string; subtitle?: string }> = ({ mainTitle, subtitle }) => {
  const containerRef = useGSAPWithFonts((tl, container) => {
    const bgShape = container.querySelector('.bg-shape')!;
    const titleEl = container.querySelector('.main-title')!;
    const divider = container.querySelector('.divider')!;

    tl.from(bgShape, { scale: 0, rotation: -45, duration: 0.8, ease: 'back.out(1.7)' });

    const split = SplitText.create(titleEl, { type: 'chars', mask: 'chars' });
    tl.from(split.chars, { y: '100%', duration: 0.5, stagger: 0.03, ease: 'power3.out' }, '-=0.3')
      .from('.subtitle', { y: 20, opacity: 0, duration: 0.6 }, '-=0.2')
      .from(divider, { scaleX: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3');
  });

  return (
    <AbsoluteFill style={{ background: '#0f172a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef} style={{ textAlign: 'center', color: '#fff' }}>
        <div className="bg-shape" style={{ position: 'absolute', width: 200, height: 200, borderRadius: '50%', background: 'rgba(59,130,246,0.2)', top: '30%', left: '45%' }} />
        <h1 className="main-title" style={{ fontSize: 80, fontWeight: 'bold', position: 'relative' }}>{mainTitle}</h1>
        <div className="divider" style={{ width: 80, height: 3, background: '#3b82f6', margin: '20px auto' }} />
        {subtitle && <p className="subtitle" style={{ fontSize: 32, opacity: 0.8 }}>{subtitle}</p>}
      </div>
    </AbsoluteFill>
  );
};

徽标显示(DrawSVG)

const LogoReveal: React.FC<{ svgContent: React.ReactNode; text?: string }> = ({ svgContent, text }) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const paths = container.querySelectorAll('.logo-path');
    tl.from(paths, { drawSVG: 0, duration: 1.5, stagger: 0.1, ease: 'power2.inOut' })
      .to(paths, { fill: '#fff', duration: 0.5 }, '-=0.3');
    if (text) {
      tl.from(container.querySelector('.logo-text')!, { opacity: 0, x: -20, duration: 0.5 }, '-=0.2');
    }
  });

  return (
    <AbsoluteFill style={{ background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef} style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
        <svg viewBox="0 0 100 100" width={120} height={120}>{svgContent}</svg>
        {text && <span className="logo-text" style={{ fontSize: 48, color: '#fff', fontWeight: 'bold' }}>{text}</span>}
      </div>
    </AbsoluteFill>
  );
};

动画计数器

使用 Remotion 的 interpolate() 进行确定性逐帧计算。不需要 GSAP——计数器是纯数学。

import { interpolate, useCurrentFrame, useVideoConfig } from 'remotion';

const AnimatedCounter: React.FC<{
  endValue: number; prefix?: string; suffix?: string; durationInSeconds?: number;
}> = ({ endValue, prefix = '', suffix = '', durationInSeconds = 2 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const value = interpolate(frame, [0, durationInSeconds * fps], [0, endValue], {
    extrapolateRight: 'clamp',
    easing: (t) => 1 - Math.pow(1 - t, 2), // power1.out 等效
  });

  return (
    <div style={{ fontSize: 96, fontWeight: 'bold', fontVariantNumeric: 'tabular-nums' }}>
      {prefix}{Math.round(value).toLocaleString()}{suffix}
    </div>
  );
};

结尾(结束场景)

const Outro: React.FC<{ headline: string; tagline?: string; logoSvg?: React.ReactNode }> = ({
  headline, tagline, logoSvg,
}) => {
  const containerRef = useGSAPWithFonts((tl, container) => {
    const headlineEl = container.querySelector('.outro-headline')!;
    const split = SplitText.create(headlineEl, { type: 'chars', mask: 'chars' });

    tl.from(split.chars, { y: '100%', duration: 0.5, stagger: 0.03, ease: 'power3.out' });
    if (tagline) {
      tl.from('.outro-tagline', { opacity: 0, y: 15, duration: 0.5, ease: 'power2.out' }, '-=0.2');
    }
    if (logoSvg) {
      const paths = container.querySelectorAll('.outro-logo path');
      tl.from(paths, { drawSVG: 0, duration: 1.2, stagger: 0.08, ease: 'power2.inOut' }, '-=0.3')
        .to(paths, { fill: '#fff', duration: 0.4 }, '-=0.2');
    }
  });

  return (
    <AbsoluteFill style={{ background: '#0f172a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef} style={{ textAlign: 'center', color: '#fff' }}>
        <h1 className="outro-headline" style={{ fontSize: 72, fontWeight: 'bold' }}>{headline}</h1>
        {tagline && <p className="outro-tagline" style={{ fontSize: 28, opacity: 0.7, marginTop: 16 }}>{tagline}</p>}
        {logoSvg && <div className="outro-logo" style={{ marginTop: 40 }}>{logoSvg}</div>}
      </div>
    </AbsoluteFill>
  );
};

分屏比较

两个面板并排,交错进入,可选中心徽章,左面板调暗效果。

const SplitScreenComparison: React.FC<{
  leftPanel: React.ReactNode;
  rightPanel: React.ReactNode;
  leftLabel?: string;
  rightLabel?: string;
  centerElement?: React.ReactNode;
  dimLeft?: boolean;
  hold?: number;
}> = ({ leftPanel, rightPanel, leftLabel, rightLabel, centerElement, dimLeft = false, hold = 2 }) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const left = container.querySelector('.ssc-left')!;
    const right = container.querySelector('.ssc-right')!;
    const badge = container.querySelector('.ssc-badge');

    // 交错进入
    tl.from(left, { x: -80, opacity: 0, duration: 0.6, ease: 'power2.out' })
      .from(right, { x: 80, opacity: 0, duration: 0.6, ease: 'power2.out' }, '-=0.4');

    // 中心徽章弹出
    if (badge) {
      tl.from(badge, { scale: 0, duration: 0.4, ease: 'back.out(2)' }, '-=0.2');
    }

    // 保持
    tl.to({}, { duration: hold });

    // 调暗左面板,弹出右面板(比较效果)
    if (dimLeft) {
      tl.to(left, { opacity: 0.5, filter: 'blur(4px)', duration: 0.5, ease: 'power2.inOut' })
        .to(right, { scale: 1.02, duration: 0.5, ease: 'power2.out' }, '<');
    }
  });

  return (
    <AbsoluteFill style={{ display: 'flex' }}>
      <div ref={containerRef} style={{ display: 'flex', width: '100%', height: '100%' }}>
        <div className="ssc-left" style={{
          flex: 1, background: '#1e1e2e', display: 'flex', flexDirection: 'column',
          alignItems: 'center', justifyContent: 'center', padding: 40, position: 'relative',
        }}>
          {leftLabel && <div style={{ fontSize: 24, opacity: 0.6, marginBottom: 16, color: '#fff' }}>{leftLabel}</div>}
          {leftPanel}
        </div>
        {centerElement && (
          <div className="ssc-badge" style={{
            position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
            zIndex: 10, background: '#3b82f6', borderRadius: '50%', width: 60, height: 60,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            fontSize: 20, fontWeight: 'bold', color: '#fff',
          }}>{centerElement}</div>
        )}
        <div className="ssc-right" style={{
          flex: 1, background: 'rgba(255,255,255,0.06)', backdropFilter: 'blur(20px)',
          display: 'flex', flexDirection: 'column', alignItems: 'center',
          justifyContent: 'center', padding: 40,
        }}>
          {rightLabel && <div style={{ fontSize: 24, opacity: 0.6, marginBottom: 16, color: '#fff' }}>{rightLabel}</div>}
          {rightPanel}
        </div>
      </div>
    </AbsoluteFill>
  );
};

属性: 设置 dimLeft: true 用于比较场景,其中右面板应“获胜”。左面板使用深色背景(#1e1e2e),右面板使用毛玻璃效果(backdropFilter: blur(20px))。


7. 注册效果

预注册效果以流畅时间线 API。在入口点导入一次。

// lib/gsap-effects.ts
gsap.registerEffect({
  name: 'textReveal',
  effect: (targets, config) => {
    const split = SplitText.create(targets, { type: 'lines', mask: 'lines' });
    return gsap.from(split.lines, { y: '100%', duration: config.duration, stagger: config.stagger, ease: config.ease });
  },
  defaults: { duration: 0.6, stagger: 0.15, ease: 'power3.out' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'charCascade',
  effect: (targets, config) => {
    const split = SplitText.create(targets, { type: 'chars' });
    return gsap.from(split.chars, { y: 50, opacity: 0, rotationX: -90, duration: config.duration, stagger: config.stagger, ease: config.ease });
  },
  defaults: { duration: 0.5, stagger: 0.02, ease: 'back.out(1.7)' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'circleReveal',
  effect: (targets, config) => gsap.fromTo(targets,
    { clipPath: 'circle(0% at 50% 50%)' },
    { clipPath: 'circle(75% at 50% 50%)', duration: config.duration, ease: config.ease }),
  defaults: { duration: 1, ease: 'power2.out' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'wipeIn',
  effect: (targets, config) => gsap.fromTo(targets,
    { clipPath: 'inset(0 100% 0 0)' },
    { clipPath: 'inset(0 0% 0 0)', duration: config.duration, ease: config.ease }),
  defaults: { duration: 0.8, ease: 'power2.inOut' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'drawIn',
  effect: (targets, config) => gsap.from(targets, { drawSVG: 0, duration: config.duration, stagger: config.stagger, ease: config.ease }),
  defaults: { duration: 1.5, stagger: 0.1, ease: 'power2.inOut' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'flipCard',
  effect: (targets, config) => gsap.to(targets,
    { rotateY: 180, duration: config.duration, ease: config.ease }),
  defaults: { duration: 1.2, ease: 'power2.inOut' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'perspectiveIn',
  effect: (targets, config) => {
    const fromX = config.fromRight ? 600 : -600;
    const fromRotateY = config.fromRight ? -60 : 60;
    return gsap.from(targets, {
      x: fromX, rotateY: fromRotateY, opacity: 0,
      duration: config.duration, ease: config.ease,
    });
  },
  defaults: { duration: 0.8, ease: 'power3.out', fromRight: false },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'textHighlight',
  effect: (targets, config) => gsap.from(targets, {
    scaleX: 0, transformOrigin: 'left center',
    duration: config.duration, stagger: config.stagger, ease: config.ease,
  }),
  defaults: { duration: 0.3, stagger: 0.3, ease: 'power2.out' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'cursorClick',
  effect: (targets, config) => {
    const tl = gsap.timeline();
    tl.to(targets, { scale: 0.95, duration: 0.1, ease: 'power2.in' })
      .to(targets, { scale: 1, duration: 0.15, ease: 'power2.out' });
    return tl;
  },
  defaults: {},
  extendTimeline: true,
});

// 简单属性动画(淡入淡出、滑动、缩放)应直接使用 Remotion 的
// interpolate() —— GSAP 注册效果保留给
// Remotion 无法原生执行的操作(SplitText、DrawSVG 等)。

使用:

const containerRef = useGSAPTimeline((tl, container) => {
  tl.textReveal('.title')
    .charCascade('.subtitle', {}, '-=0.3')
    .circleReveal('.scene-2', {}, '+=0.5')
    .flipCard('.card-3d')
    .perspectiveIn('.panel-left')
    .perspectiveIn('.panel-right', { fromRight: true }, '-=0.5')
    .textHighlight('.highlight-box')
    .cursorClick('.cta-button', {}, '+=0.3')
    .drawIn('.logo-path');
});

8. 缓动参考

运动感觉 GSAP 缓动 使用场景
平滑减速 power2.out 标准进入
强减速 power3.out / expo.out 戏剧性进入
温和加速 power2.in 标准退出
平滑双向 power1.inOut 场景过渡
轻微超调 back.out(1.7) 注意力、弹入
弹性弹簧 elastic.out(1, 0.5) 徽标、有趣
弹跳 CustomBounce 冲击、着陆
摇动/振动 CustomWiggle 注意力、错误
有机/锯齿 RoughEase 紧张、故障
自定义曲线 CustomEase.create("id", "M0,0 C...") 品牌特定
中间慢速 slow(0.7, 0.7, false) 电影速度坡道

GSAP 专用缓动(无 Remotion 等效): CustomEase、RoughEase、SlowMo、CustomBounce、CustomWiggle、ExpoScaleEase。


9. 与 react-animation 技能结合

const CombinedScene: React.FC = () => (
  <AbsoluteFill>
    {/* react-animation: 视觉氛围 */}
    <Aurora colorStops={['#3A29FF', '#FF94B4']} />

    {/* gsap-animation: 文本 + 运动 */}
    <GSAPTextReveal text="Beautiful Motion" />
    <GSAPLogoReveal svgContent={...} />

    {/* react-animation: 胶片颗粒叠加 */}
    <NoiseOverlay opacity={0.05} />
  </AbsoluteFill>
);
技能 最佳用途
react-animation 视觉背景(Aurora、Silk、Particles)、着色器效果、WebGL
gsap-animation 文本动画、SVG 运动、时间线编排、过渡、模板

10. 合成注册

export const RemotionRoot: React.FC = () => (
  <>
    <Composition id="TitleCard" component={TitleCard}
      durationInFrames={120} fps={30} width={1920} height={1080}
      defaultProps={{ mainTitle: 'HELLO WORLD', subtitle: 'A GSAP Motion Story' }} />
    <Composition id="LowerThird" component={LowerThird}
      durationInFrames={210} fps={30} width={1920} height={1080}
      defaultProps={{ name: 'Jane Smith', title: 'Creative Director' }} />
    <Composition id="LogoReveal" component={LogoReveal}
      durationInFrames={90} fps={30} width={1920} height={1080}
      defaultProps={{ svgContent: <circle className="logo-path" cx={50} cy={50} r={40} fill="none" stroke="#fff" strokeWidth={2} />, text: 'BRAND' }} />
    <Composition id="Outro" component={Outro}
      durationInFrames={120} fps={30} width={1920} height={1080}
      defaultProps={{ headline: 'THANK YOU', tagline: 'See you next time' }} />
    {/* 社交媒体变体 */}
    <Composition id="IGStory-TitleCard" component={TitleCard}
      durationInFrames={150} fps={30} width={1080} height={1920}
      defaultProps={{ mainTitle: 'SWIPE UP' }} />
  </>
);

11. 渲染

# 默认 MP4
npx remotion render src/index.ts TitleCard --output out/title.mp4

# 高质量
npx remotion render src/index.ts TitleCard --codec h264 --crf 15

# GIF
npx remotion render src/index.ts TitleCard --codec gif --every-nth-frame 2

# ProRes 用于编辑
npx remotion render src/index.ts TitleCard --codec prores --prores-profile 4444

# 带音频
# 在合成中使用 <Audio src={staticFile('bgm.mp3')} />

# 透明背景(Alpha 通道)
npx remotion render src/index.ts Overlay --codec prores --prores-profile 4444 --output out/overlay.mov
npx remotion render src/index.ts Overlay --codec vp9 --output out/overlay.webm

透明背景格式

格式 Alpha 支持 质量 文件大小 兼容性
ProRes 4444 (.mov) 无损 Final Cut、Premiere、DaVinci
WebM VP9 (.webm) 有损 Web、Chrome、After Effects
MP4 H.264 (.mp4) 有损 通用播放
GIF (.gif) 仅 1 位 Web、社交

使用 ProRes 4444 进行专业合成。使用 WebM VP9 用于 Web 叠加。MP4/H.264 支持 Alpha 通道。