🔗 快速連結


這個 app 在做什麼

一句話:上傳 1 張自拍 → AI 產出 9 種誇張表情的 3×3 圖 → 前端合成一支 WebM 拉霸影片 → 貼到 FB。

自拍.jpg ──┐
           ▼
   ┌───────────────┐   ┌───────────────┐   ┌───────────────┐
   │  Worker       │──▶│  Gemini 3.1   │──▶│  3×3 grid     │
   │  (加 API key) │   │  Flash Image  │   │  一整張圖      │
   └───────────────┘   └───────────────┘   └───────┬───────┘
                                                   │
                                     ┌─────────────┘
                                     ▼
                            ┌─────────────────────┐
                            │ 前端 canvas 拆成 9  │
                            │ MediaRecorder 錄成  │
                            │ 一支 WebM           │
                            └─────────┬───────────┘
                                      ▼
                               slot-machine.webm
                                      │
                                      ▼
                         貼到 FB → 自動播 → 點擊即停

FB 上面這招為什麼行得通

Facebook 的動態牆會自動播放短影片,而且使用者點一下就會暫停在當下那一影格

這是這個 app 整個 gimmick 的命脈:

  • 我產出的 WebM 是「9 格洗過牌的表情 × N 圈循環」
  • 每一圈洗牌結果都不同
  • 所以 FB 播到哪、觀眾點到哪 → 停在隨機的某一格表情
  • 這是一張活的心情占卜,零 JavaScript 在觀眾這邊

換句話說,我用一支 30 秒的 WebM 仿造了一個互動 widget,而 FB 原生就能放。


架構:為什麼要一個 Cloudflare Worker

懶人版:只是為了藏 Vertex AI 的 API Key。

完整版:

┌───────────────────────────┐        ┌───────────────────────────┐
│  Static frontend (Pages)  │  POST  │   Cloudflare Worker       │
│  index.html / app.js      │───────▶│   worker/src/index.js     │
│                           │  JSON  │   (holds VERTEX_API_KEY)  │
│  - 拆 3×3 圖 (canvas)     │◀───────│                           │
│  - 合成拉霸影片           │        │   fetch → Vertex AI       │
│  (MediaRecorder → WebM)   │        │   gemini-3.1-flash-image  │
└───────────────────────────┘        └───────────────────────────┘

有人會問:為什麼不直接在瀏覽器打 Vertex API?

  • API Key 會洩漏。一旦 key 在前端,任何人打開 DevTools 都能抄走。
  • CORS。Vertex AI 的 endpoint 預設不給瀏覽器直接打(沒開 CORS header),所以就算你想要,瀏覽器也會 block。
  • Rate limit 護欄。自己擁有 Worker 才能加 IP-based throttling、記 log、看用量。

Worker 其實有三個端點

端點 用途
POST / 帶圖 + slot 設定,回傳 3×3 PNG base64
POST /prompt 只組 prompt 不呼叫 Gemini。給「複製到 Gemini」用
GET /pool 回傳表情池 manifest(45 條),讓前端畫自訂 UI

第二個端點是這個 app 最省錢的設計:使用者撞到 rate limit 時,就按「複製到 Gemini」→ prompt 直接貼到 gemini.google.com 自己跑,完全不吃我的 quota。


Prompt 工程踩雷日記(最有料的部分)

這個專案折騰最久的不是前端,是怎麼讓 Gemini 乖乖畫 9 格對得上

第一版:只列 9 條敘述 → 全糊

最早的 prompt 就是樸素地列出:

1. ECSTATIC LAUGHTER — head tilted back, eyes squeezed shut...
2. BAWLING CRY — eyes tightly shut with tears streaming...
3. FURIOUS ANGER — brows pulled down, nostrils flared...
...

結果:Gemini 產出來的 9 格順序亂對,常常第 1 格畫成了第 5 格的表情,或者某兩格長得一模一樣。配對正確率大概 1-2/9

第二版:補上 ASCII 3×3 diagram + 位置名 → 救活

加了這段作為錨點:

+------+------+------+
|  A   |  B   |  C   |   ← top row
+------+------+------+
|  D   |  E   |  F   |   ← middle row
+------+------+------+
|  G   |  H   |  I   |   ← bottom row
+------+------+------+

  [A] top-left cell → ecstatic laughter
  [B] top-centre cell → bawling cry
  [C] top-right cell → furious anger
  ...

配對正確率從 1-2/9 → 6-9/9。這是整個專案最有感的一次調整。

第三版:字母標籤被烤進圖裡 → 必須「寫在 prompt 但禁止畫出來」

用了字母 A..I 當位置 anchor 以後,Gemini 開始在每格左上角真的畫上 ABC。變成一張考卷。

