Cloud Run ジョブの使用方法Video Intelligence API で動画を処理する

1. はじめに

概要

この Codelab では、動画内のすべてのシーンを視覚的に説明する Cloud Run ジョブを Node.js で記述します。まず、ジョブで Video Intelligence API を使用して、シーンが変更されるたびにタイムスタンプを検出します。次に、ジョブで ffmpeg というサードパーティ バイナリを使用して、シーン変更のタイムスタンプごとにスクリーンショットをキャプチャします。最後に、Vertex AI の画像キャプションを使用して、スクリーンショットを視覚的に説明します。

この Codelab では、Cloud Run ジョブ内で FFmpeg を使用して、特定のタイムスタンプの動画から画像をキャプチャする方法についても説明します。ffmpeg は個別にインストールする必要があるため、この Codelab では Dockerfile を作成して Cloud Run ジョブの一部として ffmpeg をインストールする方法について説明します。

次の図は、Cloud Run ジョブの仕組みを示しています。

Cloud Run ジョブの動画の説明のイラスト

学習内容

  • Dockerfile を使用してコンテナ イメージを作成し、サードパーティ バイナリをインストールする方法
  • Cloud Run ジョブが他の Google Cloud サービスを呼び出すためのサービス アカウントを作成して、最小権限の原則に従う方法
  • Cloud Run ジョブから Video Intelligence クライアント ライブラリを使用する方法
  • Google API を呼び出して Vertex AI から各シーンの視覚的な説明を取得する方法

2. 設定と要件

前提条件

Cloud Shell をアクティブにする

  1. Cloud Console で、[Cloud Shell をアクティブにする] d1264ca30785e435.png をクリックします。

cb81e7c8e34bc8d.png

Cloud Shell を初めて起動する場合は、内容を説明する中間画面が表示されます。中間画面が表示されたら、[続行] をクリックします。

d95252b003979716.png

Cloud Shell のプロビジョニングと接続に少し時間がかかる程度です。

7833d5e1c5d18f54.png

この仮想マシンには、必要なすべての開発ツールが読み込まれます。5 GB の永続的なホーム ディレクトリが用意されており、Google Cloud で稼働するため、ネットワークのパフォーマンスと認証が大幅に向上しています。この Codelab での作業のほとんどはブラウザを使って行うことができます。

Cloud Shell に接続すると、認証が完了し、プロジェクトに各自のプロジェクト ID が設定されていることがわかります。

  1. Cloud Shell で次のコマンドを実行して、認証されたことを確認します。
gcloud auth list

コマンド出力

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Cloud Shell で次のコマンドを実行して、gcloud コマンドがプロジェクトを認識していることを確認します。
gcloud config list project

コマンド出力

[core]
project = <PROJECT_ID>

上記のようになっていない場合は、次のコマンドで設定できます。

gcloud config set project <PROJECT_ID>

コマンド出力

Updated property [core/project].

3. API を有効にして環境変数を設定する

この Codelab を使用する前に、いくつかの API を有効にする必要があります。この Codelab では、次の API を使用する必要があります。これらの API を有効にするには、次のコマンドを実行します。

gcloud services enable run.googleapis.com \
    storage.googleapis.com \
    cloudbuild.googleapis.com \
    videointelligence.googleapis.com \
    aiplatform.googleapis.com

その後、この Codelab 全体で使用する環境変数を設定します。

REGION=<YOUR-REGION>
PROJECT_ID=<YOUR-PROJECT-ID>
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
JOB_NAME=video-describer-job
BUCKET_ID=$PROJECT_ID-video-describer
SERVICE_ACCOUNT="cloud-run-job-video"
SERVICE_ACCOUNT_ADDRESS=$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com

4. サービス アカウントを作成する

Cloud Storage、Vertex AI、Video Intelligence API へのアクセスに使用する Cloud Run ジョブのサービス アカウントを作成します。

まず、サービス アカウントを作成します。

gcloud iam service-accounts create $SERVICE_ACCOUNT \
  --display-name="Cloud Run Video Scene Image Describer service account"

次に、サービス アカウントに Cloud Storage バケットと Vertex AI API へのアクセス権を付与します。

# to view & download storage bucket objects
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
  --role=roles/storage.objectViewer

# to call the Vertex AI imagetext model
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
  --role=roles/aiplatform.user

5. Cloud Storage バケットを作成する

次のコマンドを使用して、Cloud Run ジョブで処理する動画をアップロードできる Cloud Storage バケットを作成します。

gsutil mb -l us-central1 gs://$BUCKET_ID/

[省略可] このサンプル動画をローカルにダウンロードして使用します。

gsutil cp gs://cloud-samples-data/video/visionapi.mp4 testvideo.mp4

動画ファイルをストレージ バケットにアップロードします。

