Utiliser les jobs Cloud Run API Video Intelligence pour traiter les vidéos

1. Introduction

Présentation

Dans cet atelier de programmation, vous allez créer un job Cloud Run écrit en Node.js qui fournit une description visuelle de chaque scène d'une vidéo. Tout d'abord, votre job utilisera l'API Video Intelligence pour détecter les codes temporels de chaque changement de scène. Votre job utilisera ensuite un binaire tiers appelé ffmpeg pour capturer une capture d'écran pour chaque code temporel de changement de scène. Enfin, la légende visuelle de Vertex AI est utilisée pour fournir une description visuelle des captures d'écran.

Cet atelier de programmation montre également comment utiliser ffmpeg dans votre job Cloud Run pour capturer des images à partir d'une vidéo à un code temporel donné. Étant donné que ffmpeg doit être installé indépendamment, cet atelier de programmation vous explique comment créer un fichier Dockerfile pour installer ffmpeg dans votre job Cloud Run.

Voici une illustration du fonctionnement du job Cloud Run :

Illustration de la description vidéo d'un job Cloud Run

Points abordés

  • Créer une image de conteneur à l'aide d'un fichier Dockerfile pour installer un binaire tiers
  • Suivre le principe du moindre privilège en créant un compte de service pour que Cloud Run Jobs appelle d'autres services Google Cloud
  • Utiliser la bibliothèque cliente Video Intelligence à partir d'un job Cloud Run
  • Appeler les API Google pour obtenir la description visuelle de chaque scène à partir de Vertex AI

2. Préparation

Prérequis

Activer Cloud Shell

  1. Dans Cloud Console, cliquez sur Activer Cloud Shell d1264ca30785e435.png.

cb81e7c8e34bc8d.png

Si vous démarrez Cloud Shell pour la première fois, un écran intermédiaire s'affiche pour vous expliquer de quoi il s'agit. Si cet écran s'est affiché, cliquez sur Continuer.

d95252b003979716.png

Le provisionnement et la connexion à Cloud Shell ne devraient pas prendre plus de quelques minutes.

7833d5e1c5d18f54.png

Cette machine virtuelle contient tous les outils de développement nécessaires. Elle comprend un répertoire d'accueil persistant de 5 Go et s'exécute sur Google Cloud, ce qui améliore nettement les performances du réseau et l'authentification. Vous pouvez réaliser une grande partie, voire la totalité, des activités de cet atelier de programmation dans un navigateur.

Une fois connecté à Cloud Shell, vous êtes en principe authentifié, et le projet est défini sur votre ID de projet.

  1. Exécutez la commande suivante dans Cloud Shell pour vérifier que vous êtes authentifié :
gcloud auth list

Résultat de la commande

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

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Exécutez la commande suivante dans Cloud Shell pour vérifier que la commande gcloud connaît votre projet :
gcloud config list project

Résultat de la commande

[core]
project = <PROJECT_ID>

Si vous obtenez un résultat différent, exécutez cette commande :

gcloud config set project <PROJECT_ID>

Résultat de la commande

Updated property [core/project].

3. Activer les API et définir les variables d'environnement

Avant de pouvoir commencer à utiliser cet atelier de programmation, vous devez activer plusieurs API. Cet atelier de programmation nécessite l'utilisation des API suivantes. Vous pouvez activer ces API en exécutant la commande suivante :

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

Vous pouvez ensuite définir des variables d'environnement qui seront utilisées tout au long de cet atelier de programmation.

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. Créer un compte de service

Vous allez créer un compte de service que le job Cloud Run utilisera pour accéder à Cloud Storage, Vertex AI et l'API Video Intelligence.

Commencez par créer le compte de service.

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

Accordez ensuite au compte de service l'accès au bucket Cloud Storage et aux API Vertex AI.

# 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. Créer un bucket Cloud Storage

Créez un bucket Cloud Storage dans lequel vous pourrez importer des vidéos à traiter par le job Cloud Run à l'aide de la commande suivante :

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

[Facultatif] Vous pouvez utiliser cet exemple de vidéo en le téléchargeant en local.

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

Importez maintenant votre fichier vidéo dans votre bucket de stockage.

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

