Cloud Run 작업 사용 방법 동영상을 처리하는 Video Intelligence API

1. 소개

개요

이 Codelab에서는 동영상의 모든 장면에 대한 시각적 설명을 제공하는 Node.js로 작성된 Cloud Run 작업을 만듭니다. 먼저, 작업에서 Video Intelligence API를 사용하여 장면이 변경될 때마다 타임스탬프를 감지합니다. 다음으로, 작업에서 ffmpeg라는 서드 파티 바이너리를 사용하여 각 장면 변경 타임스탬프의 스크린샷을 캡처합니다. 마지막으로 Vertex AI 시각적 캡션을 사용하여 스크린샷에 대한 시각적 설명을 제공합니다.

또한 이 Codelab은 Cloud Run 작업 내에서 ffmpeg를 사용하여 특정 타임스탬프에 동영상에서 이미지를 캡처하는 방법도 보여줍니다. ffmpeg는 독립적으로 설치해야 하므로 이 Codelab에서는 Dockerfile을 만들어 ffmpeg를 Cloud Run 작업의 일부로 설치하는 방법을 보여줍니다.

다음은 Cloud Run 작업의 작동 방식을 보여주는 그림입니다.

Cloud Run 작업 동영상 설명 이미지

학습할 내용

  • Dockerfile을 사용하여 컨테이너 이미지를 만들어 서드 파티 바이너리를 설치하는 방법
  • 다른 Google Cloud 서비스를 호출하는 Cloud Run 작업의 서비스 계정을 만들어 최소 권한의 원칙을 따르는 방법
  • 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

가상 머신에는 필요한 개발 도구가 모두 들어 있습니다. 영구적인 5GB 홈 디렉터리를 제공하고 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 작업 만들기

먼저 소스 코드용 디렉터리를 만들고 해당 디렉터리로 cd하세요.

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 API, Cloud Run, Vertex AI 시각적 캡션에 관한 문서를 검토하는 것이 좋습니다.

학습한 내용

  • Dockerfile을 사용하여 컨테이너 이미지를 만들어 서드 파티 바이너리를 설치하는 방법
  • 다른 Google Cloud 서비스를 호출하는 Cloud Run 작업의 서비스 계정을 만들어 최소 권한의 원칙을 따르는 방법
  • 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를 실행하면 사용 가능한 모든 프로젝트의 목록을 볼 수 있습니다.