在現代網頁應用程式中,將繁重的生成式人工智慧(Generative AI)邏輯從前端卸載到後端是必要的。在我的最新專案中,我重構了一個 Angular 應用程式,使用 Veo 3.1 模型生成高品質影片,並完全透過 Firebase 服務進行管理。
透過將此邏輯移至伺服器端,我們可以保護我們的 API 金鑰,並且可以在不重新部署整個使用者介面的情況下更新模型參數。
專案的技術堆疊:
http://localhost:5001 本地測試函式。公用的 Google AI Studio API 在我的地區(香港)受限。然而,Vertex AI(Google Cloud)提供企業級存取權限,在當地運作穩定,因此我在本次示範中選擇了 Vertex AI。
npm i -g firebase-tools
使用 npm 全域安裝 firebase-tools。
firebase logout
firebase login
登出 Firebase 並重新登入以執行正確的 Firebase 驗證。
firebase init
執行 firebase init 並依照畫面指示設定 Firebase 雲端函式、Firebase Emulator Suite、Firebase 雲端儲存與 Firebase 遠端配置。
如果您已有專案或有多個專案,可以在命令列中指定專案 ID。
firebase init --project <PROJECT_ID>
在上述兩種情況下,Firebase CLI 都會自動安裝 firebase-admin 與 firebase-functions 相依性。
完成設定步驟後,Firebase 工具將生成函式模擬器、函式、儲存規則檔案、遠端配置模板,以及如 .firebaserc 和 firebase.json 等設定檔。
npm i firebase
Angular 應用程式需要 firebase 相依性來初始化 Firebase 應用程式、載入遠端配置,並呼叫 Firebase 雲端函式以生成影片。
npm i @cfworker/json-schema @google/genai @modelcontextprotocol/sdk
安裝上述相依性以存取 Vertex AI 中的 Gemini。@google/genai 相依於 @cfworker/json-schema 與 @modelcontextprotocol/sdk。如果不安裝它們,雲端函式將無法啟動。