刪掉字母 → 位置又開始歪。留下字母 → 圖上有字。

最後在 OUTPUT RULES顯式寫死

- Do NOT render any text, letters, numbers, labels, captions, subtitles,
  callouts, watermarks, emoji, arrows, or the letter labels (A..I)
  anywhere on the image.
- The layout above is instruction for you, not text to paint.

這兩條強度拉滿以後,才同時擁有「位置對 + 圖上沒字」。

第四版:卡通 in → 照片 out → 需要「風格鎖定」條款

有一次丟一張動漫風自拍進去,拿回來的 3×3 是照片風的人臉。Gemini 自作主張把卡通「升級」成照片了。

解法:

CRITICAL — match the reference's ART STYLE exactly. Whatever the reference is, keep it:
• If reference is a photograph → output photo-realistic portraits.
• If reference is anime / manga → output anime illustrations in the same line-art and shading.
• If reference is a cartoon / chibi → stay cartoon, same linework and palette.
• If reference is 3D-rendered / CGI → stay 3D-rendered.
• If reference is a painting / sketch / watercolor → match that medium.
• If reference is a statue / deity / sculpture → keep sculptural look.

Do NOT "upgrade" the reference into photography.

這段加了以後,卡通 in → 卡通 out,雕像 in → 雕像 out。

第五版:每次抽 9(從 45)→ 每次都新鮮

第一版 prompt 是寫死的 9 種表情。結果使用者拉兩次,會覺得「每次都差不多」。

改成:

  • 36 種表情 + 9 種天氣/環境反應 = 45 條池子
  • 每次請求洗牌抽 9
  • 天氣有 50% 機率被「蓋在表情上」當成 overlay
  • 組合數 ≈ C(45,9) × 9! ≈ 3.3×10¹²

於是同一張自拍拉 10 次,得到 10 種完全不同的表情組合,包含「放聲大笑 + 淋雨」、「暴怒 + 被雷打到」這類戲劇化混搭。

第六版:天氣格的臉變成另一個人 → 需要身份一致性條款

加了天氣以後新 bug:「被雷打到」那格的主角變成路人。因為頭髮豎起來、表情變了,模型就覺得不用維持同一個人。

補上:

Identity stays constant across every cell: same face/features, colours,
hairstyle, clothing, and background treatment as the reference. Weather
states (lightning, rain, snow, wind, heat, cold, electrocution, sun-dazzle,
goosebumps) MAY temporarily change hair (wet, windblown, standing on end)
and skin/surface (wet, flushed, frosted, cracked) — that is expected.
The SUBJECT must still be clearly the same character.

關鍵是明白告訴模型:頭髮可以濕、可以豎、皮膚可以起雞皮疙瘩 — 但主角不能換人

完整 prompt 在 worker/src/index.js 裡。


前端:拆圖 + 錄影都在瀏覽器做

AI 產出是一整張 3×3 圖,前端要做兩件事:

1. 拆成 9 格

<canvas>,沒什麼玄機:

const tileW = Math.floor(img.naturalWidth / 3);
const tileH = Math.floor(img.naturalHeight / 3);

for (let r = 0; r < 3; r++) {
  for (let c = 0; c < 3; c++) {
    ctx.clearRect(0, 0, tileW, tileH);
    ctx.drawImage(
      img,
      c * tileW, r * tileH, tileW, tileH,  // source
      0, 0, tileW, tileH                    // dest
    );
    const dataUrl = canvas.toDataURL("image/png");
    // 存起來當下一步的 frame
  }
}

2. 合成拉霸影片(MediaRecorder)

關鍵是 canvas.captureStream(0) + track.requestFrame()

const stream = canvas.captureStream(0);       // 0 = 手動推 frame
const track = stream.getVideoTracks()[0];
const recorder = new MediaRecorder(stream, {
  mimeType: "video/webm;codecs=vp9",
  videoBitsPerSecond: 5_000_000,
});

recorder.start();

for (const tile of frames) {
  drawTile(ctx, tile, size);
  track.requestFrame();                        // 告訴 recorder:新 frame 來了
  await sleep(1000 / fps);
}

recorder.stop();
const blob = new Blob(chunks, { type: "video/webm" });

沒有任何 ffmpeg / wasm 影片庫。瀏覽器原生 API 就能做完。

Frame 順序要小心

每一圈 9 格都洗牌,但如果第 N 圈的最後一格和第 N+1 圈的第一格長一樣,FB 播起來就會有一個「定格卡頓」。

