Category Archives: ASP.NET MVC

Azure Media Indexer で動画の字幕検索アプリを作ってみる

この記事は、Azure Advent Calendar 2014 と ASP.NET Advent Calendar 2014 の両方の 13 日目 の記事です。

Azure Media Indexer と Azure Search を使って、動画に字幕を表示し、検索した字幕データの時間から再生できる ASP.NET MVC アプリを作成してみます。

  • Azure Media Indexer ・・・ 動画を分析し、音声をテキスト化できるサービスです。字幕を表示できるだけでなく、テキスト化された音声データを検索することで、「あの話をしたのは、いつ頃だったか?」を探すことができます。
  • Azure Search ・・・ Search as a Services なサービスで、Azure 版の Elasticsearch です。

動画の題材は、先日行われたオンラインイベントの Connect() から、Scott Hanselman のセッションを使います。

動画の音声をテキストデータに変換するアプリを作成する

Azure Media Indexer に動画をアップロードして、音声をテキストデータに変換するコンソールアプリを作成します。Azure Media Services の 拡張 SDK が必要なので、NuGet からインストールします。

  • Install-Package WindowsAzure.MediaServices.Extensions

CloudMediaContext を使って、動画ファイルをアップロード→音声をテキストデータに変換→作成された字幕ファイルをダウンロードという一連の処理を行います。

class Program
{
    private static readonly string _accountName = "xxx";
    private static readonly string _accountKey = "xxx";
    private static readonly string _uploadFile = Environment.GetEnvironmentVariable("USERPROFILE") + @"\Desktop\VSConnectScottHanselman.mp4";
    private static readonly string _downloadFile = Environment.GetEnvironmentVariable("USERPROFILE") + @"\Desktop\";
    private static CloudMediaContext _context = null;

    static void Main(string[] args)
    {
        var totalSw = new Stopwatch();
        totalSw.Start();

        _context = new CloudMediaContext(_accountName, _accountKey);

        Console.WriteLine("アップロード");
        var asset = _context.Assets.CreateFromFile(_uploadFile, AssetCreationOptions.None,
            (a, p) =>
            {
                Console.WriteLine("Uploading: {0}%", p.Progress);
            });

        Console.WriteLine("ジョブ実行");
        var configuration = File.ReadAllText("indexing.config");
        var job = _context.Jobs.CreateWithSingleTask(
            "Azure Media Indexer", configuration, asset, asset.Name + "-Indexed", AssetCreationOptions.None);
        job.Submit();
        job = job.StartExecutionProgressTask(j =>
            {
                Console.WriteLine("State: {0}", j.State);
                Console.WriteLine("Executing: {0:0.##}%", j.GetOverallProgress());
             },
            CancellationToken.None).Result;
        var outputAsset = job.OutputMediaAssets.FirstOrDefault();

        Console.WriteLine("動画配信ポイント作成");
        _context.Locators.CreateLocator(
            LocatorType.Sas,
            asset,
            _context.AccessPolicies.Create("Streaming Access Policy", TimeSpan.FromDays(30), AccessPermissions.Read)
            );

        Console.WriteLine("ファイルのダウンロード");
        foreach (IAssetFile file in outputAsset.AssetFiles)
        {
            Console.WriteLine("Downloading: {0}", file.Name);
            file.Download(_downloadFile + file.Name);
        }

        totalSw.Stop();
        Console.WriteLine(String.Format("Completed: {0}", totalSw.Elapsed.ToString()));
        Console.ReadLine();
    }
}
<?xml version="1.0" encoding="utf-8" ?>
<configuration version="2.0">
  <input>
    <metadata key="title" value="Cloud development with Azure and Visual Studio" />
    <metadata key="description" value="Learn how to extend your applications with Azure and ASP.NET vNext to take advantage of the cloud." />
  </input>
  <settings>
  </settings>
</configuration>

_accountName と _accountKey には、Azure の管理ポータルで作成した Azure Media Services の情報を設定してください。indexing.config は、Azure Media Indexer の設定ファイルです。

46分(130MB)の MP4 ファイルで試しましたが、処理が完了するまでに1時間ほどかかりました。すべての処理が完了すると、4つのファイルがダウンロードされます。

  • 字幕ファイル (SAMI 形式)
  • 字幕ファイル (TTML: Timed Text Markup Language 形式)
  • キーワード ファイル (XML)
  • SQL Server で使用するオーディオ インデックス処理 BLOB ファイル (AIB)

