iT邦幫忙

0

使用 Angular、Veo 3.1 與 Firebase 雲端函式 建立影片生成管線

  • 分享至 

  • xImage
  •  

在現代網頁應用程式中,將繁重的生成式人工智慧(Generative AI)邏輯從前端卸載到後端是必要的。在我的最新專案中,我重構了一個 Angular 應用程式,使用 Veo 3.1 模型生成高品質影片,並完全透過 Firebase 服務進行管理。

透過將此邏輯移至伺服器端,我們可以保護我們的 API 金鑰,並且可以在不重新部署整個使用者介面的情況下更新模型參數。

先決條件

專案的技術堆疊:

  • Angular 21:截至 2025 年 12 月的最新版本。
  • Node LTS:使用截至 2025 年 12 月的 LTS 版本。
  • Firebase 遠端配置 (Remote Config):用於管理動態參數。
  • Firebase 雲端函式:供前端呼叫以生成影片或在兩張影像之間進行影片內插(interpolation)。
  • Firebase 雲端儲存 (Cloud Storage):在預設的 Firebase 儲存貯體中代管生成的影片檔案。
  • Firebase 雲端函式 模擬器:在 http://localhost:5001 本地測試函式。
  • Gemini in Vertex AI:使用 Vertex AI 中的 Gemini 生成影片並將其儲存於 Firebase 雲端儲存。

公用的 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-adminfirebase-functions 相依性。

完成設定步驟後,Firebase 工具將生成函式模擬器、函式、儲存規則檔案、遠端配置模板,以及如 .firebaserc 和 firebase.json 等設定檔。

  • Angular 相依性
npm i firebase

Angular 應用程式需要 firebase 相依性來初始化 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 整合

1. 設定環境變數

我在 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/

2. 驗證環境變數

在雲端函式繼續執行任何 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 陣列不為空時,函式會拋出一個錯誤,列出所有缺失的變數名稱。如果驗證成功,則會傳回 genAIOptionsaiVideoOptionsgenAIOptions 用於初始化 GoogleGenAI,而 aiVideoOptions 則包含影片生成與內插的參數。

3. 生成影片並儲存至 Firebase Storage

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,
  };
}

4. 非同步輪詢

影片生成與內插都是耗時較長的任務。因為 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)。

5. 影格間的影片內插

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.");
  }
}

6. 儲存安全性規則

為了確保生成的內容得到正確處理,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';
      }
    }
}

7. 在 Storage 中清理影片

即使 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 天以上

8. Firebase 應用程式配置與 reCAPTCHA 網站金鑰

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);
    }
  }
);

9. 使用模擬器進行本地開發

對於本地開發,我使用了 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()),
  ]
};

10. 前端整合 (Angular)

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 分鐘。

資源


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言