6. Créer le job Cloud Run

Commencez par créer un répertoire pour le code source et accédez-y.

mkdir video-describer-job && cd $_

Créez ensuite un fichier package.json avec le contenu suivant :

{
  "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"
  }
}

Cette application se compose de plusieurs fichiers sources pour une meilleure lisibilité. Commencez par créer un fichier source app.js avec le contenu ci-dessous. Ce fichier contient le point d'entrée du job et la logique principale de l'application.

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

Ensuite, créez le 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" ]

Créez un fichier nommé .dockerignore pour ignorer la conteneurisation de certains fichiers.

Dockerfile
.dockerignore
node_modules
npm-debug.log

Créez à présent un dossier appelé helpers. Ce dossier contiendra cinq fichiers d'assistance.

mkdir helpers
cd helpers

Créez ensuite un fichier sceneDetector.js avec le contenu suivant. Ce fichier utilise l'API Video Intelligence pour détecter les changements de scènes dans la vidéo.

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

Créez ensuite un fichier nommé imageCapture.js avec le contenu suivant. Ce fichier utilise le package de nœud fluent-ffmpeg pour exécuter les commandes ffmpeg à partir d'une application de nœud.

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

Enfin, créez un fichier nommé imageCaptioning.js avec le contenu suivant. Ce fichier utilise Vertex AI pour obtenir une description visuelle de chaque image de scène.

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

Créez un fichier appelé auth.js : Ce fichier utilisera la bibliothèque cliente d'authentification Google pour obtenir un jeton d'accès nécessaire à l'appel direct des points de terminaison 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();
    }
};

Enfin, créez un fichier appelé storage.js. Ce fichier utilisera les bibliothèques clientes Cloud Storage pour télécharger une vidéo depuis 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. Déployer et exécuter le job Cloud Run

Tout d'abord, assurez-vous de vous trouver dans le répertoire racine video-describer-job de votre atelier de programmation.

cd .. && pwd

Vous pouvez ensuite utiliser cette commande pour déployer le job Cloud Run.

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

Vous pouvez ensuite exécuter le job Cloud Run en exécutant la commande suivante :

gcloud run jobs execute $JOB_NAME

Une fois l'exécution du job terminée, vous pouvez exécuter la commande suivante pour obtenir un lien vers l'URI du journal. (Vous pouvez également utiliser la console Cloud et accéder directement à Cloud Run Jobs pour afficher les journaux.)

gcloud run jobs executions describe <JOB_EXECUTION_ID>

Vous devriez voir la sortie suivante dans les journaux :

[{ 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. Félicitations !

Bravo ! Vous avez terminé cet atelier de programmation.

Nous vous recommandons de consulter la documentation sur l'API Video Intelligence, Cloud Run et le légendage visuel Vertex AI.

Points abordés

  • Créer une image de conteneur à l'aide d'un fichier Dockerfile pour installer un binaire tiers
  • Suivre le principe du moindre privilège en créant un compte de service pour que Cloud Run Jobs appelle d'autres services Google Cloud
  • Utiliser la bibliothèque cliente Video Intelligence à partir d'un job Cloud Run
  • Appeler les API Google pour obtenir la description visuelle de chaque scène à partir de Vertex AI

9. Effectuer un nettoyage

Pour éviter des frais involontaires (par exemple, si cette tâche Cloud Run est invoquée par inadvertance plus de fois que votre quota mensuel d'invocations Cloud Run dans le niveau sans frais), vous pouvez supprimer la tâche Cloud Run ou le projet que vous avez créé à l'étape 2.

Pour supprimer le job Cloud Run, accédez à la console Cloud Run sur https://console.cloud.google.com/run/, puis supprimez la fonction video-describer-job (ou $JOB_NAME si vous avez utilisé un autre nom).

Si vous choisissez de supprimer l'intégralité du projet, vous pouvez accéder à https://console.cloud.google.com/cloud-resource-manager, sélectionner le projet que vous avez créé à l'étape 2, puis choisir "Supprimer". Si vous supprimez le projet, vous devrez changer de projet dans votre SDK Cloud. Vous pouvez afficher la liste de tous les projets disponibles en exécutant gcloud projects list.