前端應用程式是使用 Angular 建置的。它依賴 Firebase AI Logic 並使用 Gemini 3 Image Pro Preview 模型來生成影像。接著,文字提示詞(prompt)與影像會被提交至 Firebase 雲端函式以生成影片。Firebase AI Logic 不支援影片生成,因此雲端函式會呼叫 Gemini API 與 Veo 3.1 模型來建立影片。此外,Gemini API 允許使用 outputGcsUri 參數,該參數是一個帶有 gs:// 前綴的有效 Google Cloud Storage 路徑。此函式將生成的影片儲存在指定的儲存貯體中並傳回 GCS URI。用戶端將 GCS URI 解析為 HTTP URL,並在 HTML 影片播放器元件中播放該影片。
我在 Firebase 專案中定義環境變數。這可確保函式知道用於儲存與函式代管的區域,以及用於影片生成的 Veo 模型。
.env.example
GOOGLE_CLOUD_LOCATION="us-central1"
GOOGLE_GENAI_USE_VERTEXAI=true
GEMINI_VIDEO_MODEL_NAME="veo-3.1-fast-generate-001"
IS_VEO31_USED="true"
POLLING_PERIOD_MS="10000"
GOOGLE_FUNCTION_LOCATION="us-central1"
WHITELIST="http://localhost:4200"
REFERER="http://localhost:4200/"
| 變數 | 說明 |
|---|---|
| GOOGLE_CLOUD_LOCATION | 儲存貯體的位置。我選擇 us-central1,因為該區域的儲存貯體始終免費。 |
| GOOGLE_GENAI_USE_VERTEXAI | 是否使用 Vertex AI。 |
| GEMINI_VIDEO_MODEL_NAME | Gemini 影片模型的名稱。 |
| IS_VEO31_USED | 是否使用 Veo 3.1。若為 false,則回退(fallback)至生成影片而非內插。 |
| POLLING_PERIOD_MS | 影片操作的輪詢(polling)週期(毫秒)。 |
| GOOGLE_FUNCTION_LOCATION | 雲端函式的區域。我選擇 us-central1,以便讓函式與儲存貯體位在同一區域。 |
| WHITELIST | 請求必須來自 http://localhost:4200 |
| REFERER | 請求源自於 http://localhost:4200/ |
在雲端函式繼續執行任何 AI 呼叫之前,確保所有必要的環境變數都存在至關重要。我實作了一個 validateVideoConfigFields 協助函式(helper function)來檢查是否使用 Veo 3.1、輪詢週期、是否使用 Vertex AI、Vertex AI 位置、模型名稱以及專案 ID。
import logger from "firebase-functions/logger";
export function validate(value: string | undefined, fieldName: string, missingKeys: string[]) {
const err = `${fieldName} is missing.`;
if (!value) {
logger.error(err);
missingKeys.push(fieldName);
return "";
}
return value;
}
import { GenerateVideosParameters, GoogleGenAI } from "@google/genai";
import { validate } from "../validate";
export function validateVideoConfigFields() {
process.loadEnvFile();
const env = process.env;
const isVeo31Used = (env.IS_VEO31_USED || "false") === "true";
const pollingPeriod = Number(env.POLLING_PERIOD_MS || "10000");
const vertexai = (env.GOOGLE_GENAI_USE_VERTEXAI || "false") === "true";
const missingKeys: string[] = [];
const location = validate(env.GOOGLE_CLOUD_LOCATION, "Vertex Location", missingKeys);
const model = validate(env.GEMINI_VIDEO_MODEL_NAME, "Gemini Video Model Name", missingKeys);
const project = validate(env.GOOGLE_CLOUD_QUOTA_PROJECT, "Project ID", missingKeys);
if (missingKeys.length > 0) {
throw new Error(`Missing environment variables: ${missingKeys.join(", ")}`);
}
return {
genAIOptions: {
project,
location,
vertexai,
},
aiVideoOptions: {
model,
storageBucket: `${project}.firebasestorage.app`,
isVeo31Used,
pollingPeriod,
},
};
}
我使用的是 2025 年 12 月的 Node 24。自 Node 20 起,我們可以使用內建的 process.loadEnvFile 函式,從 .env 檔案載入環境變數。
如果您使用的 Node 版本不支援 process.loadEnvfile,替代方案是安裝 dotenv 來載入環境變數。
npm i dotenv
import dotenv from "dotenv";
dotenv.config();
Firebase 提供 GOOGLE_CLOUD_QUOTA_PROJECT 變數,因此它未定義在 .env 檔案中。
當 missingKeys 陣列不為空時,函式會拋出一個錯誤,列出所有缺失的變數名稱。如果驗證成功,則會傳回 genAIOptions 與 aiVideoOptions。genAIOptions 用於初始化 GoogleGenAI,而 aiVideoOptions 則包含影片生成與內插的參數。
generateVideo 雲端函式將酬載(payload)傳遞給 generateVideoFunction 函式。
所有雲端函式都強制執行 App Check、CORS 以及 180 秒的逾時期間。如果未指定 WHITELIST,則 CORS 預設為 true。在示範中這沒問題,但在正式環境中,預設為 false 或特定網域會更安全。
const cors = process.env.WHITELIST ? process.env.WHITELIST.split(",") : true;
const options = {
cors,
enforceAppCheck: true,
timeoutSeconds: 180,
};
export const generateVideo = onCall( options,
({ data }) => generateVideoFunction(data)
);
generateVideoFunction 委派給 generateVideoURL 函式來建構影片參數,並輪詢影片操作直到其完成。最後,它傳回 GCS URI 或拋出錯誤。
import { GoogleGenAI } from "@google/genai";
import { AIVideoBucket, GenerateVideoRequest } from "./types/video.type";
import { generateVideoByPolling, validateVideoConfigFields } from "./video.util";
export async function generateVideoFunction(data: GenerateVideoRequest) {
const variables = validateVideoConfigFields();
if (!variables) {
return "";
}
const { genAIOptions, aiVideoOptions } = variables;
try {
const ai = new GoogleGenAI(genAIOptions);
return await generateVideoURL({ ai, ...aiVideoOptions }, data);
} catch (error) {
console.error("Error generating video:", error);
throw new Error("Error generating video");
}
}
async function generateVideoURL(aiVideo: AIVideoBucket, imageParams: GenerateVideoRequest) {
const args = constructVideoArguments(aiVideo.isVeo31Used, imageParams);
return generateVideoByPolling(aiVideo, args);
}
Veo 3.1 支援解析度(resolution)屬性,可能的值為 1080p 與 720p。為了示範目的,我將解析度硬編碼為 720p。對於 Veo 3 或更舊版本,我省略了解析度。
function constructVideoArguments(isVeo31Used: boolean, imageParams: GenerateVideoRequest) {
const veoConfig = isVeo31Used ? {
aspectRatio: "16:9",
resolution: "720p",
} : {
aspectRatio: "16:9",
};
return {
prompt: imageParams.prompt,
imageBytes: imageParams.imageBytes,
mimeType: imageParams.mimeType,
config: veoConfig,
};
}
影片生成與內插都是耗時較長的任務。因為 Vertex AI 是非同步處理這些任務,函式必須輪詢操作狀態,直到 done 旗標為 true。
Gemini API 無法存取 Firebase Emulator 的 Cloud Storage,因此它需要一個真實的輸出 GCS URI,即 gs://${storageBucket}。
import { GenerateVideosConfig, GoogleGenAI } from "@google/genai";
export type AIVideoBucket = {
ai: GoogleGenAI;
model: string;
storageBucket: string;
isVeo31Used: boolean;
pollingPeriod: number;
}
export type GenerateVideoRequest = {
prompt: string;
imageBytes: string;
mimeType: string;
config?: GenerateVideosConfig;
}
import { AIVideoBucket, GenerateVideoRequest } from "./types/video.type";
import { GenerateVideosParameters, GoogleGenAI } from "@google/genai";
export async function generateVideoByPolling(
{ ai, model, storageBucket, pollingPeriod }: AIVideoBucket,
request: GenerateVideoRequest,
) {
const genVideosParams: GenerateVideosParameters = {
model,
prompt: request.prompt,
config: {
...request.config,
numberOfVideos: 1,
outputGcsUri: `gs://${storageBucket}`,
},
image: {
imageBytes: request.imageBytes,
mimeType: request.mimeType,
},
};
return getVideoUri(ai, genVideosParams, pollingPeriod);
}
當 done 旗標為 true 時,操作結束並會出現以下三種結果之一:
結果 1:error 為 true,影片生成失敗。因此,函式拋出錯誤。
結果 2:影片已儲存在 GCS URI,且函式將其傳回給用戶端應用程式。
結果 3:皆未發生。既無錯誤也無 GCS URI,函式傳回未知錯誤。
async function getVideoUri(
ai: GoogleGenAI,
genVideosParams: GenerateVideosParameters,
pollingPeriod: number,
): Promise<string> {
let operation = await ai.models.generateVideos(genVideosParams);
while (!operation.done) {
await new Promise((resolve) => setTimeout(resolve, pollingPeriod));
operation = await ai.operations.getVideosOperation({ operation });
}
if (operation.error) {
const strError = `Video generation failed: ${operation.error.message}`;
throw new Error(strError);
}
const uri = operation.response?.generatedVideos?.[0]?.video?.uri;
if (uri) {
return uri;
}
const strError = "Video generation finished but no uri was provided.";
throw new Error(strError);
}
注意:為了示範目的,輪詢是處理非同步影片生成的不錯方案。然而,這相當耗費成本,且會產生不必要的負載與延遲。對於正式生產用途,您可能會考慮推播通知(push notification),例如 WebSocket 或伺服器傳送事件(Server-Sent Events)。
Veo 3.1 還引入了影片內插功能,模型使用兩張影像來推論影片中發生的變化。在此模式下,函式會傳送第一張與最後一張影格(frame)。AI 會生成兩者之間的過渡,有效地將序列「動畫化」。
const cors = process.env.WHITELIST ? process.env.WHITELIST.split(",") : true;
const options = {
cors,
enforceAppCheck: true,
timeoutSeconds: 180,
};
export const interpolateVideo = onCall( options,
({ data }) => generateVideoFromFramesFunction(data)
);
import { GenerateVideosConfig } from "@google/genai";
export type GenerateVideoRequest = {
prompt: string;
imageBytes: string;
mimeType: string;
config?: GenerateVideosConfig;
}
export type GenerateVideoFromFramesRequest = GenerateVideoRequest & {
lastFrameImageBytes: string;
lastFrameMimeType: string;
}
import { AIVideoBucket, GenerateVideoFromFramesRequest } from "./types/video.type";
import { GoogleGenAI } from "@google/genai";
import { generateVideoByPolling, validateVideoConfigFields } from "./video.util";
export async function generateVideoFromFramesFunction(data: GenerateVideoFromFramesRequest) {
const variables = validateVideoConfigFields();
if (!variables) {
return "";
}
const { genAIOptions, aiVideoOptions } = variables;
try {
const ai = new GoogleGenAI(genAIOptions);
return await interpolateVideo({ ai, ...aiVideoOptions }, data);
} catch (error) {
console.error("Error generating video:", error);
throw new Error("Error generating video");
}
}
目前僅 Veo 3.1 支援 lastFrame。當 isVeo31Used 為 true 時,資料 URL 與 MIME 類型會提供給 lastFrame。否則,函式將回退至從第一張影像生成影片。
function constructVideoArguments(isVeo31Used: boolean, imageParams: GenerateVideoFromFramesRequest) {
const veoConfig = isVeo31Used ? {
aspectRatio: "16:9",
resolution: "720p",
lastFrame: {
imageBytes: imageParams.lastFrameImageBytes,
mimeType: imageParams.lastFrameMimeType,
},
} : {
aspectRatio: "16:9",
};
return {
prompt: imageParams.prompt,
imageBytes: imageParams.imageBytes,
mimeType: imageParams.mimeType,
config: veoConfig,
};
}
同樣地,interpolateVideo 函式重複使用 generateVideoByPolling 函式來輪詢操作,直到操作完成。函式傳回 GCS URI 或拋出錯誤。
async function interpolateVideo(aiVideo: AIVideoBucket, imageParams: GenerateVideoFromFramesRequest) {
try {
const args = constructVideoArguments(aiVideo.isVeo31Used, imageParams);
return await generateVideoByPolling(aiVideo, args);
} catch (e) {
throw e instanceof Error ?
e :
new Error("An unexpected error occurred in video generation using the first and last frames.");
}
}
為了確保生成的內容得到正確處理,Firebase Storage 規則被設定為僅允許 MP4 檔案。
service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
allow read: if resource.name.matches('.*\\.mp4');
allow write: if request.resource.name.matches('.*\\.mp4')
&& request.resource.contentType == 'video/mp4';
}
}
}
即使 us-central1 區域提供免費層級,影片也應在一段時間後清除,以免儲存貯體無限增長。
我在 Google Cloud Storage 中新增了一條規則,在物件建立 5 天後從 Firebase Storage 儲存貯體中刪除物件。5 天是我隨機選擇的,您可以根據自己的情境選擇任何天數。
Google Cloud Console > Select the Firebase Project > Cloud Storage > Buckets > Select Bucket name > Lifecycle > Rules > Add a rule
| 欄位 | 值 |
|---|---|
| 操作 | 刪除物件 |
| 物件條件 | 建立物件已超過 5 天以上 |
getFirebaseConfig 是一個 Firebase 雲端函式,傳回 Firebase 應用程式配置與 reCAPTCHA 網站金鑰。
const cors = process.env.WHITELIST ? process.env.WHITELIST.split(",") : true;
const whitelist = process.env.WHITELIST?.split(",") || [];
const refererList = process.env.REFERER?.split(",") || [];
export const getFirebaseConfig = onRequest( { cors },
(request, response) => {
if (request.method !== "GET") {
response.status(405).send("Method Not Allowed");
return;
}
try {
const referer = request.header("referer");
const origin = request.header("origin");
if (!referer) {
response.status(401).send("Unauthorized, invalid referer.");
return;
}
if (!refererList.includes(referer)) {
response.status(401).send("Unauthorized, invalid referer.");
return;
}
if (!origin) {
response.status(401).send("Unauthorized, invalid origin.");
return;
}
if (!whitelist.includes(origin)) {
response.status(401).send("Unauthorized, invalid origin.");
return;
}
const config = {
app: {
apiKey: '<Firebase API Key>',
appId: '<Firebase App ID',
projectId: '<Google Cloud Project ID>',
storageBucket: '<Firebase Storage Bucket>
messagingSenderId: '<Firebase Messaging Sender ID>',
authDomain: '<Firebase Auth Domain>',
},
recaptchaSiteKey: '<reCAPTCHA Site Key>',
};
response.set("Cache-Control", "public, max-age=3600, s-maxage=3600");
response.json(config);
} catch (err) {
console.error(err);
response.status(401).send(err);
}
}
);
對於本地開發,我使用了 Firebase Emulator Suite。在 bootstrapFirebase 流程中,應用程式會呼叫 connectFunctionsEmulator 以連結至在 http://localhost:5001 執行的雲端函式。
執行 firebase init 時,埠號預設為 5001。
注意: 雖然雲端函式是在本地執行(零成本),但 Storage 模擬器並未使用。這是因為 Gemini API 需要一個實際可存取的 GCS 儲存貯體來儲存生成的影片。
loadFirebaseConfig 是一個協助函式,向雲端函式發出請求以取得 Firebase 配置與 reCAPTCHA 網站金鑰。
{
"appUrl": "<Firebase cloud function base URL>"
}
import { connectFunctionsEmulator, Functions, getFunctions } from "firebase/functions";
import { fetchAndActivate, getRemoteConfig, getValue, RemoteConfig } from 'firebase/remote-config';
import { FirebaseApp, initializeApp } from 'firebase/app';
import { initializeAppCheck, ReCaptchaEnterpriseProvider } from 'firebase/app-check';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, lastValueFrom, throwError } from 'rxjs';
async function loadFirebaseConfig() {
const httpService = inject(HttpClient);
const firebaseConfig$ =
httpService.get(`${config.appUrl}/getFirebaseConfig`)
.pipe(
catchError((e) => throwError(() => e))
);
return lastValueFrom(firebaseConfig$);
}
export async function bootstrapFirebase() {
try {
const firebaseConfig = await loadFirebaseConfig();
const { app, recaptchaSiteKey } = firebaseConfig;
const firebaseApp = initializeApp(app);
initializeAppCheck(firebaseApp, {
provider: new ReCaptchaEnterpriseProvider(recaptchaSiteKey),
isTokenAutoRefreshEnabled: true,
});
const functions = getFunctions(firebaseApp, 'us-central1');
if (location.hostname === 'localhost') {
connectFunctionsEmulator(functions, 'localhost', 5001);
}
} catch (err) {
console.error(err);
}
}
AppConfig 保持不變。
import { ApplicationConfig, provideAppInitializer } from '@angular/core';
import { bootstrapFirebase } from './app.bootstrap';
export const appConfig: ApplicationConfig = {
providers: [
provideAppInitializer(async () => bootstrapFirebase()),
]
};
Angular 前端使用 httpsCallable 觸發流程。一旦函式傳回 Cloud Storage 路徑,應用程式就會擷取下載 URL 進行播放。
ConfigService 儲存要在整個應用程式中使用的 Firebase 應用程式與函式。
import { Injectable } from '@angular/core';
import { FirebaseApp } from 'firebase/app';
import { Functions } from 'firebase/functions';
@Injectable({
providedIn: 'root'
})
export class ConfigService {
firebaseApp: FirebaseApp | undefined = undefined;
functions: Functions | undefined = undefined;
loadConfig(firebaseApp: FirebaseApp, functions: Functions) {
this.firebaseApp = firebaseApp;
this.functions = functions;
}
}
retrieveVideoUri 方法直接呼叫雲端函式以擷取 GCS URI。
downloadVideoAsUrl 方法將 URI 解析為 HTTP URL,以便 HTML 影片播放器可以立即播放。
import { inject, Injectable } from '@angular/core';
import { httpsCallable } from 'firebase/functions';
import { getDownloadURL, getStorage, ref } from 'firebase/storage';
import { GenerateVideoRequest } from '../types/video.type';
import { ConfigService } from './config.service';
@Injectable({
providedIn: 'root'
})
export class GeminiService {
private readonly storage = getStorage();
private readonly configService = inject(ConfigService);
async retrieveVideoUri(request: GenerateVideoRequest, methodName: string): Promise<string> {
try {
const functions = this.configService.functions;
if (!functions) {
throw new Error('Functions does not exist.');
}
const downloadGcsUri = httpsCallable<GenerateVideoRequest, string>(
functions, methodName
);
const { data: gcsUri } = await downloadGcsUri(request);
return gcsUri;
} catch (err) {
console.error(err);
throw err;
}
}
async downloadVideoAsUrl(request: GenerateVideoRequest, methodName='videos-generateVideo'): Promise<string> {
const gcsUri = await this.retrieveVideoUri(request, methodName);
if (!gcsUri) {
throw new Error('Video operation completed but no URI was returned.');
}
return getDownloadURL(ref(this.storage, gcsUri))
.then((url) => url)
.catch((error) => {
console.error(error);
throw new Error("Unknown error occurred");
});
}
}
VideoPlayerComponent 具有必要的 videoUrl 訊號輸入(signal input),該輸入被分配給影片播放器的來源以播放影片。
@Component({
selector: 'app-video-player',
template: `
<div>
<video [src]="videoUrl()" controls autoplay loop class="w-full rounded-md"></video>
</div>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VideoPlayerComponent {
isGeneratingVideo = input(false);
videoUrl = input.required<string>();
}
本示範的逐步解說至此結束,您應該已經能在雲端函式中生成影片、將其安全地儲存在儲存貯體中,並在使用者介面的影片播放器中播放。
將 Veo 3.1 與 Firebase 的無伺服器擴展性相結合是一個強大的工作流程。
首先,Angular 應用程式既不需要安裝 genai 相依性,也不需要在 .env 檔案中維護 Vertex AI 環境變數。用戶端應用程式呼叫雲端函式來執行密集型任務並等待結果。
雲端函式接收來自用戶端的參數,執行如生成與內插等複雜的 AI 操作,並將影片安全地寫入專屬的儲存貯體中。在本地開發期間,Firebase Emulator 會呼叫位在 http://localhost:5001 的函式,而非 Cloud Run 平台上的已部署函式。
該應用程式可以進一步擴充,探索其他 Veo 3.1 功能,例如擴充影片以及使用參考影像生成新影片。由於這些功能目前僅支援 Python,因此必須初始化一個獨立的 Python 程式碼庫,且不覆寫 TypeScript 函式定義。
擴充影片非常有趣,因為 Veo 生成的影片可以再延長 7 秒,最多可延長 20 次。最終影片的總長度最長可達 148 秒(約 8 秒 + 20 * 7 秒),約 2.5 分鐘。