Analizar videos con Azure Media Services y Azure Functions

¡Cómo echaba de menos mi rincón! Creo que es la vez que más tiempo he estado sin publicar nada, por diversos motivos, pero espero que en las próximas semanas por fin pueda volver a mi normalidad 🙂
Mientras tanto, hoy quería compartir contigo una prueba de concepto en la que he estado trabajando, la cual tiene como objetivo analizar un video, para saber qué tipo de contenido tiene. Para ello, he utilizado Azure Functions, donde implemento la lógica, y Azure Media Services el cual tiene un procesador llamado Video Indexer que me permite obtener un análisis del audio y del video que le pasamos.

La explicación

Toda esta prueba se basa en que el procesador llamado Video Indexer es capaz de pasar nuestro video por diferentes modelos ya entrenados que devuelven diferentes salidas.

Entre toda la información que me devuelve, solamente estoy comprobando si el video que le paso a mi función tiene contenido adulto. Para que puedas seguir todo el proceso, he enumerado los pasos que ido dando en la función principal:

  1. Recuperar el fichero de la llamada: Lo primero que necesitas es recuperar el archivo con el que has invocado a la Azure Function, que está haciendo uso de un HttpTrigger. Para ello, utilizo el provider MultipartMemoryStreamProvider para almacenar en memoria el contenido del archivo. Una vez que tengo este puedo recuperar además el nombre del mismo.
            //1. Get the file from the HTTP Request body
            var provider = new MultipartMemoryStreamProvider();
            await req.Content.ReadAsMultipartAsync(provider);
            var file = provider.Contents.First();
            var fileInfo = file.Headers.ContentDisposition;
            var fileData = await file.ReadAsStreamAsync();

            var fileName = fileInfo.FileName.Replace(@"""", "");

2. Crear una transformación: Para poder trabajar con Azure Media Services lo primero que necesito, además de una cuenta, es crear una transformación. Esta tiene la configuración del trabajo que queremos realizar con este servicio. Puede ser una transcodificación, el análisis de audio y/o video (que es lo que vamos a hacer aquí), transcripción del audio o el reconocimiento de caras. Esta transformación se creará la primera vez y el resto de veces que la Azure Function se ejecute simplemente recuperará la que hemos creado. Toda esta lógica está encapsulada en la función GetOrCreateTransformAsync.

        /// <summary>
        /// Gets or create the tranform
        /// </summary>
        /// <returns></returns>
        private async Task<Transform> GetOrCreateTransformAsync()
        {
            var transform = await _client.Transforms.GetAsync(_settings.ResourceGroup, _settings.AccountName, VideoAnalyzerTransformName);

            if (transform == null) //the transform doesn't exist yet
            {
                //You need to specify what you want it produce as an output
                var output = new TransformOutput[]
                {
                    new TransformOutput
                    {
                           Preset = new VideoAnalyzerPreset(insightsToExtract: InsightsType.VideoInsightsOnly),
                           RelativePriority = Priority.High,

                    }
                };

                transform = await _client.Transforms.CreateOrUpdateAsync(_settings.ResourceGroup, _settings.AccountName, VideoAnalyzerTransformName, output);
            }

            return transform;
        }

3. Asset de entrada: para que Azure Media Services pueda trabajar con nuestro video debemos crear lo que el servicio conoce como Asset. Este objeto se mapea a un contenedor, en la cuenta de almacenamiento asociada a nuestro AMS, en el que podemos incluir los archivos que queremos, en este caso analizar, como es nuestro potencial video. La creación y la subida del archivo recibido se hace en la función CreateInputAssetAsync.

        /// <summary>
        /// Create an asset in the Azure Media Service Account
        /// </summary>
        /// <param name="name"></param>
        /// <param name="fileData"></param>
        /// <returns></returns>
        private async Task<Asset> CreateInputAssetAsync(string name, Stream fileData)
        {
            //Check if the asset exists
            var inputAsset = await _client.Assets.GetAsync(_settings.ResourceGroup, _settings.AccountName, name);
            var assetName = name;

            if (inputAsset != null)
            {
                // Name collision! In order to get the sample to work, let's just go ahead and create a unique asset name
                // Note that the returned Asset can have a different name than the one specified as an input parameter.
                // You may want to update this part to throw an Exception instead, and handle name collisions differently.
                string uniqueness = $"-{Guid.NewGuid():N}";
                assetName += uniqueness;

                _log.LogInformation("Warning – found an existing Asset with name = " + name);
                _log.LogInformation("Creating an Asset with this name instead: " + assetName);
            }

            //Copy the file inside of the asset
            // Call Media Services API to create an Asset.
            // This method creates a container in storage for the Asset.
            // The files (blobs) associated with the asset will be stored in this container.
            var newAsset = await _client.Assets.CreateOrUpdateAsync(_settings.ResourceGroup, _settings.AccountName, assetName, new Asset());

            // Use Media Services API to get back a response that contains
            // SAS URL for the Asset container into which to upload blobs.
            // That is where you would specify read-write permissions 
            // and the exparation time for the SAS URL.
            var response = await _client.Assets.ListContainerSasAsync(
                _settings.ResourceGroup,
                _settings.AccountName,
                assetName,
                permissions: AssetContainerPermission.ReadWrite,
                expiryTime: DateTime.UtcNow.AddHours(4).ToUniversalTime());

            var sasUri = new Uri(response.AssetContainerSasUrls.First());

            var container = new BlobContainerClient(sasUri);
            var blob = container.GetBlobClient(name);

            await blob.UploadAsync(fileData);

            return newAsset;
        }

4. Asset de salida: Por otro lado, necesitamos tener otro asset de salida donde el servicio dejará los archivos generados tras el análisis del video:

        /// <summary>
        /// Create an output asset in th Azure Media Service Account
        /// </summary>
        /// <param name="outputName"></param>
        /// <returns></returns>
        private async Task<Asset> CreateOutputAssetAsync(string outputName)
        {
            //Check if an Asset already exists
            var outputAsset = await _client.Assets.GetAsync(_settings.ResourceGroup, _settings.AccountName, outputName);
            string outputAssetName = outputName;

            if (outputAsset != null)
            {
                // Name collision! In order to get the sample to work, let's just go ahead and create a unique asset name
                // Note that the returned Asset can have a different name than the one specified as an input parameter.
                // You may want to update this part to throw an Exception instead, and handle name collisions differently.
                string uniqueness = $"-{Guid.NewGuid():N}";
                outputAssetName += uniqueness;

                _log.LogInformation("Warning – found an existing Asset with name = " + outputName);
                _log.LogInformation("Creating an Asset with this name instead: " + outputAssetName);
            }

            return await _client.Assets.CreateOrUpdateAsync(_settings.ResourceGroup, _settings.AccountName, outputAssetName, new Asset());
        }

5. Enviar el trabajo a Azure Media Services: ahora que ya tenemos el qué, el cómo y el dónde dejar el resultado ha llegado el momento de lanzar la petición al servicio a través de un trabajo. La lógica, en SubmitJobAsync, es bastante sencilla, ya que podemos decir que el job es el pegamento de todos los componentes generados hasta ahora: la transformación, el asset de entrada y el asset de salida:

        /// <summary>
        /// Submit the job to initialize the process
        /// </summary>
        /// <param name="jobName"></param>
        /// <param name="jobInput"></param>
        /// <param name="outputAssetName"></param>
        /// <returns></returns>
        private async Task<Job> SubmitJobAsync(string jobName, JobInputAsset jobInput, string outputAssetName)
        {
            JobOutput[] jobOutputs = { new JobOutputAsset(outputAssetName) };

            var job = await _client.Jobs.CreateAsync(_settings.ResourceGroup, _settings.AccountName, VideoAnalyzerTransformName, jobName, new Job { Input = jobInput, Outputs = jobOutputs });

            return job;
        }

6. Esperar a que el trabajo termine: si bien en esta prueba de concepto se espera a que el trabajo que acabo de lanzar termine, no se considera una buena práctica gestionar esto de esta forma. Lo ideal sería que una vez lanzado el trabajo esta función terminara y una siguiente estuviera suscrita al evento Job finished que produce Azure Media Services y registra en Event Grid, para no tener que gestionar un proceso de polling en la misma función, lo cual hace que tarde mucho más y que incluso pueda llegar a fallar por ser un proceso de larga duración. Otra opción podría ser utilizar Azure Durable Functions. En cualquier caso, en este escenario se valoraba hacer este análisis de manera síncrona y es por ello que en el método WaitForJobToFinishAsync se espera a que el trabajo finalice.

/// <summary>
        /// Wait for job to finish
        /// </summary>
        /// <param name="jobName"></param>
        /// <returns></returns>
        private async Task<Job> WaitForJobToFinishAsync(string jobName)
        {
            const int SleepIntervalMs = 2000;
            Job job;

            do
            {
                job = await _client.Jobs.GetAsync(_settings.ResourceGroup, _settings.AccountName, VideoAnalyzerTransformName, jobName);
                _log.LogInformation($"Job is '{job.State}'.");

                for (int i = 0; i < job.Outputs.Count; i++)
                {
                    JobOutput output = job.Outputs[i];
                    _log.LogInformation($"\tJobOutput[{i}] is '{output.State}'.");
                    if (output.State == JobState.Processing)
                    {
                        _log.LogInformation($"  Progress: '{output.Progress}'.");
                    }

                    _log.LogInformation(Environment.NewLine);
                }

                if (job.State != JobState.Finished && job.State != JobState.Error && job.State != JobState.Canceled)
                {
                    await Task.Delay(SleepIntervalMs);
                }


            } while (job.State != JobState.Finished && job.State != JobState.Error && job.State != JobState.Canceled);

            return job;
        }

7. Descarga de los archivos generados en el asset de salida: Una vez que el proceso finaliza, podremos comprobar que varios archivos se han generado en el asset de salida:

Archivos de salida de Video Indexer

Para descargar todos ellos he utilizado otro método llamado DownloadOutputAssetAsync:

        /// <summary>
        /// Download the assets into the Ouput folder
        /// </summary>
        /// <param name="outputAssetName"></param>
        /// <returns></returns>
        private async Task DownloadOutputAssetAsync(string outputAssetName)
        {
            if (!Directory.Exists(OutputFolderName))
            {
                Directory.CreateDirectory(OutputFolderName);
            }

            // Use Media Service and Storage APIs to download the output files to a local folder
            AssetContainerSas assetContainerSas = _client.Assets.ListContainerSas(
                            _settings.ResourceGroup,
                            _settings.AccountName,
                            outputAssetName,
                            permissions: AssetContainerPermission.Read,
                            expiryTime: DateTime.UtcNow.AddHours(1).ToUniversalTime()
                            );

            Uri containerSasUrl = new Uri(assetContainerSas.AssetContainerSasUrls.FirstOrDefault());
            BlobContainerClient container = new BlobContainerClient(containerSasUrl);

            string directory = Path.Combine(OutputFolderName, outputAssetName);
            Directory.CreateDirectory(directory);

            _log.LogInformation("Downloading results to {0}.", directory);

            string continuationToken = null;

            do
            {
                var resultSegment = container.GetBlobs().AsPages(continuationToken);

                foreach (Azure.Page<BlobItem> blobPage in resultSegment)
                {
                    foreach (BlobItem blobItem in blobPage.Values)
                    {

                        var blobClient = container.GetBlobClient(blobItem.Name);
                        string filename = Path.Combine(directory, blobItem.Name);
                        await blobClient.DownloadToAsync(filename);
                    }

                    // Get the continuation token and loop until it is empty.
                    continuationToken = blobPage.ContinuationToken;
                }

            } while (continuationToken != "");

            _log.LogInformation("Download complete.");
        }

8. Analizar el archivo insights.json: Para este ejemplo, el objetivo era comprobar si el contenido analizado era para adultos. Por ello, una vez que tengo los archivos JSON, que contienen la información del análisis, cargo en memoria el llamado insights.json y compruebo si existe la propiedad visualContentModeration y, de ser así, reviso las puntuaciones que se han dado en los diferentes fragmentos, utilizando la propiedad adultScore, y si es mayor de 0.5 devuelvo true, haciendo saber que el contenido no es apto para menores.

        /// <summary>
        /// It checks if the content is adult only
        /// </summary>
        /// <param name="outputAssetName"></param>
        /// <returns></returns>
        private bool IsAdultCheck(string outputAssetName)
        {
            var isAdult = false;
            //read the insighs.json
            string insightsJson = File.ReadAllText([email protected]"{OutputFolderName}\{outputAssetName}\insights.json");

            dynamic data = JsonConvert.DeserializeObject(insightsJson);

            if (data.visualContentModeration != null)
            {
                foreach (var item in data.visualContentModeration)
                {
                    _log.LogInformation($"content {item.adultScore}");
                    if (item.adultScore > 0.5)
                    {
                        isAdult = true;
                        break;
                    }
                }
            }

            _log.LogInformation($"Is Adult content? {isAdult}");

            return isAdult;
        }

Cómo probarlo

Antes de ejecutar este código, vas a necesitar crear un archivo local.settings.json, al cual se hace referencia en el archivo Startup.cs que es el archivo de arranque del entorno.

using AMSv3Indexer.Models;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Azure.Management.Media;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Microsoft.Rest.Azure.Authentication;
using System;
using System.IO;

[assembly: FunctionsStartup(typeof(AMSv3Indexer.Startup))]
namespace AMSv3Indexer
{
    public class Startup : FunctionsStartup
    {

        public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
        {
            builder.ConfigurationBuilder
                   .SetBasePath(Directory.GetCurrentDirectory())
                   .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true)
                   .Build();

            base.ConfigureAppConfiguration(builder);
        }

        public override void Configure(IFunctionsHostBuilder builder)
        {
            builder.Services.AddOptions<AMSSettings>()
                            .Configure<IConfiguration>((settings, configuration) =>
                            {
                                configuration.GetSection("AzureMediaServices").Bind(settings);
                            });

            builder.Services.AddSingleton<IAzureMediaServicesClient>(client =>
            {
                var _settings = new AMSSettings();
                builder.GetContext().Configuration.GetSection("AzureMediaServices").Bind(_settings);

                var clientCredentials = new ClientCredential(_settings.AadClientId, _settings.AadSecret);
                var credentials = ApplicationTokenProvider.LoginSilentAsync(_settings.AadTenantId, clientCredentials, ActiveDirectoryServiceSettings.Azure).GetAwaiter().GetResult();

                return new AzureMediaServicesClient(new Uri(_settings.ArmEndpoint), credentials) { SubscriptionId = _settings.SubscriptionId, LongRunningOperationRetryTimeout = 2 };
            });
        }
    }
}

Gracias a este archivo puedo cargar la configuración desde este archivo, además de inyectar la el cliente que necesito para Azure Media Services. Este archivo de configuración debería de tener el siguiente formato:

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet"
  },
  "AzureMediaServices": {
    "AadClientId": "YOUR_CLIENT_ID",
    "AadEndpoint": "https://login.microsoftonline.com",
    "AadSecret": "YOUR_SECRET",
    "AadTenantId": "YOUR_TENANT_ID",
    "AccountName": "YOUR_AMS_NAME",
    "ArmAadAudience": "https://management.core.windows.net/",
    "ArmEndpoint": "https://management.azure.com/",
    "Location": "YOUR_LOCATION",
    "Region": "YOUR_LOCATION",
    "ResourceGroup": "YOUR_RESOURCE_GROUP",
    "Role": {
      "isDefault": true
    },
    "ServicePrincipalName": "YOUR_PRINCIPAL_NAME",
    "SubscriptionId": "YOUR_SUBSCRIPTION_ID"
  }
}

Para recuperar la información que ves en el apartado AzureMediaServices puedes lanzar este comando:

az ams account sp create --account-name YOUR_AMS_NAME--resource-group YOUR_RESOURCE_GROUP

Para probar este código, puedes utilizar Postman y, a través de un método POST, añadir como parte del cuerpo, seleccionando form-data, un video:

Probando con Postman la función isAdult

El código completo lo tienes en mi GitHub.

¡Saludos!