let prev = null;
for (let i = 0; i < repeats; i++) {
  const shuffled = shuffle([...tiles]);
  if (prev !== null && shuffled[0] === prev && shuffled.length > 1) {
    [shuffled[0], shuffled[1]] = [shuffled[1], shuffled[0]];
  }
  frames.push(...shuffled);
  prev = shuffled[shuffled.length - 1];
}

這段 3 行救掉「圈與圈之間卡頓」的視覺 bug。


PWA + Web Share API

加了 manifest.json + sw.js,變成可安裝的 app:

  • 手機 Safari / Chrome → 加到主畫面,有獨立 app icon
  • 觀眾不用記網址,跟別的 app 一樣在 drawer 裡
  • Service Worker 快取 shell,離線也能打開(AI 功能當然還是要連線)

分享用 navigator.share(),能偵測環境自動選支援的分法:

const file = new File([blob], "slot-machine.webm", { type: blob.type });

if (navigator.canShare?.({ files: [file] })) {
  await navigator.share({
    files: [file],
    title: "表情拉霸機",
    text: "點一下影片看你今天的心情 😏",
  });
  return;
}
// 不支援 → 退回下載
alert("請先下載影片再手動上傳到 FB");

iOS 上直接跳系統的分享選單,一鍵丟到 FB / Line / Messages。


自訂 9 格:為什麼要做這個

預設是「9 格全隨機」,對 80% 的用戶夠了。但有人會想要:

  • 「第 5 格(正中間)我要是愛心眼,其他隨便」
  • 「全部都要 嚎啕大哭,但天氣各不同」(做梗)
  • 「第 1 格我要是『看到帥哥兩眼發亮』」(自訂描述)

所以有了一個 settings dialog:

┌─────────────────────────────────────────┐
│        第 1 格                           │
│  [表情:放聲大笑         ▼]             │
│  [天氣:淋雨             ▼]             │
├─────────────────────────────────────────┤
│        第 2 格                           │
│  [表情:🎲 隨機          ▼]             │
│  [天氣:🎲 隨機          ▼]             │
...
  • 表情:36 種 preset + 「自訂描述」(自己打一句話)+ 「隨機」
  • 天氣:9 種 preset + 「強制沒有天氣」+ 「隨機」

設定存在 localStorage,下次打開還在。

對應到 Worker 的 slot model

// 9 格陣列,每一格是:
null                          // 全隨機
{ exprId: 3 }                 // 用 preset id
{ exprCustom: "看到..." }     // 自己打
{ exprId: 3, weatherId: 40 } // 表情 + 天氣
{ weatherNone: true }         // 強制關天氣

Worker 的 buildPrompt() 會把填好的和空的混著用,空的就從剩下的 pool 隨機抽。


省錢小撇步(給其他人抄作業)

AI 功能是我付錢呼叫 Gemini。rate limit 撞到 → 大家就一起炸。

三個解法,依序推薦:

1. 按「複製到 Gemini」(最省)

Settings → 自訂 9 格 → 複製到 Gemini
→ 貼到 gemini.google.com 自己跑
→ 拿到 3×3 圖回來丟進 ① 直接上傳

完全不走我的 Worker,免費、免排隊。

2. 自己 deploy 一份 Worker

cd worker
npx wrangler login
npx wrangler secret put VERTEX_API_KEY
npx wrangler deploy

然後在你的瀏覽器:

localStorage.setItem("slot-api-url", "https://YOUR.workers.dev");

你就有自己的一份,用自己的 quota、自己的錢。

3. 用其他工具產圖(ChatGPT / Flow / Midjourney)

App 的 step ① 可以直接收任何 3×3 圖。只要那張圖三等分切起來每格是獨立的表情就行。


你可以拿這個專案做什麼

拉霸影片 trick 不是只能拿來做表情包:

  • 9 張塔羅牌:同一個問題、9 種答案
  • 9 句幸運話:「今天會遇到前任 / 會中午餐 / 老闆加班…」
  • 9 種星座運勢:生日當禮物很剛好
  • 9 種穿搭推薦:同一個人、9 套造型
  • 9 種餐點:午餐選擇困難症的救星

核心是 FB 自動播 + 點擊即停 = 一支影片 = 一個互動 widget


結語

這個專案對我來說是三件事的交集:

  1. prompt engineering 的血淚:每一條規則背後都有一次「產出看起來很怪」的實際經驗
  2. 純前端能做到的事比想像中多:拆圖 / 錄影 / 分享全在瀏覽器,後端只是個 API Key 保險箱
  3. FB 這種老平台的新玩法:限制反而是 trick 的溫床

Repo 在這裡:https://github.com/yazelin/emoji-slot-machine

覺得好玩的話 → Star repo / Buy me a coffee ☕。實測每 50 個 coffee 大概等於 API 費用打平一個月,讓這支 demo 繼續活著 🙏