TTML ファイルは、HTML の Video タグと Track タグでそのまま使えるので、今回はこのファイルだけを使います。

ttml

字幕データをアップロードするアプリを作成する

Azure Search にTTML ファイルのデータをアップロードするコンソールアプリを作成します。REST API が公開されているのですが、RedDog.Search が便利なので、NuGet からインストールします。

  • Install-Package RedDog.Search

インデックスを作成し、TTML ファイルから抽出したデータをアップロードします。

public class Caption
{
    public string Id { get; set; }
    public string BeginTime { get; set; }
    public string EndTime { get; set; }
    public string Text { get; set; }
}

class Program
{
    private static readonly string _serviceName = "xxx";
    private static readonly string _apiKey = "xxx";
    private static readonly string _indexName = "connect";
    private static readonly string _ttmlFile = Environment.GetEnvironmentVariable("USERPROFILE") + @"\Desktop\VSConnectScottHanselman.mp4.ttml";

    static void Main(string[] args)
    {
        // インデックスの作成
        var connection = ApiConnection.Create(_serviceName, _apiKey);
        var client = new IndexManagementClient(connection);
        var response = client.CreateIndexAsync(new Index(_indexName)
                            .WithStringField("id", f => f.IsKey().IsRetrievable())
                            .WithStringField("beginTime", f => f.IsRetrievable().IsSortable())
                            .WithStringField("endTime", f => f.IsRetrievable().IsSortable())
                            .WithStringField("text", f => f.IsSearchable().IsRetrievable())
                        ).Result;
        if (!response.IsSuccess)
        {
            Console.WriteLine("{0}:{1}", response.Error.Code, response.Error.Message);
            return;
        }

        // データのアップロード
        var file = XDocument.Load(_ttmlFile);
        XNamespace ttmlns = "http://www.w3.org/ns/ttml";
        var targets = file.Descendants(ttmlns + "p")
                            .Select(c => new Caption
                            {
                                BeginTime = c.FirstAttribute.Value,
                                EndTime = c.LastAttribute.Value,
                                Text = c.Value
                            });
        foreach (var target in targets.Select((v, i) => new { value = v, index = i }))
        {
            var response2 = client.PopulateAsync(_indexName,
                    new IndexOperation(IndexOperationType.Upload, "id", (target.index + 1).ToString())
                        .WithProperty("text", target.value.Text)
                        .WithProperty("beginTime", target.value.BeginTime)
                        .WithProperty("endTime", target.value.EndTime)
                ).Result;
            if (!response2.IsSuccess)
            {
                Console.WriteLine("{0}:{1}", response2.Error.Code, response2.Error.Message);
                return;
            }
        }
    }
}

インデックスのフィールドは、字幕テキストを検索対象とし、開始時間と終了時間をソート対象としています。_serviceName と _apiKey には、Azure のプレビューポータルで作成した Azure Search の情報を設定してください。

字幕を検索するアプリを作成する

ようやく本題です。動画の字幕表示と検索ができる ASP.NET MVC の Web アプリを作成します。まず、このプロジェクトに TTML ファイルをドラッグアンドドロップし、配置しておきます。前述と同様に RedDog.Search が便利なので、NuGet からインストールします。

Model と Controller を実装します。Ajax 通信で呼ばれる Search メソッドでは、指定されたキーワードで検索した結果を部分ビューとして返却します。

public class Caption
{
    public string Id { get; set; }
    public string BeginTime { get; set; }
    public string EndTime { get; set; }
    public string Text { get; set; }
}
public class CaptionController : Controller
{
    private static readonly string _serviceName = "xxx";
    private static readonly string _apiKey = "xxx";
    private static readonly string _indexName = "connect";

    public ActionResult Index()
    {
        return View();
    }