FILENAME=<YOUR-VIDEO-FILENAME>
gsutil cp $FILENAME gs://$BUCKET_ID

6. Cloud Run ジョブを作成する

まず、ソースコード用のディレクトリを作成し、そのディレクトリに移動します。

mkdir video-describer-job && cd $_

次に、次の内容の package.json ファイルを作成します。

{
  "name": "video-describer-job",
  "version": "1.0.0",
  "private": true,
  "description": "describes the image in every scene for a given video",
  "main": "app.js",
  "author": "Google LLC",
  "license": "Apache-2.0",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "@google-cloud/storage": "^7.7.0",
    "@google-cloud/video-intelligence": "^5.0.1",
    "axios": "^1.6.2",
    "fluent-ffmpeg": "^2.1.2",
    "google-auth-library": "^9.4.1"
  }
}

このアプリは、読みやすくするために複数のソースファイルで構成されています。まず、以下の内容で app.js ソースファイルを作成します。このファイルには、ジョブのエントリ ポイントとアプリのメインロジックが含まれています。

const bucketName = "<YOUR_BUCKET_ID>";
const videoFilename = "<YOUR-VIDEO-FILENAME>";

const { captureImages } = require("./helpers/imageCapture.js");
const { detectSceneChanges } = require("./helpers/sceneDetector.js");
const { getImageCaption } = require("./helpers/imageCaptioning.js");
const storageHelper = require("./helpers/storage.js");
const authHelper = require("./helpers/auth.js");

const fs = require("fs").promises;
const path = require("path");

const main = async () => {

    try {

        // download the file to locally to the Cloud Run Job instance
        let localFilename = await storageHelper.downloadVideoFile(
            bucketName,
            videoFilename
        );

        // PART 1 - Use Video Intelligence API
        // detect all the scenes in the video & save timestamps to an array

        // EXAMPLE OUTPUT
        // Detected scene changes at the following timestamps:
        // [1, 7, 11, 12]
        let timestamps = await detectSceneChanges(localFilename);
        console.log(
            "Detected scene changes at the following timestamps: ",
            timestamps
        );

        // PART 2 - Use ffmpeg via dockerfile install
        // create an image of each scene change
        // and save to a local directory called "output"
        // returns the base filename for the generated images

        // EXAMPLE OUTPUT
        // creating screenshot for scene:  1 at output/video-filename-1.png
        // creating screenshot for scene:  7 at output/video-filename-7.png
        // creating screenshot for scene:  11 at output/video-filename-11.png
        // creating screenshot for scene:  12 at output/video-filename-12.png
        // returns the base filename for the generated images
        let imageBaseName = await captureImages(localFilename, timestamps);

        // PART 3a - get Access Token to call Vertex AI APIs via REST
        // needed for the image captioning
        // since we're calling the Vertex AI APIs directly
        let accessToken = await authHelper.getAccessToken();
        console.log("got an access token");

        // PART 3b - use Image Captioning to describe each scene per screenshot
        // EXAMPLE OUTPUT
        /*
        [
            {
                timestamp: 1,
                description:
                    "an aerial view of a city with a bridge in the background"
            },
            {
                timestamp: 7,
                description:
                    "a man in a blue shirt sits in front of shelves of donuts"
            },
            {
                timestamp: 11,
                description:
                    "a black and white photo of people working in a bakery"
            },
            {
                timestamp: 12,
                description:
                    "a black and white photo of a man and woman working in a bakery"
            }
        ]; */

        // instantiate the data structure for storing the scene description and timestamp
        // e.g. an array of json objects,
        // [{ timestamp: 5, description: "..." }, ...]
        let scenes = [];

        // for each timestamp, send the image to Vertex AI
        console.log("getting Vertex AI description for each timestamps");
        scenes = await Promise.all(
            timestamps.map(async (timestamp) => {
                let filepath = path.join(
                    "./output",
                    imageBaseName + "-" + timestamp + ".png"
                );

                // get the base64 encoded image bc sending via REST
                const encodedFile = await fs.readFile(filepath, "base64");

                // send each screenshot to Vertex AI for description
                let description = await getImageCaption(
                    accessToken,
                    encodedFile
                );

                return { timestamp: timestamp, description: description };
            })
        );

        console.log("finished collecting all the scenes");
        console.log(scenes);
    } catch (error) {
        //return an error
        console.error("received error: ", error);
    }
};

// Start script
main().catch((err) => {
    console.error(err);
});

次に、Dockerfile を作成します。

# Copyright 2020 Google, LLC.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Use the official lightweight Node.js image.
# https://hub.docker.com/_/node
FROM node:20.10.0-slim

# Create and change to the app directory.
WORKDIR /usr/src/app

RUN apt-get update && apt-get install -y ffmpeg

# Copy application dependency manifests to the container image.
# A wildcard is used to ensure both package.json AND package-lock.json are copied.
# Copying this separately prevents re-running npm install on every code change.
COPY package*.json ./

