Communitygithub.com

HTML 動態圖形

建立電影級、確定性的動態圖形,作為零伺服器、獨立 `index.html` 檔案,由 GSAP、定向 SVG 動態模糊、統一的 `#world` 攝影機裝置以及可選的 Three.js WebGL 背景驅動。

HTML 動態圖形 是什麼?

這個技能提供了一個完整的流程,用於在單一的、獨立可移植的 `index.html` 檔案中創作專業級別的動態圖形 (motion graphics)。它專為需要創建促銷影片、產品發布開場、品牌標誌或動態排版等情境的開發者而設計,這些內容可直接透過 `file://` 協定在瀏覽器中開啟,或進行無頭渲染以生成 MP4 影片。該技能的核心在於一個統一的 `#world` 攝影機裝置,可將所有場景元素(包括 WebGL 畫布、背景和 DOM 圖層)結合在一起,透過一致的攝影機運鏡實現複雜的場景轉換。它強調使用 GSAP 時間軸的確定性動畫,確保時間點的精確控制,並可逐影格搜尋以進行偵錯。此外,它還配備了先進的運動設計規則,例如不對稱時間軸、定向 SVG 動態模糊(使用 `feGaussianBlur`)以及攝影機呼吸效果,以避免產生類似簡報幻燈片的結果。支援的關鍵平台包括在瀏覽器中直接預覽,或透過 Playwright/Puppeteer 使用 `window.OPENER.seek(t)` API 進行無頭渲染。

相容平台~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/YanaMiku/html-motion-graphics/tree/HEAD

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

HTML 動態圖形 是做什麼的?

建立電影級、確定性的動態圖形,作為零伺服器、獨立 index.html 檔案,由 GSAP、定向 SVG 動態模糊、統一的 #world 攝影機裝置以及可選的 Three.js WebGL 背景驅動。

使用時機

  • 在 index.html 中創建宣傳影片、產品發布開場、預告片或品牌標誌。
  • 構建動態排版、SaaS UI 產品導覽或多場景視覺解說。
  • 將靜態幻燈片風格的網頁動畫轉變為真正的攝影機驅動的動態設計。
  • 提供一個可移植的 index.html,可直接透過 file:// 開啟,並支援影格精確的搜尋和無頭 MP4 渲染。

核心工作流程

步驟 1:鎖定創意簡報與概念框架

在編寫程式碼之前,先定義一個簡潔的風格簡報,以確保輸出看起來絕不會像普通的幻燈片模板:

  1. 長寬比與解析度:預設為 1920x1080 (16:9) 適用於桌上型電腦/YouTube,或 1080x1920 (9:16) 適用於 Reels/TikTok/Shorts(將內容包裝在一個 .safe 容器中,並在 transform-origin: 50% 45% 處縮放至 0.78,以預留 9:16 社交媒體 HUD 的清除空間)。
  2. 概念架構:根據主題選擇清晰的結構概念,而不是預設採用 鉤子 -> 3 個圖示方塊 -> CTA:
    • 連續世界 / 攝影機旅程:一個統一的空間,攝影機在其中進行推拉、環繞或穿入場景。
    • 動態 UI 導覽:重建 DOM/SVG 產品元件,游標/觸控與之互動,面板展開,數據確定性地動畫化。
    • 問題到清晰:視覺混亂逐漸累積,然後一個產品動作將畫面清理乾淨,最終呈現品牌標誌。
    • 純動態排版:文字從不同軸線進入,改變重量/比例,並組裝成最終的標誌。
    • 舞台卡通 / 連續動作:角色或主角保持活躍,同時背景、樂器和擬聲標籤在其周圍變化。
  3. 色彩與表面系統:定義 CSS 自訂屬性 (--base, --ink, --accent, --accent-lit)。絕不要留下平坦的單色背景。至少結合兩層(例如,徑向地平線光暈 + 線性漸層、柔和網格狀色塊、或 WebGL 星雲 + 飄散灰塵 + SVG 底片顆粒覆蓋層)。
  4. 音訊與時間規劃:鎖定總時長(DURATION 以秒為單位),並將每個節拍映射到以秒為單位的絕對時間戳記。

步驟 2:建構獨立的 index.html 架構

