Remotion LabRemotion Lab
返回模板庫

YouTube 影片推薦卡

模擬 YouTube 側邊欄影片推薦卡的動畫元件,包含縮圖、播放按鈕彈出、時長標籤、頻道資訊與觀看次數動態累加。

社群簡約橫式
提示詞(可直接修改內容)
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import React from "react";

const VIDEO-TITLE = "用 Remotion 製作專業影片動畫\n完整教學從入門到進階";
const CHANNEL-NAME = "Remotion 中文頻道";
const VIEWS-TARGET = 12000;
const DURATION-LABEL = "10:42";

export const YtVideoCard: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // Card scale + fade: frames 0-15
  const cardProgress = spring({
    frame,
    fps,
    config: { damping: 22, stiffness: 140 },
    durationInFrames: 15,
  });
  const cardScale = interpolate(cardProgress, [0, 1], [0.95, 1]);
  const cardOpacity = interpolate(cardProgress, [0, 0.3], [0, 1], {
    extrapolateRight: "clamp",
  });

  // Thumbnail fade: frames 5-20
  const thumbOpacity = interpolate(frame, [5, 20], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // Play button bounce: frames 18-32
  const playProgress = spring({
    frame: frame - 18,
    fps,
    config: { damping: 14, stiffness: 220 },
    durationInFrames: 14,
  });
  const playScale = interpolate(playProgress, [0, 1], [0, 1], {
    extrapolateRight: "clamp",
  });

  // Duration badge fade: frames 25-35
  const durationOpacity = interpolate(frame, [25, 35], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // Title slide up: frames 28-42
  const titleProgress = spring({
    frame: frame - 28,
    fps,
    config: { damping: 22, stiffness: 160 },
    durationInFrames: 14,
  });
  const titleY = interpolate(titleProgress, [0, 1], [20, 0]);
  const titleOpacity = interpolate(titleProgress, [0, 0.4], [0, 1], {
    extrapolateRight: "clamp",
  });

  // Channel + metadata fade: frames 38-55
  const metaOpacity = interpolate(frame, [38, 55], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // View count up: frames 45-80
  const viewRaw = interpolate(frame, [45, 80], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const currentViews = Math.round(viewRaw * VIEWS-TARGET);

  function formatViews(n: number): string {
    if (n >= 10000) return (n / 10000).toFixed(1).replace(/\.0$/, "") + "萬";
    if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "K";
    return String(n);
  }

  const titleLines = VIDEO-TITLE.split("\n");

  return (
    <AbsoluteFill
      style={{
        background: "#111111",
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      {/* Card */}
      <div
        style={{
          transform: `scale(${cardScale})`,
          opacity: cardOpacity,
          background: "#1f1f1f",
          borderRadius: 12,
          width: 900,
          boxSizing: "border-box",
          fontFamily: "sans-serif",
          overflow: "hidden",
          boxShadow: "0 24px 64px rgba(0,0,0,0.7)",
        }}
      >
        {/* Thumbnail */}
        <div
          style={{
            width: 900,
            height: 506,
            background: "linear-gradient(160deg, #1a1a2e 0%, #16213e 35%, #0f3460 65%, #533483 100%)",
            opacity: thumbOpacity,
            position: "relative",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            overflow: "hidden",
          }}
        >
          {/* Subtle noise/pattern */}
          <div
            style={{
              position: "absolute",
              inset: 0,
              backgroundImage:
                "radial-gradient(circle at 30% 50%, rgba(99,179,237,0.12) 0%, transparent 50%), radial-gradient(circle at 70% 30%, rgba(183,121,255,0.12) 0%, transparent 50%)",
            }}
          />

          {/* HD badge top-left */}
          <div
            style={{
              position: "absolute",
              top: 12,
              left: 12,
              background: "rgba(0,0,0,0.7)",
              color: "#ffffff",
              fontSize: 12,
              fontWeight: 700,
              padding: "3px 7px",
              borderRadius: 4,
              letterSpacing: 0.5,
            }}
          >
            HD
          </div>

          {/* Play button */}
          <div
            style={{
              transform: `scale(${playScale})`,
              width: 72,
              height: 72,
              borderRadius: "50%",
              background: "rgba(255,255,255,0.9)",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              position: "relative",
              zIndex: 2,
              boxShadow: "0 4px 24px rgba(0,0,0,0.5)",
            }}
          >
            <div
              style={{
                width: 0,
                height: 0,
                borderTop: "14px solid transparent",
                borderBottom: "14px solid transparent",
                borderLeft: "24px solid #1f1f1f",
                marginLeft: 6,
              }}
            />
          </div>

          {/* Duration badge */}
          <div
            style={{
              position: "absolute",
              bottom: 10,
              right: 12,
              background: "rgba(0,0,0,0.85)",
              color: "#ffffff",
              fontSize: 14,
              fontWeight: 700,
              padding: "4px 8px",
              borderRadius: 4,
              opacity: durationOpacity,
              letterSpacing: 0.5,
            }}
          >
            {DURATION-LABEL}
          </div>
        </div>

        {/* Info row */}
        <div
          style={{
            display: "flex",
            padding: "14px 16px 18px",
            gap: 12,
            alignItems: "flex-start",
          }}
        >
          {/* Channel avatar */}
          <div
            style={{
              width: 40,
              height: 40,
              borderRadius: "50%",
              background: "linear-gradient(135deg, #ff0000, #cc0000)",
              flexShrink: 0,
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              fontSize: 16,
              fontWeight: 700,
              color: "#ffffff",
              opacity: metaOpacity,
              marginTop: 2,
            }}
          >
            R
          </div>

          {/* Text info */}
          <div style={{ flex: 1, minWidth: 0 }}>
            {/* Title */}
            <div
              style={{
                transform: `translateY(${titleY}px)`,
                opacity: titleOpacity,
              }}
            >
              {titleLines.map((line, i) => (
                <div
                  key={i}
                  style={{
                    fontSize: 17,
                    fontWeight: 700,
                    color: "#f1f1f1",
                    lineHeight: 1.4,
                  }}
                >
                  {line}
                </div>
              ))}
            </div>

            {/* Channel name */}
            <div
              style={{
                fontSize: 14,
                color: "#aaaaaa",
                marginTop: 6,
                opacity: metaOpacity,
              }}
            >
              {CHANNEL-NAME}
            </div>

            {/* Metadata */}
            <div
              style={{
                fontSize: 14,
                color: "#aaaaaa",
                marginTop: 2,
                opacity: metaOpacity,
              }}
            >
              {formatViews(currentViews)} 次觀看 · 3 天前
            </div>
          </div>

          {/* Menu dots */}
          <div
            style={{
              fontSize: 22,
              color: "#aaaaaa",
              fontWeight: 700,
              letterSpacing: 1,
              opacity: metaOpacity,
              flexShrink: 0,
              marginTop: 2,
            }}
          >

          </div>
        </div>
      </div>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