# Install dependencies.
# If you add a package-lock.json speed your build by switching to 'npm ci'.
# RUN npm ci --only=production
RUN npm install --production

# Copy local code to the container image.
COPY . .

# Run the job on container startup.
CMD [ "npm", "start" ]

.dockerignore というファイルを作成して、特定のファイルのコンテナ化を無視します。

Dockerfile
.dockerignore
node_modules
npm-debug.log

次に、helpers という名前のフォルダを作成します。このフォルダには 5 つのヘルパー ファイルが含まれます。

mkdir helpers
cd helpers

次に、次の内容の sceneDetector.js ファイルを作成します。このファイルは、Video Intelligence API を使用して、動画のシーンの変化を検出します。

const fs = require("fs");
const util = require("util");
const readFile = util.promisify(fs.readFile);
const ffmpeg = require("fluent-ffmpeg");

const Video = require("@google-cloud/video-intelligence");
const client = new Video.VideoIntelligenceServiceClient();

module.exports = {
    detectSceneChanges: async function (downloadedFile) {
        // Reads a local video file and converts it to base64
        const file = await readFile(downloadedFile);
        const inputContent = file.toString("base64");

        // setup request for shot change detection
        const videoContext = {
            speechTranscriptionConfig: {
                languageCode: "en-US",
                enableAutomaticPunctuation: true
            }
        };

        const request = {
            inputContent: inputContent,
            features: ["SHOT_CHANGE_DETECTION"]
        };

        // Detects camera shot changes
        const [operation] = await client.annotateVideo(request);
        console.log("Shot (scene) detection in progress...");
        const [operationResult] = await operation.promise();

        // Gets shot changes
        const shotChanges =
            operationResult.annotationResults[0].shotAnnotations;

        console.log(
            "Shot (scene) changes detected: " + shotChanges.length
        );

        // data structure to be returned
        let sceneChanges = [];

        // for the initial scene
        sceneChanges.push(1);

        // if only one scene, keep at 1 second
        if (shotChanges.length === 1) {
            return sceneChanges;
        }

        // get length of video
        const videoLength = await getVideoLength(downloadedFile);

        shotChanges.forEach((shot, shotIndex) => {
            if (shot.endTimeOffset === undefined) {
                shot.endTimeOffset = {};
            }
            if (shot.endTimeOffset.seconds === undefined) {
                shot.endTimeOffset.seconds = 0;
            }
            if (shot.endTimeOffset.nanos === undefined) {
                shot.endTimeOffset.nanos = 0;
            }

            // convert to a number
            let currentTimestampSecond = Number(
                shot.endTimeOffset.seconds
            );

            let sceneChangeTime = 0;
            // double-check no scenes were detected within the last second
            if (currentTimestampSecond + 1 > videoLength) {
                sceneChangeTime = currentTimestampSecond;
            } else {
                // otherwise, for simplicity, just round up to the next second
                sceneChangeTime = currentTimestampSecond + 1;
            }

            sceneChanges.push(sceneChangeTime);
        });

        return sceneChanges;
    }
};

async function getVideoLength(localFile) {
    let getLength = util.promisify(ffmpeg.ffprobe);
    let length = await getLength(localFile);

    console.log("video length: ", length.format.duration);
    return length.format.duration;
}

次に、次の内容のファイルを imageCapture.js という名前で作成します。このファイルは、ノード パッケージ fluent-ffmpeg を使用して、ノードアプリ内から FFmpeg コマンドを実行します。

const ffmpeg = require("fluent-ffmpeg");
const path = require("path");
const util = require("util");

module.exports = {
    captureImages: async function (localFile, scenes) {
        let imageBaseName = path.parse(localFile).name;

        try {
            for (scene of scenes) {
                console.log("creating screenshot for scene: ", +scene);
                await createScreenshot(localFile, imageBaseName, scene);
            }
        } catch (error) {
            console.log("error gathering screenshots: ", error);
        }

        console.log("finished gathering the screenshots");
        return imageBaseName; // return the base filename for each image
    }
};

async function createScreenshot(localFile, imageBaseName, scene) {
    return new Promise((resolve, reject) => {
        ffmpeg(localFile)
            .screenshots({
                timestamps: [scene],
                filename: `${imageBaseName}-${scene}.png`,
                folder: "output",
                size: "320x240"
            })
            .on("error", () => {
                console.log(
                    "Failed to create scene for timestamp: " + scene
                );
                return reject(
                    "Failed to create scene for timestamp: " + scene
                );
            })
            .on("end", () => {
                return resolve();
            });
    });
}

最後に、次の内容のファイルを imageCaptioning.js という名前で作成します。このファイルは、Vertex AI を使用して各シーンの画像の視覚的な説明を取得します。