提供一個單一的、自包含的 index.html,其中包含內聯 <style> 和從 https://esm.sh 或 https://cdn.jsdelivr.net 匯入 GSAP(如果需要 3D 深度/粒子,則匯入 Three.js)的 <script type="module">。

  • 固定解析度舞台:將 #stage 鎖定為 1920x1080(或 1080x1920),並透過 CSS --fit 按比例縮放以適應任何視口,確保構圖座標在各種顯示器上保持一致。
  • 統一的 #world 攝影機裝置:將 <canvas>(如果是 WebGL)、背景漸層和 #dom 場景圖層放在 #world 內部。當攝影機縮放或平移時,背景和前景會一起移動。將鏡頭覆蓋層(漏光、暗角、底片顆粒)放在 #world 外部。
  • CSS 優先的隱藏狀態:在 CSS 中將動畫元素(.line, .ch, .hl-t, .obj)的 opacity 設為 0,並在 GSAP fromTo 補間動畫中使用 immediateRender: false 以防止第一影格閃爍。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Motion Graphic</title>
<style>
  :root {
    --base: #070913;
    --ink: #f8fafc;
    --accent: #38bdf8;
    --accent-lit: #bae6fd;
    --fit: 1;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: system-ui, -apple-system, sans-serif; }
  #stage {
    position: fixed; left: 50%; top: 50%; width: 1920px; height: 1080px;
    transform: translate(-50%, -50%) scale(var(--fit));
    overflow: hidden; background: var(--base); color: var(--ink);
  }
  #world { position: absolute; inset: -240px; transform-origin: 50% 50%; will-change: transform; }
  .bg-layer {
    position: absolute; inset: 0;
    background:
      radial-gradient(70% 45% at 50% 105%, color-mix(in srgb, var(--accent) 32%, transparent), transparent 70%),
      linear-gradient(180deg, #04060c 0%, var(--base) 60%, #0d1326 100%);
  }
  .scene { position: absolute; inset: 240px; display: grid; place-items: center; pointer-events: none; }
  .line { font-size: 112px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.1; white-space: nowrap; opacity: 0; text-shadow: 0 6px 32px rgba(0,0,0,0.55); }
  .wd { display: inline-block; white-space: nowrap; }
  .ch { display: inline-block; opacity: 0; will-change: transform, opacity, filter; }
  .vignette { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(circle at 50% 50%, transparent 58%, rgba(0,0,0,0.65) 100%); }
  .grain { position: absolute; inset: -60px; pointer-events: none; filter: url(#grain); opacity: 0.16; mix-blend-mode: overlay; }
  #hud { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); display: none; gap: 10px; align-items: center; background: rgba(15,23,42,0.85); padding: 8px 14px; border-radius: 999px; color: #fff; font: 13px/1 monospace; z-index: 99; }
  #hud.show { display: flex; }
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="grain" x="0" y="0" width="100%" height="100%">
      <feTurbulence type="fractalNoise" baseFrequency="0.75" numOctaves="3" stitchTiles="stitch" />
      <feColorMatrix type="saturate" values="0" />
    </filter>
  </defs>
</svg>
<div id="stage">
  <div id="world">
    <div class="bg-layer"></div>
    <div class="scene" id="s1"><h1 class="line" id="t1">Motion without limits</h1></div>
  </div>
  <div class="vignette"></div>
  <div class="grain" id="grain"></div>
</div>
<div id="hud">
  <button id="btnPlay">Pause</button>
  <input id="scrub" type="range" min="0" max="1" step="0.001" value="0" style="width:260px" />
  <span id="clock">0.00s</span>
</div>
<script type="module">
import gsap from "https://esm.sh/[email protected]";

const W = 1920, H = 1080, DURATION = 6.0;
const stage = document.getElementById("stage");
const world = document.getElementById("world");
const grain = document.getElementById("grain");

function fitStage() {
  const s = Math.min(window.innerWidth / W, window.innerHeight / H);
  stage.style.setProperty("--fit", s);
}
window.addEventListener("resize", fitStage);
fitStage();

function splitChars(el) {
  const text = el.textContent.trim();
  el.innerHTML = text.split(/\s+/).map(word =>
    `<span class="wd">${[...word].map(c => `<span class="ch">${c}</span>`).join("")}</span>`
  ).join(" ");
  return el.querySelectorAll(".ch");
}

function blurTween(tl, target, at, fromV, toV, dur, axis = "x", ease = "power3.out") {
  const id = "mb_" + Math.random().toString(36).slice(2, 8);
  const defs = document.querySelector("svg defs");
  const filter = document.createElementNS("http://www.w3.org/2000/svg", "filter");
  filter.setAttribute("id", id);
  filter.setAttribute("x", "-70%"); filter.setAttribute("y", "-70%");
  filter.setAttribute("width", "240%"); filter.setAttribute("height", "240%");
  const blur = document.createElementNS("http://www.w3.org/2000/svg", "feGaussianBlur");
  blur.setAttribute("stdDeviation", "0 0");
  filter.appendChild(blur);
  defs.appendChild(filter);
  const proxy = { v: fromV };
  tl.set(target, { filter: `url(#${id})` }, at);
  tl.to(proxy, {
    v: toV, duration: dur, ease,
    onUpdate() {
      const a = proxy.v.toFixed(2), b = (proxy.v * 0.08).toFixed(2);
      blur.setAttribute("stdDeviation", axis === "x" ? `${a} ${b}` : `${b} ${a}`);
    },
    onComplete() { target.style.filter = "none"; }
  }, at);
}

const CAM = { s: 1, fx: W / 2, fy: H / 2 };
function applyCam(t) {
  const breathe = CAM.s * (1 + 0.012 * Math.sin(t * 0.9));
  const tx = W / 2 - CAM.fx * breathe;
  const ty = H / 2 - CAM.fy * breathe;
  world.style.transform = `translate(${tx}px, ${ty}px) scale(${breathe})`;
  const k = Math.floor(t * 12);
  grain.style.transform = `translate(${(k * 37 % 60) - 30}px, ${(k * 61 % 60) - 30}px)`;
}

const tl = gsap.timeline({
  paused: true,
  onUpdate() { applyCam(tl.time()); }
});

await document.fonts.ready;
const t1 = document.getElementById("t1");
const chars1 = splitChars(t1);
tl.set(t1, { opacity: 1 }, 0.2);
tl.fromTo(chars1,
  { opacity: 0, yPercent: 65, scaleY: 1.4 },
  { opacity: 1, yPercent: 0, scaleY: 1, duration: 0.85, ease: "power4.out", stagger: 0.022, immediateRender: false },
  0.2
);
blurTween(tl, t1, 0.2, 28, 0, 0.85, "y", "power4.out");
tl.to(CAM, { s: 1.08, duration: 3.0, ease: "sine.inOut" }, 0.2);
tl.to(chars1, { opacity: 0, yPercent: -65, duration: 0.42, ease: "power2.in", stagger: 0.01 }, 4.6);
blurTween(tl, t1, 4.6, 0, 42, 0.48, "y", "power2.in");
tl.to({}, { duration: 0.01 }, DURATION);

const params = new URLSearchParams(location.search);
const isClean = params.get("clean") === "1";
const isDebug = params.get("debug") === "1";
const hud = document.getElementById("hud");
const scrub = document.getElementById("scrub");
const clock = document.getElementById("clock");
const btnPlay = document.getElementById("btnPlay");

if (isDebug) hud.classList.add("show");

function seek(t) {
  const clamped = Math.max(0, Math.min(DURATION, t));
  tl.pause(clamped, false);
  gsap.ticker.tick();
  applyCam(clamped);
  scrub.value = clamped / DURATION;
  clock.textContent = `${clamped.toFixed(2)}s`;
}

tl.eventCallback("onUpdate", () => {
  const t = tl.time();
  applyCam(t);
  scrub.value = t / DURATION;
  clock.textContent = `${t.toFixed(2)}s`;
});
tl.eventCallback("onComplete", () => { if (!isClean) tl.restart(); });

btnPlay.onclick = () => {
  if (tl.paused()) { tl.play(); btnPlay.textContent = "Pause"; }
  else { tl.pause(); btnPlay.textContent = "Play"; }
};
scrub.oninput = () => seek(parseFloat(scrub.value) * DURATION);
window.addEventListener("keydown", e => {
  if (e.code === "Space") { e.preventDefault(); btnPlay.click(); }
  if (e.code === "KeyR") { tl.restart(); }
});

window.OPENER = { tl, DURATION, seek };
applyCam(0);
if (!isClean) tl.play(0);
</script>
</body>
</html>

步驟 3:強制執行反簡報風格的動態設計規則

在每個場景中應用這些工藝規則,使結果呈現為動態設計而非動畫幻燈片:

  1. 每個節拍一個陳述:將標題保持在 2-5 個單字,使用句子大小寫(粗細 500-600)。永遠不要在同一個靜態網格中堆疊眉題 + 2 行標題 + 2 行內文 + 4 個圖示徽章。
  2. 不對稱時間:以果斷的減速進入(0.7-1.1s,power4.out 或 back.out(1.5)),並更快地退出(0.35-0.5s,power2.in)。將退場和進場元素重疊 0.15-0.3s,這樣畫面永遠不會變空。
  3. 定向 SVG 動態模糊:使用 feGaussianBlur 搭配與移動方向對齊的分割 stdDeviation="x y",擴大濾波器區域(x="-70%" y="-70%" width="240%" height="240%"),以確保模糊尾巴不會被裁剪。完成時移除濾波器(filter: none)。
  4. 有動機的攝影機運動:將持續的細微攝影機呼吸效果(+/-1-3% 正弦漂移)與有目的的鏡頭變化結合(廣角 -> 中近景 -> 近攝到活動元素)。切勿平移場景至背景邊緣露出空白黑色條(在 #world 上提供 240px+ 的過掃描區域)。
  5. 對比優先:保持標題文字清晰(#fff 帶有柔和的閱讀陰影,不要在文字上使用厚重的多層霓虹光暈)。將光暈和輝光保留給主角物件、UI 亮點和 3D 強調元素。

步驟 4:保證 100% 確定性

每個視覺狀態必須是 t = tl.time() 的純函數,這樣搜尋(?debug=1)和無頭 MP4 渲染才能產生相同的影格:

  • 渲染循環中禁止使用:絕不使用 Math.random()、Date.now()、performance.now()、setInterval 或累積積分(x += vx * dt)。
  • 確定性替代方案:使用種子相位常數和 Math.sin(t * freq + seed) 或補間距離代理(flow)來驅動粒子、底片顆粒偏移和振盪。
  • 絕對時間軸放置:使用明確的數值時間戳記(tl.to(..., 2.40))來安排補間動畫,而不是連結的相對偏移量(+=0.3),這樣調整一個節拍就不會破壞下游的音訊同步。

步驟 5:視覺驗證與匯出(如有要求)

  1. 關鍵時間戳記檢查:在瀏覽器中開啟 index.html?debug=1(或使用 Playwright/Puppeteer 透過 window.OPENER.seek(t) 擷取螢幕截圖),檢查每個場景高峰和轉場中間點。驗證沒有文字裁剪、前景/背景對比度足夠高以及平滑的重疊。
  2. MP4 匯出流程:當使用者要求影片檔案時,使用 ?clean=1 和 window.OPENER.seek(frame / fps) 以 60fps 渲染確定性影格,然後使用 FFmpeg 編碼:
ffmpeg -framerate 60 -i frames/f%05d.png -c:v libx264 -pix_fmt yuv420p -crf 14 -tune grain output.mp4

注意事項

  • 字體載入前的 FOUC:在網頁字體完成載入之前將文字拆分為 .ch 範圍,會錯誤計算字形的邊界框。在執行 splitChars() 之前,務必先 await document.fonts.ready。
  • SVG 群組上的 GSAP transformOrigin:對巢狀 <g> 元素進行 rotation 或 scale 動畫會改變它們的邊界框並導致關節脫離。透過 JS 代理物件驅動 SVG 變換,該物件在 onUpdate 中直接寫入 translate(x,y) rotate(r) scale(sx,sy)。
  • file:// 上的模組 CORS:由於 null 來源的 CORS 規則,外部 <script type="module" src="..."> 在直接從磁碟開啟時會失敗。將 CSS 和 JS 保留在 index.html 內聯,同時從 HTTPS CDN 匯入函式庫。
  • 隱藏分頁的 requestAnimationFrame 節流:無頭瀏覽器會在背景分頁中節流 rAF。務必在 window.OPENER.seek(t) 內部同步呼叫 gsap.ticker.tick() 和 applyCam(t)。

相關技能