GeminiVertex AIYouTubeVideo AIAPIBackend

把 YouTube 連結直接餵給 Gemini:一個 API 呼叫,和文件不會告訴你的四件事

·閱讀約 7 分鐘

需求很單純:用戶貼一個 YouTube 連結,App 回一組重點卡片——影片在講什麼、分成哪幾個重點、每個重點在影片的第幾分鐘。

直覺的技術路線是三段式:抓字幕 → 餵給 LLM → 整理輸出。但抓字幕這一段本身就是個沼澤:不是每支影片有字幕、自動字幕品質不穩、抓取手段又容易被平台防禦盯上。

後來發現根本不用。Gemini 的 generateContent 有一個很少人注意的能力:fileData 欄位可以直接放 YouTube URL。

整個核心就是一個請求

javascript
const res = await fetch(vertexUrl(env, model, 'generateContent'), {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    system_instruction: { parts: [{ text: systemPrompt }] },
    contents: [{
      role: 'user',
      parts: [
        { fileData: { fileUri: videoUrl, mimeType: 'video/*' } },  // ← 就是這行
        { text: userPrompt },
      ],
    }],
    generationConfig: {
      temperature: 0.3,
      maxOutputTokens: 4096,
      responseMimeType: 'application/json',
      mediaResolution: 'MEDIA_RESOLUTION_LOW',
    },
  }),
});

fileUri 直接放 YouTube 網址,mimeType 給 video/*,模型就會自己去「看」那支影片——畫面和聲音都看,不依賴字幕。沒有下載、沒有轉檔、沒有字幕抓取,整條管線就是一個 HTTP 請求。

跑通大概花十分鐘。接下來的四件事,是從跑通到敢給用戶用之間,實際踩出來的。

第一件事:有一整類影片會 403,而且錯誤訊息很難懂

測試階段一切順利,直到我丟了一支音樂 MV 進去:Vertex 回 403,錯誤訊息裡有一句「not owned」。

實測下來規律很清楚:受版權保護(Content ID)的影片——音樂 MV 是大宗——一律 403;演講、教學、Podcast、技術分享類,全數通過。

這件事必須在產品層處理,因為「403 not owned」對用戶來說是天書。我的做法是把這個特定錯誤轉成一句人話:

javascript
if (res.status === 403 && errText.includes('not owned')) {
  throw new Error('VIDEO_COPYRIGHT');
}
// 上層轉成給用戶看的訊息:
// 「此影片受版權保護,無法解析(音樂 MV 等)。
//   演講、教學類影片沒有此限制」

注意判斷條件是「403 且訊息含 not owned」——單看 403 不夠,權限設錯也是 403。把兩種混為一談,你會把自己的設定問題報告成「影片有版權」。

第二件事:mediaResolution 是成本的主開關

影片輸入的 token 消耗和「模型看得多細」直接相關。generationConfig 裡的 mediaResolution 控制每一幀影格用多少 token 表示,預設解析度下,一支演講影片的 prompt token 會相當可觀。

我的用例是「聽內容」——演講、教學的資訊密度在語音軌,畫面通常是投影片或講者。把 mediaResolution 設成 MEDIA_RESOLUTION_LOW 之後,實測輸出品質沒有可感差異:重點還是那些重點,timestamp 還是準的。

但成本差距是倍數級的。如果你的用例是「看畫面」——UI 操作教學、視覺分析——那另當別論;如果是聽內容,LOW 幾乎是白撿的折扣。這個欄位很容易被忽略,因為不設它一切也「正常」,只是帳單不正常。

順帶一提成本的量測:回應的 usageMetadata 有 promptTokenCount 和 candidatesTokenCount,把兩個加起來記進 log,你才知道每支影片實際花多少。我們同時把這個功能計入用戶的每日 AI 配額——影片是目前最貴的輸入型態,不設額度等於開放稻草人攻擊自己的帳單。

第三件事:模型回的 JSON,一個欄位都不能直接信

請求裡設了 responseMimeType: application/json,模型大多數時候會回合法的 JSON。「大多數時候」在生產環境等於「會出事」。

實際遇過的變體:整段回傳不是 JSON(直接 parse 失敗)、欄位該是字串卻回了陣列、cards 裡混進 null、points 該是陣列卻回成一整段字串。每一種都能讓天真的前端直接白屏。

所以解析層是「防禦性正規化」,對每個欄位逐一收斂型別,任何缺失都有預設值:

javascript
let data;
try {
  data = JSON.parse(result.text);
} catch {
  data = { title: 'YouTube 影片', tldr: '', cards: [], tags: [] };
}

// 每個欄位逐一正規化:字串就是字串,陣列就是陣列,null 一律清掉
const asStr = (v) => Array.isArray(v) ? v.filter(x => x != null).join('\n')
  : v == null || typeof v === 'string' ? v : String(v);

data.cards = Array.isArray(data.cards)
  ? data.cards.filter(c => c && typeof c === 'object').map(c => ({
      heading: asStr(c.heading) || '',
      points: Array.isArray(c.points) ? c.points.filter(p => p != null).map(String) : [],
      timestamp: asStr(c.timestamp) || '',
    }))
  : [];

原則:LLM 的輸出永遠當外部輸入處理。responseMimeType 提高的是機率,不是保證;schema 的最後一道防線必須在你自己的程式裡。

第四件事:timestamp 不會自己出現,要在 prompt 裡要

重點卡片要能點回影片的對應時間點,這是體驗的關鍵。模型看得懂影片的時間軸,但你不明確要求,它就不會給——或者給了但格式每次不一樣(「三分二十秒」「3:20」「約 200 秒處」)。

解法單純但必要:在輸出 schema 裡明確定義格式。

text
"cards": [
  {"heading": "重點標題", "points": ["要點1", "要點2"],
   "timestamp": "mm:ss 該重點開始的時間"}
]
卡片數 3~7 張,依影片長度;每個要點具體、有資訊量,不要空話。

把格式範例直接寫進 schema 描述(mm:ss),比在指示裡另外解釋有效得多。同一個 prompt 也順便控制卡片數量的上下限——不綁範圍,短影片會被硬擠出七張水卡,長影片會被壓成三張。

整體架構的位置

最後一個決定跟 API 無關,但同樣重要:這個呼叫放在哪裡。

答案是 server 端的 gateway,不是 App 內。三個理由:API 金鑰不落地(App 內的金鑰等於公開)、配額和權限在 server 統一管(誰是付費用戶、今天用了幾次,依 JWT 判斷而不是信 client)、模型和 prompt 可以隨時換而不用發版。

App 端只看到一個乾淨的介面:POST 一個 YouTube URL,拿回一組結構化卡片,或一句看得懂的錯誤。所有這篇講的髒活,都留在 gateway 裡。

如果你要做同一件事

一、先確認你的影片類型過得了:拿目標類型的真實影片測,音樂/影視內容會 403,不要等上線才發現。

二、成本先量再上:usageMetadata 記 log,mediaResolution 依用例選,配額從第一天就設。

三、解析層當作在接一個不可信的外部 API 寫:JSON parse 有 fallback,每個欄位正規化。

四、輸出格式(含 timestamp)在 prompt 的 schema 裡寫死,不要靠模型自由發揮。

五、呼叫放 server 端,金鑰、配額、prompt 三件事都會感謝你。

這個功能所在的產品:KOFNote

參考資料