const axios = require("axios");
const { GoogleAuth } = require("google-auth-library");

const auth = new GoogleAuth({
    scopes: "https://www.googleapis.com/auth/cloud-platform"
});

module.exports = {
    getImageCaption: async function (token, encodedFile) {
        // this example shows you how to call the Vertex REST APIs directly
        // https://cloud.google.com/vertex-ai/generative-ai/docs/image/image-captioning#get-captions-short
        // https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/image-captioning

        let projectId = await auth.getProjectId();

        let config = {
            headers: {
                "Authorization": "Bearer " + token,
                "Content-Type": "application/json; charset=utf-8"
            }
        };

        const json = {
            "instances": [
                {
                    "image": {
                        "bytesBase64Encoded": encodedFile
                    }
                }
            ],
            "parameters": {
                "sampleCount": 1,
                "language": "en"
            }
        };

        let response = await axios.post(
            "https://us-central1-aiplatform.googleapis.com/v1/projects/" +
                projectId +
                "/locations/us-central1/publishers/google/models/imagetext:predict",
            json,
            config
        );

        return response.data.predictions[0];
    }
};

auth.js というファイルを作成します。このファイルは、Google 認証クライアント ライブラリを使用して、Vertex AI エンドポイントを直接呼び出すために必要なアクセス トークンを取得します。

const { GoogleAuth } = require("google-auth-library");

const auth = new GoogleAuth({
    scopes: "https://www.googleapis.com/auth/cloud-platform"
});

module.exports = {
    getAccessToken: async function () {
        return await auth.getAccessToken();
    }
};

最後に、storage.js というファイルを作成します。このファイルは、Cloud Storage クライアント ライブラリを使用して、クラウド ストレージから動画をダウンロードします。

const { Storage } = require("@google-cloud/storage");

module.exports = {
    downloadVideoFile: async function (bucketName, videoFilename) {
        // Creates a client
        const storage = new Storage();

        // keep same name locally
        let localFilename = videoFilename;

        const options = {
            destination: localFilename
        };

        // Download the file
        await storage
            .bucket(bucketName)
            .file(videoFilename)
            .download(options);

        console.log(
            `gs://${bucketName}/${videoFilename} downloaded locally to ${localFilename}.`
        );

        return localFilename;
    }
};

7. Cloud Run ジョブをデプロイして実行する

まず、Codelab のルート ディレクトリ video-describer-job にいることを確認します。

cd .. && pwd

その後、このコマンドを使用して Cloud Run ジョブをデプロイできます。

gcloud run jobs deploy $JOB_NAME  --source . --region $REGION

これで、次のコマンドを実行して Cloud Run ジョブを実行できます。

gcloud run jobs execute $JOB_NAME

ジョブの実行が完了したら、次のコマンドを実行してログの URI へのリンクを取得できます。(または、Cloud コンソールを使用して、Cloud Run ジョブに直接移動してログを表示することもできます)。

gcloud run jobs executions describe <JOB_EXECUTION_ID>

ログに次の出力が表示されます。

[{ timestamp: 1, description: 'what is google cloud vision api ? is written on a white background .'},
{ timestamp: 3, description: 'a woman wearing a google cloud vision api shirt sits at a table'},
{ timestamp: 18, description: 'a person holding a cell phone with the words what is cloud vision api on the bottom' }, ...]

8. 完了

これでこの Codelab は完了です。

Video Intelligence APICloud RunVertex AI の画像キャプションに関するドキュメントを確認することをおすすめします。

学習した内容

  • Dockerfile を使用してコンテナ イメージを作成し、サードパーティ バイナリをインストールする方法
  • Cloud Run ジョブが他の Google Cloud サービスを呼び出すためのサービス アカウントを作成して、最小権限の原則に従う方法
  • Cloud Run ジョブから Video Intelligence クライアント ライブラリを使用する方法
  • Google API を呼び出して Vertex AI から各シーンの視覚的な説明を取得する方法

9. クリーンアップ

不注意による料金が発生しないように(たとえば、この Cloud Run ジョブが無料枠の毎月の Cloud Run 呼び出し割り当てよりも頻繁に呼び出された場合)、Cloud Run ジョブを削除するか、手順 2 で作成したプロジェクトを削除します。

Cloud Run ジョブを削除するには、Cloud Run Cloud コンソール(https://console.cloud.google.com/run/)に移動し、video-describer-job 関数(別の名前を使用している場合は $JOB_NAME)を削除します。

プロジェクト全体を削除する場合は、https://console.cloud.google.com/cloud-resource-manager に移動し、手順 2 で作成したプロジェクトを選択して [削除] を選択します。プロジェクトを削除する場合は、Cloud SDK でプロジェクトを変更する必要があります。使用可能なすべてのプロジェクトのリストを表示するには、gcloud projects list を実行します。