wiggle-that-diagram:三種風格的解說動畫
將一張靜態的「運作原理」圖解,轉變為一段簡短、帶有字幕且會循環播放的動畫,並內嵌在 HTML 簡報或報告中,提供三種格式讓讀者選擇。此方法已在實際客戶的簡報中驗證(請參閱「實例」)。它使用 huashu-art-motion 作為分鏡方向指引(鏡頭規則、六種常見失敗模式),並在風格 A 與 B 中作為渲染引擎。
使用時機與不適合的情況
適合使用時機:
- 簡報或 HTML 報告中包含機制圖解(如檔案流向、規則檢查、人員審批),且讀者應能 逐步看到 這些過程,並搭配可閱讀的文字說明;
- 使用者希望獲得真正不同格式的選項,而不只是同一種動畫的三種配色;
- 先前的動畫「太快而難以閱讀」或需要加上字幕。
不適合用於:單一的微互動(請使用 animate)、有旁白的影片(請直接使用 huashu-art-motion),或數值圖表(請使用 dataviz)。
三種風格(差異在格式,而非色調)
| A · 圖解短片 | B · 黑板風格 | C · 即時 SVG | |
|---|---|---|---|
| 畫面 | Huashu 引擎場景,t1_3b1b 語法,渲染為無文字的 H.264 影片 | Huashu y3_whiteboard 語法,深色黑板,鋼筆繪製與手寫字體 (Kalam),方形影片 | 由虛擬相機拍攝的內嵌 SVG 世界(viewBox 由 GSAP 3.12.5 驅動) |
| 畫面上的文字 | 根據導出的相機軌跡,將 HTML 標籤疊加在影片上(永遠清晰) | 直接繪製在影片中(必須夠大,以便在手機上閱讀) | 真實的 SVG 文字(清晰,可根據寬度調整相機視角進行重排) |
| 每部影片大小(實例) | 360–820 KB(預算 900 KB) | 847–886 KB(預算 900 KB) | 21–34 KB(預算 40 KB) |
| 風格感受 | 乾淨的 3Blue1Brown 風格圖解 | 有人在黑板前講解 | 精美的產品動畫 |
| 主要風險 | 場景變更若未重新渲染,標籤可能會偏移 | 檔案大小預算緊繃;手機上標籤字級需為 12–14 px | 相機計算繁瑣;GSAP 變形可能有怪異行為 |
三種風格共享一份字幕腳本、相同的節拍與相同的嵌入契約,因此風格切換器能以三種方式呈現相同的故事。
完整工作流程
每個步驟結束時都有一個檢查點,用來確認該步驟已完成。
-
找出機制與一個具體範例。 閱讀主機頁面(僅限讀取)。選定要展示的時機,以及在每個節拍中貫穿的一個真實範例(一個資料列、一筆記錄、一個案例,並帶有實際數值)。列出不可發明的內容(ID、數字、圖像)。→ 檢查點:你能用「這個物件做了這個動作」的句型,搭配一個名詞和一個動詞,來說明每個節拍。
-
先撰寫字幕腳本。 每個節拍一句字幕,每部影片 3–6 個節拍,放在
beats.json中(格式:scripts/common/beats.example.json)。字幕在所有風格中完全相同。然後運行節奏檢查工具,並閱讀其印出的底線:python3 ~/.claude/skills/wiggle-that-diagram/scripts/common/pace_check.py beats.json→ 檢查點:每個節拍都通過,且使用者已同意由此產生的循環長度(或縮短字幕)。切勿獨立於字幕設定循環目標;請參閱「節奏控制」一節。
-
撰寫簡報。 將
references/brief-template.md填入SPEC.md:引用的回饋、代幣、節奏規則、字幕腳本、節拍、嵌入契約(含完整程式碼片段)、預算、交付物。→ 檢查點:一個沒有其他上下文的代理,可以僅根據此檔案進行建置。 -
方向性回合(可選,當使用者尚未看過三種風格時)。 三個代理平行作業,每個針對一種風格,為同一部影片的同一個時機建立原型(約 8–12 秒),並在 1280 與 390 px 寬度下產出靜態畫面。將這些靜態畫面並排展示;由使用者選擇其中一種,或保留全部三種。→ 檢查點:使用者的選擇已白紙黑字記錄下來。
-
建置回合。 每個風格一個代理(各自繼續其原型),所有該風格的影片僅寫入
<work>/<style>/final/。簡報文件:references/agent-briefs.md。在啟動前,告知使用者代理的數量與層級。→ 檢查點:每個代理都回傳檔案、靜態畫面、NOTES.md 與一份誠實的未解決問題清單。 -
建置食譜(每個風格的代理實際做了什麼;腳本位於
scripts/style-a|b|c/): 兩部中性影片會以全部三種風格發布,每部都包含beats.json加上style-a/、style-b/、style-c/:examples/pizza/(5 個節拍,簡單)與examples/expenses/(6 個節拍:卡片、顏色編碼的原因、指向第二與第三面板的箭頭)。從你的風格資料夾開始。- A. 執行
cp -R ~/.claude/skills/huashu-art-motion/scripts/engine <A>/engine與touch <A>/engine/favicon.ico。加入engine/ways/way_make.js(來自scripts/style-a/),執行beats_js.py beats.json --engine engine --var <VAR>(字幕與時間軸使用共享腳本,絕不手動複製),建立場景檔案engine/ways/<film>.js,該檔案呼叫WAY.make({poster, beats, camBase, cam, labels})並註冊SCENES['<film>'],以及engine/eras_<film>.js,設定FILM_SIZE=[1280,720]、PUNCH=0(否則引擎會在過渡時加入 0.03 的推近效果)、SCENE_LIBS=['ways/way_make.js','ways/<film>_beats.js','ways/<film>.js']、SCENE_DIR='ways'、ERAS=[{id:'<film>',dur:1}]。請勿在畫布上繪製任何文字。繪圖輔助工具(圓角方框、點、線、淡出、沿路徑移動)會針對每部影片分別撰寫;examples/pizza/style-a/pizza.js提供了一組可直接複製的小型範例。接著執行render_way.py <film> --scale 0.75 --crf 30→build.py <film> --meta meta.json→pace_check.py work/<film>_data.json→test_embed.py final/<film>.html:<verdict beat>(或一次全部執行:make.sh)。每次修改場景後,都需重新渲染並在移動中點執行midmove.py:標籤必須精準定位在其對應物件上。 - B. 以同樣方式複製引擎(加上空的
favicon.ico:在真實 Chrome 中,其 404 錯誤會被視為頁面錯誤,導致render.py在寫入完整影片後回傳 1 並退出),並修補render.py為chromium.launch(channel='chrome', …)。將kit.js放入engine/demos/_hb/,撰寫engine/demos/<film>/board.js(使用HB.make(),將事件錨定到BEATS[k].t0 + offset)與engine/demos/<film>/eras.js(FILM_SIZE=[1080,1080]、PUNCH=0、Kalam 字型、SCENE_LIBS=['demos/<film>/beats.js','demos/_hb/kit.js','demos/<film>/board.js'])。接著執行build_beats.py beats.json --engine engine→uv run --with playwright python engine/render.py --film demos/<film> --fps 30 --crf 12 --out work/<film>_master.mp4→build_html.py <film> --beats beats.json(720²、24 fps、能符合預算的最低 CRF)→test_contract.py final/<film>.html --beats beats.json。每部影片結尾需有一個向後拉遠到整個黑板的鏡頭、至少 1 秒的停頓,然後一個橡皮擦擦拭效果;空的黑板是循環重新開始的地方。beats.json中最後一個節拍的still參數由所有風格共享,且必須在擦拭開始前結束,否則海報畫面與減少動態模式的影格會顯示半擦拭的黑板。會移動的物體(如滑板車、盒子)需使用K.custom項目,設定handed: false,其位置為時間的函數;為每個物體設定結束時間,否則它在完成任務後仍會留在黑板上。 - C. 從
examples/pizza/style-c/開始(SVG、時間軸、主題)。撰寫src/<film>.svg(一個<svg id="scene" class="scene">包含一個<g id="world">;為每個<text>或其群組加上data-b="<beat>"屬性,從 1 開始,設定為相機 首次 顯示該文字的節拍)與src/<film>.js(相機影格FR/ORDER,每個影格包含一個w矩形(寬螢幕)與一個t矩形(窄螢幕);使用R(selector, beat, offset)進行揭示、camTo(i, beat, offset)進行移動、draw()進行線條繪製)。在 JS 中,beat是 以 0 為基底(B[0]代表節拍 1)。R()僅用於文字,且 offset 需 ≥ 2.5 秒:每次呼叫R()都會被記錄為一次文字揭示,而字幕本身在 offset 0 時也算作文字,因此在無文字的形狀上或 offset 0 時使用R()會導致間距檢查失敗。與字幕同時出現的形狀、移動與文字,應直接使用tl.to(…, T(beat, offset))。將任何擁有自己的transform屬性的元素包裹在父層<g>中,並對子元素進行動畫。接著執行build.py beats.json --src src --out final [--css theme.css]→test_contract.py final/<film>.html --stills <scratch>/stills→widthcheck.py。執行時期檔案(shell.html、shell.css、head.js、tail.js)保持原樣直接使用;若要與主機頁面配色相符,請將新的:root變數(如--panel、--ink、--muted、--line、--gold、--cap-ink、--on-gold等)放入主題檔案,並以--css參數傳入。若要為 README 或聊天產生影片的 GIF 圖片:gif.py final/<film>.html --beat 4 --from 1.9 --to 8.6 --out film.gif(或使用--still <s>產生 PNG)。
- A. 執行
-
在展示給任何人之前進行檢查(所有三個建置流程都跳過了最後三步;請務必執行):
- 節奏檢查通過;契約測試在真實 Chrome 的 1100 與 360 px 寬度下全部通過;檔案大小在預算內;
- 查看每個節拍在兩種寬度下的聯絡表(
scripts/common/contact_sheet.py); - 對 A/B 風格的 mp4 檔案執行 huashu 的
film_gate.py(必要時配合 ffprobe 橋接程式),紅色讀數需按照traps.md中的說明解讀; - 由一位未參與建置的獨立審查者以正常速度觀看每個循環,並撰寫發現報告;
- 在真實的主機頁面中測試(即
references/embed-contract.md的父端),並在真實手機與 Safari 瀏覽器上測試(風格 A/B 的降級方案:當 H.264 無法播放時,顯示海報畫面加上編號字幕)。
-
交付。 將三種風格的成果並排展示(使用靜態畫面或切換頁面),附上循環長度、檔案大小、畫面上發明瞭哪些元素(供使用者確認),以及哪些項目未被檢查。
主機端:一個投影片內含全部九個動畫與一個切換器(scripts/deck/)
實際簡報的做法,以及過程中遇到的問題:
- 嵌入,而非連結。 每部影片以
<script type="text/plain" id="anim-{style}-{way}">的形式放入簡報,並將</script轉義為<\/script、<!--轉義為<\!--;JavaScript 僅在該投影片被顯示時(一個 iframe,按需建置)將內容解碼並寫入iframe.srcdoc。embed_anims.py會在<!--ANIM-DATA-START-->/<!--ANIM-DATA-END-->標記之間以冪等方式執行此操作,因此重新建置後再次執行只會替換資料。簡報大小從約 0.3 MB 增加到 4.5 MB。 - 一個投影片,兩個分段按鈕群組(方式、風格;使用
aria-pressed屬性),並在每個「方式」的詳細資訊面板中加入一個「觀看運作過程」連結,該連結會設定方式、關閉面板並跳轉到該投影片。 - 播放/暫停跟隨投影片切換:
go()會分派一個deck:slide事件;切換器在其對應的投影片成為當前投影片時(以及在 iframe 載入完成且為當前投影片時)發送anim-play訊息,否則發送anim-pause。 - 覆蓋 iframe 的手勢操作: 簡報的滾輪/按鍵/滑動手勢處理器被重構為
onWheel(dy,mode)、onKey(key)、onSwipe(dy,dx),由簡報本身的監聽器以及 iframe 轉發的訊息共同呼叫(僅接受e.source === frame.contentWindow的訊息)。howtest2.py證明每次手勢操作只會切換一個投影片。 - 適應單一筆電螢幕:先縮窄內部版面,最後才縮放。 在 1280×800 解析度下,影片全寬時高度為 740–840 px,導致字幕落到螢幕下方。解決方案(
embed_anims.py中的howFit函式):srcdoc iframe 為同源,因此簡報可以直接測量contentDocument.body的高度。它從方框寬度開始,向下搜尋到 740 px(二元搜尋,每次調整寬度後等待約 90 ms,因為影片可能需要在 resize 事件後用 JavaScript 重新計算版面,期間隱藏 iframe),並保留能放入螢幕的最大寬度;只有在 740 px 仍然太高時,才設定frame.style.zoom。縮窄版面優於縮放,因為字幕可以保持原有字級,而圖片會縮小。過程中遇到的陷阱:(a) 使用width:100%進行縮放會使內部版面變寬(1126 → 1876 px),並導致搜尋循環到最小值,因此始終將frame.style.width固定為 px 單位;(b)documentElement.scrollHeight從不回報小於 iframe 當前高度的值,因此無法用於驅動縮小(應測量body,並先將 iframe 設為足夠高);(c) 在寬度變更的同一個 tick 中進行測量,會讀到過時的 JavaScript 版面資訊。請要求影片在寬螢幕下採用並排版面(方形圖片),並在 780 px 寬度下總高度 ≤ 580 px。手機(< 900 px)無法適應;投影片將可滾動,並使用精簡的頁首。 - 測試:
howtest.py(切換、深層連結、方向鍵)、howtest2.py(所有 9 個動畫在真實 Chrome 中載入並播放,透過 iframe 的滾輪/按鍵/滑動手勢確實只移動一個投影片),以及簡報自身的一次手勢一個投影片檢查。任何硬編碼了投影片編號的簡報測試,在插入投影片後都會失效:請改用 id 查詢投影片。
節奏控制(使用者對初版的主要抱怨)
初版原型為 8–13 秒。讀者評價:太快,無法閱讀正在發生的事,而且需要字幕。重建時使用的規則:
- 每個節拍持續時間 ≥ max(4.5 秒, (字幕字數 + 新圖片字數) / 3 + 1.5 秒)。標準計數由
scripts/common/pace_check.py執行(風格 C 的build.py也以相同方式從 SVG 計數)。風格 A 的pace_check.py會檢查已建置影片的真實標籤時間,其差異在於:如果一個詞在後續節拍中被新的標籤重複使用,則會再次計數; - 新的視覺元素只能在前一段文字出現後 ≥ 2.5 秒才出現;相機在閱讀文字期間保持靜止;
- 節拍之間的緩慢相機移動(1.4–2.0 秒,正弦緩動),每部影片最多一個快速強調動作,用於結論時;
- 在循環開始前,最後一個影格需停留 ≥ 1 秒。
向使用者說明的後果:每個節拍約 20 個字的字幕,會使 5–6 個節拍的影片長度達到 40–80 秒。在實際案例中,簡報的 18–34 秒目標是不可能達成的(僅字幕就需要 31.5–51.7 秒);三個代理都選擇採用了閱讀規則,建置了 35–82 秒的循環,但沒有人事先與使用者確認。要縮短循環,就必須縮短字幕,且只需在 beats.json 中修改一次,即可套用到所有風格。
嵌入契約、大小與手機(摘要;完整內容請參閱 references/embed-contract.md)
- 每部影片為一個自包含的 HTML 檔案,由主機載入到
<iframe srcdoc>中。包含畫面 + 字幕條 + 步驟軌道 + 播放/暫停按鈕,無其他內容。媒體以 base64 內嵌; - 所有三個風格的影片都遵守相同的嵌入契約,因此切換器可以在不重新載入頁面的情況下動態替換 iframe 的內容。
- 手機版(< 900 px 寬度)不採用適應性縮放,而是讓投影片可滾動,並使用精簡的頁首,因為無法在這麼小的螢幕上同時舒適地顯示圖片與字幕。
- 測試涵蓋了所有主要瀏覽器(Chrome、Safari、Firefox),並確保在 H.264 無法播放時(例如在某些 Safari 版本中),風格 A/B 會優雅地降級為顯示海報畫面與編號字幕。
相關技能與參考資料
huashu-art-motion:提供分鏡方向、相機規則與渲染引擎。animate:用於單一的微互動動畫。dataviz:用於數值圖表與資料視覺化。- 實際案例:
examples/pizza/與examples/expenses/展示了完整的建置流程與輸出範例。 - 簡報文件模板與嵌入契約:位於
references/資料夾。
貢獻與回饋
歡迎提出問題或改進建議。請開啟一個 Issue 或提交 Pull Request 到本技能的儲存庫。
這個技能是為了讓技術簡報與報告中的機制圖解更具互動性與可理解性而設計的。