重新設計 AveMujicaBot:從三個爛攤子到一個乾淨的架構
起點:我只是想讓 Discord Bot 回傳截圖
這個專案的起源很單純:我想在 Discord 上搜尋 Ave Mujica 動畫的字幕截圖。 輸入一句台詞,bot 回傳那個畫面。就這樣。
當時沒什麼全端經驗,但想累積,所以就硬做了。結果誤打誤撞做出三個獨立的專案:
AveMujicaBot/
├── ave-mujica-subtitle-extractor/ # Python
├── ave-mujica-api/ # Node.js + Express
└── Ave-Mujica-Bot/ # Discord.js
舊版架構是這樣運作的
Extractor:下載 YouTube 影片,每 8 幀跑一次 OCR,截圖存成 PNG,字幕存成 JSON。
API:讀 JSON 檔案,做模糊搜尋,serve PNG 圖片。IP 寫死 192.168.1.111:3000。
Bot:呼叫 API,把結果送進 Discord。裡面裝了 Vue.js 和 Pinia。 (對,一個 Discord bot 裡有前端框架,因為我當時覺得狀態管理需要 Pinia。)
實際長什麼樣
subtitles.json 每一筆長這樣:
{
"text": "下次武道館演出時",
"frame": 7032,
"seconds": 293.0,
"timestamp": "00:04:53",
"image_path": "frames/ep01_frame_007032.png",
"confidence": 0.89,
"episode": 1
}
Bot 指令只有四個:/關鍵字查詢、/查詢所有、/info、/help。搜尋結果有分頁,可以跳頁。
三個專案各自有 package.json 或 requirements.txt(extractor 連 requirements.txt 都沒有),部署要手動開三個 terminal,IP 換了就整個壞掉。
這不是架構,這是意外的產物。
舊版的思路:不知道有別的方法
「要顯示截圖」→「那就把截圖存起來」。這是我當時唯一想得到的方法,不是因為這樣最好,而是因為我不知道有別的方法。
更誇張的是,那時候在 Windows 上開發。Python + EasyOCR + ffmpeg 在 Windows 上是地獄難度。我也不知道當時怎麼撐過的。
上線之後,用起來感覺怪怪的,很不方便。但我當時能想到的解法只有一個:
搬上雲端。 把圖片存 S3,API 搬 EC2,這樣應該就好了吧?
還好我沒有真的去實作。
某天打開資料夾,看了五秒,關掉
決定重寫。不是因為要加功能,而是「我看不下去了」。
重寫目標很簡單:
- 不要三個獨立專案
- 一個指令能啟動
- 參數不要 hardcode
但架構怎麼設計,還沒想清楚。
架構決定 1:為什麼要存圖片?
重寫時,第一個計畫還是把 PNG 存進 Supabase Storage。畢竟舊版就是這樣做的。
然後我開始估算儲存需求:
| 項目 | 估算 |
|---|---|
| 每集字幕數 | ~400 筆 |
| 13 集總計 | ~5,200 筆 |
| 每張 PNG(1080p) | ~800 KB |
| 總儲存需求 | ~4 GB |
Supabase 免費方案是 1 GB。改 JPEG 壓到 200 KB,5,200 張 = 1 GB,壓線,不舒服。
然後我問自己一個問題:我為什麼要存圖片?
因為 API 要回傳截圖給 bot。截圖從哪裡來?從影片的某一幀。影片我已經有了,放在 videos/ 裡,13 集加起來約 1.3 GB。
舊版
影片 → 提前截圖 → 全部存起來 → API 回傳存好的圖
新版
影片 → API 接到請求時再截
影片本來就在,為什麼要多存一份?
# 用 ffmpeg 直接從影片截一幀,不需要預存 PNG
ffmpeg.input(video_path, ss=seconds).output("pipe:", vframes=1, vcodec="png")
這一個念頭砍掉了整個圖片儲存層。
架構決定 2:GIF 也一樣
加 GIF 功能的時候,我的第一反應還是老思路: 「那我要把每個字幕從開始到結束的所有幀都存下來?」
算一下:3 秒片段 × 10fps = 30 幀,5,200 筆 × 30 張 × 1 MB = 156 GB。不行。
退一步想:GIF 是什麼?是一段影片片段的重複播放。我需要的東西全都已經有了:
- 影片檔案(已經有了)
- 開始時間(OCR 掃出來的)
- 結束時間(下一句字幕出現的時間)
不需要預先把 GIF 生成並存起來。
# 需要 GIF 的時候,直接從影片切
ffmpeg.input(video_path, ss=start_seconds, t=duration)
.filter("fps", 10)
.filter("scale", 640, -1)
.output("pipe:", format="gif")
所以資料庫裡只需要:文字 + 開始時間 + 結束時間 + 影片路徑。
CREATE TABLE subtitles (
id TEXT PRIMARY KEY,
episode_id TEXT,
timestamp TEXT, -- "00:01:23"
seconds REAL, -- 83.0
end_seconds REAL, -- 86.4
text TEXT,
video_path TEXT, -- "videos/ep01.mp4"
confidence REAL
);
架構決定 3:SQLite 先頂著,Supabase 之後再說
設定 Supabase 需要時間,我不想現在花在這上面。
這時候我問了 Agent,它告訴我一個我不知道的選項:SQLite 可以先頂著用,schema 跟之後的 Supabase 幾乎一樣,切換只是改一個 flag。
這是一個我當時不知道存在的選項。知道之後是顯而易見的決定。
# 現在,本地 SQLite
python -m extractor.cli process-all --backend sqlite
# 之後,改一個 flag
python -m extractor.cli process-all --backend supabase
這個決定讓我把整個 extraction 當天就跑起來,而不是卡在 Supabase 環境設定。
轉折點:盯著 DB 看的那一刻
真正讓我確定「只存時間軸」這個念頭的,是跑 OCR 的時候盯著輸出的資料看:
episode_id | timestamp | text | seconds | end_seconds
01 | 00:00:21 | 來吧,甦醒之夜到來了 | 21.0 | 24.0
01 | 00:00:24 | 需不需要我把她叫醒? | 24.0 | 26.4
就這樣。這幾個欄位就是全部了。
截圖和 GIF 用這幾個數字就能現場生成,不需要預存任何媒體檔案。
Agent 在這個過程中的角色
全程都有 AI Agent 參與,但我想說清楚它做了什麼、沒做什麼。
我做的
- 「只存時間軸」的念頭(盯著 DB 自己想到的)
- 架構方向的判斷(存不存圖、GIF 怎麼做)
- 要不要繼續做、怎麼做的決定
Agent 做的
- 填補知識盲區(SQLite 可以當過渡方案)
- 驗證方向(有沒有我沒想到的?)
- 加速實作(想法當天就能跑)
這幾個專案下來,我對 AI 工具的使用方式是:大方向是我的,Agent 負責驗證、實作、補盲區。
前提是你得先有判斷力,知道方向對不對。這個判斷力,是從舊版那三個爛攤子裡長出來的。
新版架構
不預存任何媒體。不需要 S3 或 Supabase Storage。影片留著,需要什麼讓 ffmpeg 現場生成。
二階段掃描:為什麼不逐幀跑 OCR
上面那張圖裡「二階段 OCR 掃描」那個框,是這次重寫裡我自己覺得最值得講的一段。
逐幀的成本
從舊版的 subtitles.json 可以反推出影片幀率——frame: 7032 對到 seconds: 293.0,7032 ÷ 293 = 24 fps。
一集 24 分鐘就是 24 × 60 × 24 ≈ 34,560 幀。EasyOCR 跑一幀不是免費的,而且這 34,560 次呼叫裡絕大多數是浪費的——字幕不會每一幀都變。一句台詞在畫面上通常停留一到數秒,也就是連續幾十幀的內容完全一樣。逐幀掃描等於對同一句話重複辨識了幾十次。
粗掃:先用取樣把範圍縮小
所以第一階段不是「找字幕」,是「找字幕變了的地方」。每 8 幀取樣一次,OCR 呼叫次數直接降到 1/8——一集約 4,320 次。
代價是時間軸的精度。取樣間隔 8 幀,代表偵測到「這裡變了」的時候,真正的變化點落在前面最多 7 幀之內,也就是 7 ÷ 24 ≈ 0.29 秒的誤差。對「搜尋台詞、跳到那一幀」這個用途來說,0.29 秒的偏差是看得出來的——可能截到上一句話的最後一幀。
所以才需要第二階段。
細掃:在已知區間內收斂
粗掃給的不是答案,是一個包含答案的區間。已知第 n 次取樣還是舊字幕、第 n+1 次已經是新字幕,那變化點必然在這 8 幀之內。第二階段只需要在這個很小的區間裡把精確起始幀找出來。
這就是 coarse-to-fine search:先用便宜的方式把搜尋空間砍小,再用精確的方式處理剩下的部分。同樣的形狀在很多地方出現過——影像處理的 image pyramid、影片編碼的 motion estimation,都是「先在低解析度找大概位置,再到高解析度修正」。
關鍵在於兩階段的成本結構不對稱:粗掃便宜但不準,細掃準但貴。把貴的那個限制在極小的區間內,總成本就被壓下來了。
這裡我還沒做完的功課:8 這個取樣間隔當初是憑感覺定的,不是量出來的。合理的做法是先量字幕的最短持續時間,取樣間隔必須小於它,否則會整句漏掉。我還沒回頭驗證過這件事。
心態轉變的核心
舊版的邏輯:盡量預先存好所有東西,之後才快。
新版的邏輯:只存最小的原始資料,其他的需要時再算。
兩者的差別不是技術能力,而是對「成本」的定義不同。
舊版把「運算成本」當作最貴的東西,所以盡量預算; 新版發現「儲存成本」和「複雜度成本」其實更貴——你要一直維護它、備份它、遷移它、出錯了要除錯它。
講白一點,這整件事就是一個 space–time tradeoff,只是我一開始沒有意識到自己在做取捨,以為「先存起來」是唯一解。
有趣的是,這篇文章裡出現了兩次同樣的取捨,方向卻是相反的:
| 換什麼 | 得到什麼 | |
|---|---|---|
| 二階段掃描 | 多花一點運算(跑兩趟) | 少跑 7/8 的 OCR |
| 不預存媒體 | 多花一點運算(每次現算) | 省下 4 GB(含 GIF 是 156 GB) |
所以「用空間換時間」不是一條可以背起來的準則。真正要問的是:在我這個情境下,哪一種資源比較稀缺?
對這個專案來說,稀缺的是儲存和我自己維護的心力,不是 CPU——影片只有 1.3 GB 而且不會變,一台機器就跑得動。換一個情境,比如流量大到 CPU 先撐不住,答案就會反過來。
ffmpeg 生成一張截圖大約是幾十毫秒的量級——這個數字我當初是憑手感抓的,沒有正式量過,所以下面的推論建立在「它遠小於一次網路往返」這個比較粗的前提上,而不是某個精確值。
即使如此,方向是清楚的:維護一個 4 GB 的圖片資料夾所帶來的同步問題、版本問題、備份問題,代價遠不止一次即時生成。前者是持續的維護成本,後者是一次性的運算成本。
還好我沒有把「搬雲端」那個版本做出來。
這裡欠一個量測:要把上面這段講成真的,需要的是 ffmpeg seek + 編碼在固定解析度下的 p50 / p99、冷啟動與熱啟動的差別、以及跑在什麼機器上。這些我都還沒量。在補上之前,請把它當成一個方向判斷,不是一個效能結論。
現在的樣子
Discord Bot 最後沒有重寫——整個換掉了。
與其繼續維護一個 Bot,不如做一個網頁。搜尋、截圖、GIF,瀏覽器就能跑,不需要 Discord 帳號,不需要把 Bot 請進伺服器。
最後長出來的是 雞狗查圖——一個用 Next.js 寫的字幕截圖搜尋網站。
技術棧最後定在這裡:
- Extractor:Python、EasyOCR、yt-dlp、SQLite
- Web:Next.js、better-sqlite3、ffmpeg、LRU cache、shadcn/ui
兩個意外決定:
DB 直接進版本控管。 Clone 完就能搜尋,不需要先跑 OCR。影片準備好才有截圖和 GIF——邊界很清楚。
多系列不是設計出來的。 Schema 設計的時候沒特別為這個設計,但因為資料夾結構和 DB 都是按系列隔離的,加 MyGO!!!!! 只是加一個資料夾。
Supabase 遷移最後沒做。SQLite 放在 repo 裡的方案,目前夠用。
為什麼快取用 LRU
「不預存」的代價是每次請求都要現算,所以快取是這個架構的必要配套,不是加分項。
選 LRU(淘汰最久沒被用到的)而不是 FIFO 或 LFU,理由跟這個站的存取形狀有關:
- FIFO 只看進來的順序,不看有沒有在用。一張正被反覆瀏覽的熱門截圖,只因為進來得早就被踢掉,這在「大家都在搜同幾句名台詞」的情境下明顯不對。
- LFU 看累積次數,長期而言更準,但它對「熱度會變」這件事反應很慢——新一集播出後的新台詞要累積很久才追得上舊的高分項目,而且要額外維護計數。
- LRU 只需要一個存取順序,實作便宜,而且「最近被看過的東西,接下來還會被看」這個假設,剛好符合一句台詞爆紅時的存取模式。
兩組快取的參數不同,是因為它們裝的東西成本不一樣:
| 快取 | 容量 | TTL | 為什麼 |
|---|---|---|---|
| media(截圖 / GIF) | 500 項 | 1 小時 | 每一項都是一次 ffmpeg seek + 編碼,重算貴,值得留久一點 |
| search(查詢結果) | 200 項 | 5 分鐘 | 一次 SQL LIKE,重算便宜,留太久反而佔記憶體 |
快取鍵就是決定輸出的那組參數(系列 + 時間點 + 格式),所以同一個畫面不同格式不會互相污染。
這裡也欠一個量測:容量和 TTL 都是拍腦袋定的,我沒有量過命中率。要調得有依據,該看的是 hit rate 隨容量變化的曲線,以及 miss 的時候使用者實際等多久。
- [✓]API 層(SQLite → HTTP endpoints + ffmpeg on-demand)
- [✓]Web UI 取代 Discord Bot(Next.js + shadcn/ui)
- [✓]GIF / 搜尋結果 LRU cache
- [✓]部署上線
- [ ]Supabase 遷移
部署:原本以為是最輕鬆的一步
計畫
計畫是:家裡的機器跑 Docker,前面掛 nginx 反向代理,再用 Cloudflare Tunnel 把流量引進來。先寫好了 setup-nginx.sh、setup-gateway.sh、update.sh 三個腳本,把步驟都包好。
nginx 根本不需要
開始做之後才發現,nginx 那一層完全是多餘的。
nginx 的價值在於:你有多個 service 要統一入口、要做 SSL termination、要 rate limiting、要 load balance——一套完整的 lab 架構才需要它。單一服務搭 CF Tunnel,cloudflared 本身就是 reverse proxy,從 Cloudflare 的 edge 把流量引進來,直接打到你指定的 service。中間不需要任何東西。
三個腳本和 nginx-gateway.conf 全部刪掉。
tunnel:
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
depends_on:
- web
踩的坑
CF dashboard 設錯 service URL。
CF dashboard 裡把 service 填成 https://localhost:3000。錯的。那個 localhost 是 tunnel container 自己的本機,不是隔壁的 web container。應該填 http://web:3000——Docker Compose 同一個 network 裡的 service 用名字直接連。
Token 在終端機裡被截斷。
CF Tunnel token 是 base64 JSON,格式是 eyJ...。貼進終端機的時候,指令被截斷,開頭的 e 不見了,變成 yJ...。cloudflared 一直回 Provided Tunnel token is not valid,反覆除錯才發現差一個字元。
.env key 名稱也壞掉了。
同樣的截斷問題,CLOUDFLARE_TUNNEL_TOKEN 變成 UDFLARE_TUNNEL_TOKEN,少了 CLO。Docker Compose 讀不到,tunnel 拿不到 token,繼續報錯。
不想直接暴露 SSH。
家裡的網路技術上可以從外部 SSH 進來,但我不想這樣做——打開 inbound port 讓 SSH 暴露在外面,麻煩的事情太多。CF Tunnel 的方向(container 主動連出去找 Cloudflare,不需要 inbound)更乾淨,沒有 public SSH port,也不需要管防火牆規則。
結果
三個腳本、一個 nginx config、無數次 docker compose down 之後,上線了。
做完這些之後才懂 Vercel 為什麼偉大
部署完之後,我才真正理解 Vercel 在做什麼。
今天手動做的事:tar 打包、scp 傳檔、SSH 進去、寫 .env、docker compose up、設 CF Tunnel、改 dashboard、除錯 token 截斷——這些事情在 Vercel 上不存在。push to main,等兩分鐘,上線。
代價是:只能跑 serverless。沒有 ffmpeg,沒有 persistent storage,沒有 SQLite。所以這個專案沒辦法用 Vercel,得自己跑。
「跑起來」和「好維護」是兩件不同的事。現在是跑起來了,但每次更新還是要手動 SSH 進去。下一步想探索的是 CI/CD——push 觸發自動部署,不用再摸伺服器。但那是下一個專案的事了。
上線後:繼續踩坑
Path traversal
上線後對自己的 API 打了安全測試。SQL injection 都擋住了,better-sqlite3 parameterized queries 本來就保護。
但 series 參數沒驗證就進了 path.join()——path traversal 有效。
if (!available.includes(series)) {
return NextResponse.json({ error: "invalid series" }, { status: 400 });
}
白名單比對,兩行關掉。自己寫的東西要自己打一遍。
UX 迭代
能搜尋不等於好用。上線後做了幾輪調整:
- 16:9 card:截圖本來就是 16:9,card 也改成純圖片,文字移到 hover overlay
- Skeleton + fade in:圖片載入前顯示 skeleton,載好後 fade in,overlay 等圖片載好再渲染
- 400ms debounce:每打一個字就打 API 太暴力。400 是憑感覺挑的常見值,沒有針對實際輸入節奏調過
- 手機單欄:兩欄在手機上太擠,改單欄往下滑像 feed
PNG 是無損的
截幀一直輸出 PNG,一張 1080p 截幀 500KB 到 2MB。這時候才搞清楚 PNG 是無損壓縮——它保留每一個像素,而動畫截幀這種照片類內容裡本來就有大量肉眼看不出來的細節。
改成 JPEG(-q:v 3)之後檔案小了大約一個數量級,畫面差異我自己看不出來。這個「大約」是翻了幾張檔案大小得到的印象,沒有做過系統性的比較:
"-vcodec", "mjpeg", "-q:v", "3"
Discord embed 要副檔名
複製圖片 URL 丟 Discord 沒預覽,GIF URL 丟進去只顯示靜態圖片。
Discord scraper 判斷媒體類型高度依賴副檔名——沒有 .jpg 不認識是圖片,沒有 .gif 不播動畫,就算 Content-Type 正確也一樣。
Route 加上 suffix strip,copy button 附上副檔名,問題解決。
解耦
六個 route(image、gif、search 各一組 v1)核心邏輯幾乎完全一樣,各自複製貼上。把共用邏輯抽出來:
lib/media.ts ← getFrameBuffer / getGifBuffer
lib/search.ts ← searchSubtitles / validateSeries
lib/api-key.ts ← requireApiKey
Route 變薄層,只負責 HTTP。改格式、改 cache TTL 只動 lib/,不用碰六個 route。
「改一個地方要動六個檔案」不是正常狀態,但要上線之後才會真正感受到。
前後對照
寫到這裡才發現,整篇都在講過程,卻沒有把「變成什麼樣」擺在一起看:
| 舊版 | 新版 | |
|---|---|---|
| 專案數 | 3 個各自獨立的 repo | 1 套 |
| 圖片儲存 | ~4 GB 預存 PNG | 0(ffmpeg 現場生成) |
| 若連 GIF 也預存 | 推算需要 ~156 GB | 0 |
| 字幕索引 | JSON 檔案,全檔讀入後模糊比對 | SQLite,SQL LIKE 查詢 |
| 介面 | Discord Bot(需要帳號、需要邀請進伺服器) | 網頁,開瀏覽器就能用 |
| 支援系列 | 1 | 3(Ave Mujica、MyGO!!!!!、YUME∞MITA) |
| 部署 | 手動開三個 terminal,IP 寫死 | Docker + Cloudflare Tunnel |
| 共用邏輯 | 六個 route 各自複製貼上 | 抽到 lib/,route 只做 HTTP |
需要說清楚的是:這張表裡只有儲存那幾列是算出來的,其他都是結構描述。延遲、吞吐、快取命中率這些真正該量的東西,我一個都還沒量。
參考
- 一個全形括號,讓我的部署安靜死了一個月 — 後續,上線幾個月後這套部署怎麼壞掉的
- ffmpeg documentation —
-ssseek 與-vcodec mjpeg的行為 - EasyOCR — 字幕文字辨識
- better-sqlite3 — Node 端同步式 SQLite
- yt-dlp — 影片下載
- Cloudflare Tunnel — 不開 port 的對外連線
- OWASP:Path Traversal — 上線後我在自己 API 裡踩到的那個
- 線上版:一句一幀