# 錄教學影片時,游標為什麼錄不進去?一個 Claude Code 錄影 skill 的誕生
我想讓 AI 幫我操作瀏覽器,順便把過程錄成教學影片。聽起來只是「開個螢幕錄影」的小事,實際做下去卻撞到一個作業系統層級的限制:在單一視窗的錄影裡,滑鼠游標不可能出現。
不是 macOS 忘了做這個功能,是結構上做不到。查清楚這件事之後,解法反而變得清楚——既然抓不到真的游標,那就在頁面裡畫一顆假的。
最後這套東西被我包成一個 Claude Code skill,已經開源:github.com/yanchen184/browser-record。這篇講的是中間那段推導,以及三個模式各自的取捨。
TL;DR:想錄乾淨的網頁教學影片會遇到三難:要游標、要畫面乾淨、要錄的時候能做別的事。macOS 全螢幕錄影有游標但吃掉整個桌面;單視窗錄影(screencapture -l )能在視窗被完全蓋住時照錄,但結構上不可能有游標——它讀的是視窗自己的 backing store,而游標是系統畫在所有視窗之上的獨立圖層。解法是換前提:用 Playwright 錄瀏覽器內容,在頁面裡注入一顆 DOM 假游標(平滑移動 + 點擊漣漪)。三者兼得。已開源成 Claude Code skill。
重點速覽
- 問題:錄網頁教學影片,想同時要「看得出點了哪裡」「畫面乾淨」「錄的時候我能做別的事」,三者用內建工具無法兼得。
- 關鍵發現:macOS
screencapture -l可以在視窗被其他視窗完全蓋住時照錄乾淨內容(讀 window server 的 backing store),但同樣的原理決定了它永遠不會有游標——游標不屬於任何視窗。 - 解法:不要捕捉真游標。用 Playwright 錄自己起的瀏覽器,
addInitScript注入一顆固定定位的 DOM 圓點當游標,用 CSStransformtransition 做平滑移動,點擊時放@keyframes漣漪。 - 最難的坑不是畫游標,是驗證它有沒有出現:注入的節點會被框架 hydration 清掉,而「抽影片畫格來檢查」不可靠——隨便挑的時間點很可能落在游標移動的空檔,看不到不代表沒有。正解是在點擊那一瞬間存證截圖。
- 成果:開源 skill,三種模式一支腳本,clone → npm install 就能用。
- 三難:游標、乾淨、不占螢幕
- 被蓋住也能錄:backing store 的意外收穫
- 為什麼游標抓不到
- 換個前提:畫一顆假的
- 三種模式怎麼選
- 最難的不是畫游標,是驗證它真的在
- 踩過的坑
- 安裝與使用
- FAQ
三難:游標、乾淨、不占螢幕
錄一段「怎麼操作這個網站」的教學影片,我要的東西其實只有三個:
用內建工具,這三個湊不齊。全螢幕錄影(Cmd+Shift+5)滿足第 1 點,但要求瀏覽器攤在桌面最上層,於是第 2、3 點全破。我開始翻 screencapture 的參數,想看看有沒有辦法只錄一個視窗。
被蓋住也能錄:backing store 的意外收穫
screencapture 有個 -l 參數可以指定 window id,配上 -v(錄影):
screencapture -x -v -V 15 -l <windowid> out.mov
我實測的時候順手把 iTerm 整個蓋在 Chrome 上面,想說錄出來大概是一片黑或錄到 iTerm。結果打開影片——乾乾淨淨的 Chrome 內容,完全沒有 iTerm 的痕跡。
原理是 macOS 的 window server 給每個視窗各自維護一份 backing store(自己的繪圖快取)。畫面上你看到誰蓋住誰,是合成階段的結果;而 -l 直接去讀那個視窗自己的快取,繞過了合成。所以遮擋對它沒有意義。
這一條直接解掉了第 2、3 點:畫面乾淨,而且錄製期間我可以把視窗丟到後面繼續做別的事。
有個例外要記住:縮到 Dock 就錄不到。最小化會讓 backing store 停止更新,蓋住可以,縮小不行。另外 window id 每次開視窗都會變,一定要動態抓,不能寫死。
為什麼游標抓不到
高興沒多久,我發現錄出來的影片裡沒有滑鼠游標。翻遍 screencapture 的 man page 也沒有相關開關。
一開始以為是漏了參數,後來才想通:這跟上面那個「被蓋住也能錄」是同一件事的兩面。
-l 讀的是「某個視窗自己的 backing store」。而滑鼠游標不屬於任何視窗——它是系統合成在所有視窗之上的一個獨立圖層。所以:
- 你讀單一視窗的 backing store → 遮擋物不會進來(優點)
- 同樣地,游標也不會進來(代價)
-capture_cursor 1),連同它的所有缺點一起接受。
想通這點之後,我不再試圖「把游標弄進來」——那條路是封死的。
換個前提:畫一顆假的
既然抓不到系統游標,那就換掉「錄使用者的螢幕」這個前提:用 Playwright 起一個自己的瀏覽器,錄它的內容,然後在頁面裡注入一顆自己畫的游標。
這個轉向一次解掉全部三難:
- Playwright 的
recordVideo錄的是瀏覽器內容,跟桌面完全無關 → 畫面乾淨、不占螢幕 - 游標是我自己畫的 DOM 節點 → 想讓它多明顯就多明顯,還能加點擊動畫
#__demo_cursor {
position: fixed; top: 0; left: 0;
width: 22px; height: 22px;
border-radius: 50%;
background: rgba(255,64,96,0.85);
border: 2px solid #fff;
z-index: 2147483647; pointer-events: none;
transition: transform var(--cur-dur, 900ms) cubic-bezier(.25,.8,.3,1);
}
pointer-events: none 是必要的,否則這顆點會擋住底下元素的點擊。移動就是改 transform,讓 CSS 自己補間:
window.__cursor = {
move(x, y, dur) {
dot.style.setProperty('--cur-dur', dur + 'ms');
dot.style.transform = translate(${x}px, ${y}px);
},
click(x, y) {
ripple.style.animation = 'none';
void ripple.offsetWidth; // 強制 reflow,動畫才能重播
ripple.style.animation = '__demo_ping 600ms ease-out';
},
};
點擊的漣漪是另一顆同心圓,@keyframes 從 scale(1) 放到 scale(3.4) 同時淡出。那句 void ripple.offsetWidth 是重點——不強制 reflow,連續兩次點擊的第二次動畫不會重播。
實際操作時,「點一個元素」被拆成:算出元素中心座標 → 游標平滑滑過去 → 放漣漪 → 才真的觸發 click()。慢下來反而是特色,教學影片本來就需要讓觀眾跟得上。
三種模式怎麼選
最後三種做法我都留著了,因為它們各有沒辦法互相取代的場景:
| A 全螢幕 | B 單視窗 | C Playwright + 假游標 | |
|---|---|---|---|
| 真實滑鼠游標 | ✅ | ❌ | ❌(有視覺指示器) |
| 視窗被蓋住也能錄 | ❌ | ✅ | ✅(根本不占螢幕) |
| 畫面乾淨 | ❌ | ✅ | ✅ |
| 看得出點了哪裡 | ✅ | ❌ | ✅ 漣漪動畫 |
| 錄製時能做別的事 | ❌ | ✅ | ✅ |
| 平台 | macOS | macOS | 跨平台 |
B 的定位比較窄,但在「要錄非 Playwright 控制的那顆瀏覽器」時很有用——比如你要錄的是自己手動操作、有登入狀態的那個視窗。
最難的不是畫游標,是驗證它真的在
這是整件事我花最多時間的地方,也是唯一值得單獨拿出來講的教訓。
假游標是注入的 DOM 節點,而現代網站的框架在 hydration 階段會接管並重建 DOM——注入的節點可能就這樣被掃掉。錄影腳本跑完、印出 OK duration=27.5s,不代表影片裡真的有游標。
我第一次驗證的方法是:從影片抽幾張畫格出來看。結果連續三次都「看不到游標」,我以為是注入失敗,改了三輪程式(加重試、加 MutationObserver、加延遲),問題依舊。
後來寫了一個最小重現腳本,直接在頁面裡問「#__demo_cursor 這個節點現在存在嗎」,答案是存在,而且位置正確。游標一直都在,是我的驗證方法錯了:
transform 是 translate(-50px,-50px)(畫面外),而我那份劇本的第一個動作是捲動、不是點擊——所以開頭十秒游標本來就在畫面外「抽畫格看不到」推不出「游標沒出現」。 這跟看 log 有 success 就當作修好了是同一類錯誤:用一個不對的觀測點,得到一個假結論,然後對著幻覺改了三輪程式碼。
正確做法是讓驗證發生在確定該有東西的那一刻。所以 --verify 參數會在每次點擊的瞬間存一張截圖:
const clickAt = async (selector) => {
const { x, y, el } = await moveTo(selector);
await page.evaluate(([x, y]) => window.__cursor?.click(x, y), [x, y]);
await sleep(180);
if (has('verify')) {
await page.screenshot({ path: join(WORK_DIR, verify_click_${++shotSeq}.png) });
}
await el.click();
};
交付前打開其中一張,用眼睛確認紅點真的壓在目標元素上。這才叫驗過。
順帶一提,初始位置在畫面外那個設計也順手修了:goto() 之後會自動把游標帶到畫面中央,不管劇本第一個動作是什麼。
踩過的坑
除了上面那個,還有幾個值得記:
waitUntil: 'networkidle'會 timeout——多數站有長連線(留言系統、分析腳本),永遠不會 idle。用domcontentloaded+waitForLoadState('load')。我在自己的部落格上卡了 60 秒才想通。- 注入節點要掛
document.body,不要掛documentElement——掛在 documentElement 上比較容易被框架的 DOM 重建掃掉。 context.close()才會 flush 影片——直接browser.close()會拿不到檔案,這是 Playwright 的行為,不是 bug。ffmpeg -list_devices一定回非零 exit code——它把""當成輸入檔所以報錯。在set -euo pipefail的腳本裡直接 pipe,整個 script 會靜默死掉(exit 251,沒有任何錯誤訊息)。必須用|| true隔離。這個坑我 debug 了很久,因為它什麼都不印。- avfoundation 的裝置清單有兩組方括號——格式是
[AVFoundation indev @ 0x...] [3] Capture screen 0,用awk -F'[][]' '{print $2}'會抓到前面的 log prefix 而不是裝置編號。要用sed -nE 's/.*\[([0-9]+)\] Capture screen 0.*/\1/p'。而且這個編號每台機器不一樣,不能寫死。
安裝與使用
skill 已經開源,MIT 授權:
git clone https://github.com/yanchen184/browser-record.git ~/.claude/skills/browser-record
cd ~/.claude/skills/browser-record
npm install
npx playwright install chromium
裝在 ~/.claude/skills/ 底下 Claude Code 會自動載入,之後直接說「幫我錄一段這個網站的操作影片」就會觸發。依賴裝在 skill 自己的目錄裡,所以從任何專案跑都行,不會污染你的專案。
手動跑的話,先寫一份劇本:
export const pace = { cursorMove: 900, afterClick: 1400, readPause: 2200 };
export default async ({ goto, clickAt, scrollBy, type, pause }) => {
await goto('https://example.com');
await scrollBy(500);
await clickAt('a[href="/docs/"]');
await type('input[name=q]', '搜尋關鍵字');
await pause();
};
然後:
node ~/.claude/skills/browser-record/scripts/record.mjs ./my-demo.mjs \
--out ./demo.mp4 --verify
pace 那組數字是給教學影片用的節奏,覺得太慢可以自己調。
FAQ
Q:假游標看起來會不會很假?
會,它就是一顆紅點,不是 macOS 的箭頭。但教學影片要的是「觀眾知道現在點了哪裡」,一顆有漣漪動畫的紅點在這件事上比真實箭頭更清楚——真箭頭在 1280 寬的影片裡其實很難看見。
Q:可以錄需要登入的頁面嗎?
可以,劇本裡的 page 是原生 Playwright page,要帶 cookie、走登入流程都行。但如果你要錄的是已經登入好的那顆手動瀏覽器,那應該用模式 B 而不是 C。
Q:Windows / Linux 能用嗎?
模式 C 可以,它只依賴 Playwright 和 ffmpeg。模式 A、B 是 macOS 專屬(screencapture 和 avfoundation)。
Q:為什麼不用現成的螢幕錄影軟體?
如果你是手動操作、要錄自己的畫面,現成軟體更好。這個 skill 的場景是讓 AI 執行一段可重複的操作流程並錄下來——改版之後重跑一次劇本就得到新影片,不用重錄。
Q:影片可以直接發嗎?
輸出是 mp4(H.264 / yuv420p),可以直接上傳。README 裡那張 GIF 就是從輸出的 mp4 轉的。