    public async Task<ActionResult> Search(string keyword)
    {
        if (Request.IsAjaxRequest())
        {
            var connection = ApiConnection.Create(_serviceName, _apiKey);
            var client = new IndexQueryClient(connection);

            var response = await client.SearchAsync(_indexName, new SearchQuery(keyword)
                .SearchField("text")
                .OrderBy("beginTime asc")
                .Count(true));
            if (!response.IsSuccess)
            {
                throw new Exception(String.Format("{0}:{1}", response.Error.Code, response.Error.Message));
            }

            var model = response.Body.Records
                .Select(c => new Caption
                {
                    Id = c.Properties["id"] as string,
                    BeginTime = c.Properties["beginTime"] as string,
                    EndTime = c.Properties["endTime"] as string,
                    Text = c.Properties["text"] as string
                });
            ViewBag.Count = response.Body.Count;
            return PartialView("_SearchResult", model);
        }
        return Content("Ajax 通信以外のアクセスはできません。");
    }
}

View を実装します。HTML の Video タグと Track タグを利用します。Video タグの src には、管理ポータルから取得できる Media Services コンテンツの Publish URL を設定します。検索ボタンが押されたら、Jquery で Search アクションメソッドを呼び出して、結果を表示します。

@{
    ViewBag.Title = "Index";
}

<div class="row">
    <h2></h2>
    <div>
        <video id="videoPlayer" controls="controls" autoplay="autoplay" width="500"
               src="https://xxx.blob.core.windows.net/xxx">
            <track id="trackEN" src="~/VSConnectScottHanselman.mp4.ttml" kind="captions" label="English" default />
        </video>
    </div>
    <hr />
    <form class="form-horizontal">
        <div class="form-group">
            @Html.Label("キーワード:", new { @for = "keyword", @class = "control-label col-md-2" })
            <div class="col-md-2">
                @Html.TextBox("keyword", "", new { @class = "form-control" })
            </div>
            <div class="col-md-8">
                @Html.TextBox("search", "検索", new { type = "button", @class = "btn btn-primary" })
            </div>
        </div>
    </form>
    <div id="result"></div>
</div>

@section scripts
{
    <script>
         $(function () {
            $('#search').click(function () {
                $('#result').load('/Caption/Search', { keyword: $('#keyword').val() });
            })
        })
    </script>
}

部分 View を実装します。Azure Search からの検索結果をバインドします。再生ボタンを押した際に、Video タグの currentTime に開始時間をセットして再生しています。

@model IEnumerable<MediaIndexerViewer.Models.Caption>

<script type="text/javascript">
    function setCurrentTime(currentTime) {
        var video = $('#videoPlayer').get(0);
        video.currentTime = currentTime;
        video.play();
    }
</script>

<p>検索結果:@ViewBag.Count</p>
<table class="table table-striped">
    <tr>
        <th>
            @Html.DisplayNameFor(model => model.BeginTime)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.EndTime)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.Text)
        </th>
        <th></th>
    </tr>

    @foreach (var item in Model)
    {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.BeginTime)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.EndTime)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Text)
            </td>
            <td>
                @Html.TextBox("play", "再生", new { type = "button", onclick = "setCurrentTime(" + TimeSpan.Parse(item.BeginTime).TotalSeconds + ")", @class = "btn btn-success" })
            </td>
        </tr>
    }
</table>

最後に、Web.config で TTML の MIME 設定を行います。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <staticContent>
        <remove fileExtension=".ttml" />
        <mimeMap fileExtension=".ttml" mimeType="text/xml" />
    </staticContent>
</configuration>

結果確認

字幕検索 Web アプリを実行すると、Scott Hanselman が話しているタイミングと同時に字幕が表示されます。

indexer01

例えば、Xamarin の CTO である Miguel が登場したシーンを探すとしたら、「Miguel」で検索して再生ボタンを押せば、頭出し再生されます。

indexer02

まとめ

Azure Media Indexer を知ったきっかけは、MSC 2014 のキーノートの西脇さんのデモでした。こんなことが簡単にできる時代になったことに驚いたのと、うまく活用すれば面白いことができそうだと思いました。現時点では英語のみで日本語には対応していません。このサービスを利用して、「Skype Translator」という Skype でのビデオ会話をリアルタイムで通訳する取り組みが進められているそうです。Azure Search を使う必要あるの?というツッコミがありそうですが、Azure Search を使ってみたかっただけなんです(キッパリ。TTML ファイルと一緒に AIB ファイルが作成されるので、SQL Server のフルテキスト検索も可能です。

最後に、今回のアプリを作成するにあたり、こちらのブログを参考にさせて頂きました。