🦊

AWS でのビデオオンデマンド実装ソリューションをやってみる

2022/12/22に公開

はじめに

お客様に、動画マニュアルの社内共有サービスを作れないかと相談されたので、会員向けの動画配信サービスの構築方法を検討することになりました。
ちょうどAWSでサーバレスで、VODを構築する記事があったので、これをAWS CDKをつかって構築してみます。

構成

最終的に構築する構成は以下の通りです。

①:ソース動画ファイルを保存するS3バケット

②:AWS Elemental MediaConvert でエンコーディングジョブを作成するための AWS Lambda 関数

③:MediaConvert は動画を HLS Adaptive Bitrate ファイルにトランスコード

④:MediaConvert のエンコーディングジョブを追跡し、Lambda ジョブ完了関数をコール

⑤:出力を処理する Lambda ジョブ完了関数

⑥:完了したジョブを通知

⑦:変換後の動画を保存するS3バケット

⑧:動画を配信するCDN

構築

なお、結論からいうと、元記事からたどれるAWSソリューションライブラリにAWSが作成したGitHubリポジトリへのリンクが張ってあり、そちらに、CDKのソースコード自体が確認できます。

なので、今回は、お手本のGitHubのコードをベースに、内容の理解しつつ、自分に合わない部分を修正しています。最終的な実装結果は、こちらに公開しています。

お手本と変えている箇所は以下の通りです。

MediaConvertのエンドポイントの取得方法を変更

お手本では、CloudFormationのカスタムリソースを使用してエンドポイントを取得しています。
MediaConvertのエンドポイントは、AWSのアカウント事に固有の値になるそうです。
お手本では、最終的にCloudFormationテンプレートを様々な人に利用してもらうことを想定しているので、CloudFormationテンプレートから構築する際に、その構築者のアカウント毎のエンドポイントをカスタムリソースで取得する必要があるのはわかりますが、今回は、CDKから直接構築することを想定しているので、CDKからCloudFormationテンプレート構築時に、エンドポイントを取得するように変更しています(そのため、このCDKで作成したCloudFormationテンプレートを別のAWSアカウントで利用することはできません)

S3パケットからのイベント通知方法を変更

お手本では、S3のイベント通知を使用して、Lambda関数を起動しています。
S3のイベント通知もカスタムリソースで作成する必要があるようです。正直カスタムリソースはめんどくさいので、EventBridgeを使う方法に変更しました。

Lambda関数をTypeScriptで作成

お手本では、JavaScriptで作成されていますが、TypeScriptで書くようにしました。

いくつかの機能を無効化

  • お手本では、S3に、MediaConvertのジョブ設定を保存しておき、それを使ってMediaConvertのジョブを生成していますが、ジョブの設定を変更できなくてよいかなと思い、Lambda内に、固定値として設定するように変更しました
  • お手本では、MediaConvertで変更した履歴を、S3に残していますが、これもめんどくさいの削除しました(実際のユースケースではDynamoDBなどに、残した方がよさそう)

ソースコード解説

全体

全体はこんな感じです。libフォルダ内に、CloudFormationスタックの定義と、2つのLambdaの関数を一つのファイルで作成しています。

aws-vod-cdk-stack.ts

CloudFormationのスタックの定義です。

import { CloudFrontToS3 } from "@aws-solutions-constructs/aws-cloudfront-s3"
import { LambdaToSns } from "@aws-solutions-constructs/aws-lambda-sns"
import { Aws, Stack, StackProps } from "aws-cdk-lib"
import { EventField, Rule, RuleTargetInput } from "aws-cdk-lib/aws-events"
import { LambdaFunction } from "aws-cdk-lib/aws-events-targets"
import { Policy, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam"
import { Runtime } from "aws-cdk-lib/aws-lambda"
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"
import { BlockPublicAccess, Bucket, BucketEncryption, HttpMethods } from "aws-cdk-lib/aws-s3"
import { Topic } from "aws-cdk-lib/aws-sns"
import { EmailSubscription } from "aws-cdk-lib/aws-sns-subscriptions"

import { Construct } from "constructs"
import * as path from "path"

const SUPPORT_MOVIE_SUFFIX = [
  ".mpg",
  ".mp4",
  ".m4v",
  ".mov",
  ".m2ts",
  ".wmv",
  ".mxf",
  ".mkv",
  ".m3u8",
  ".mpeg",
  ".webm",
  ".h264",
]

interface AwsVodCdkStackProps extends StackProps {
  mediaConvertEndpoint: string
  adminEmail: string
}

export class AwsVodCdkStack extends Stack {
  constructor(scope: Construct, id: string, props: AwsVodCdkStackProps) {
    super(scope, id, props)

    const notificationTopic = new Topic(this, "Topic", {
      topicName: `${this.stackName}-Topic`,
    })

    notificationTopic.addSubscription(new EmailSubscription(props.adminEmail))

    /**
     * Logs bucket for S3 and CloudFront
     */
    const logsBucket = new Bucket(this, "Logs", {
      encryption: BucketEncryption.S3_MANAGED,
      publicReadAccess: false,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
    })

    /**
     * Source S3 bucket to host source videos and jobSettings JSON files
     */
    const source = new Bucket(this, "Source", {
      serverAccessLogsBucket: logsBucket,
      serverAccessLogsPrefix: "source-bucket-logs/",
      encryption: BucketEncryption.S3_MANAGED,
      publicReadAccess: false,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      eventBridgeEnabled: true,
    })

    /**
     * Destination S3 bucket to host the mediaconvert outputs
     */
    const destination = new Bucket(this, "Destination", {
      serverAccessLogsBucket: logsBucket,
      serverAccessLogsPrefix: "destination-bucket-logs/",
      encryption: BucketEncryption.S3_MANAGED,
      publicReadAccess: false,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      cors: [
        {
          maxAge: 3000,
          allowedOrigins: ["*"],
          allowedHeaders: ["*"],
          allowedMethods: [HttpMethods.GET],
        },
      ],
    })

    /**
     * Solutions construct to create Cloudfront with a s3 bucket as the origin
     * https://docs.aws.amazon.com/solutions/latest/constructs/aws-cloudfront-s3.html
     * insertHttpSecurityHeaders is set to false as this requires the deployment to be in us-east-1
     */
    const cloudFront = new CloudFrontToS3(this, "CloudFront", {
      existingBucketObj: destination,
      insertHttpSecurityHeaders: false,
      cloudFrontDistributionProps: {
        defaultCacheBehavior: {
          allowedMethods: ["GET", "HEAD", "OPTIONS"],
          Compress: false,
          forwardedValues: {
            queryString: false,
            headers: ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"],
            cookies: { forward: "none" },
          },
          viewerProtocolPolicy: "allow-all",
        },
        loggingConfig: {
          bucket: logsBucket,
          prefix: "cloudfront-logs",
        },
      },
    })

    /**
     * MediaConvert Service Role to grant Mediaconvert Access to the source and Destination Bucket,
     * API invoke * is also required for the services.
     */
    const mediaConvertRole = new Role(this, "MediaConvertRole", {
      assumedBy: new ServicePrincipal("mediaconvert.amazonaws.com"),
    })

    const mediaConvertPolicy = new Policy(this, "MediaConvertPolicy", {
      statements: [
        new PolicyStatement({
          resources: [`${source.bucketArn}/*`, `${destination.bucketArn}/*`],
          actions: ["s3:GetObject", "s3:PutObject"],
        }),
        new PolicyStatement({
          resources: [`arn:${Aws.PARTITION}:execute-api:${Aws.REGION}:${Aws.ACCOUNT_ID}:*`],
          actions: ["execute-api:Invoke"],
        }),
      ],
    })
    mediaConvertPolicy.attachToRole(mediaConvertRole)

    const jobSubmit = new NodejsFunction(this, "jobSubmit", {
      entry: path.join(__dirname, "job-submit-fn.ts"),
      handler: "handler",
      runtime: Runtime.NODEJS_16_X,
      environment: {
        MEDIACONVERT_ENDPOINT: props.mediaConvertEndpoint,
        MEDIACONVERT_ROLE: mediaConvertRole.roleArn,
        DESTINATION_BUCKET: destination.bucketName,
        STACKNAME: Aws.STACK_NAME,
        SNS_TOPIC_ARN: notificationTopic.topicArn,
      },
      initialPolicy: [
        new PolicyStatement({
          actions: ["iam:PassRole"],
          resources: [mediaConvertRole.roleArn],
        }),
        new PolicyStatement({
          actions: ["mediaconvert:CreateJob"],
          resources: [`arn:${Aws.PARTITION}:mediaconvert:${Aws.REGION}:${Aws.ACCOUNT_ID}:*`],
        }),
        new PolicyStatement({
          actions: ["s3:GetObject"],
          resources: [source.bucketArn, `${source.bucketArn}/*`],
        }),
      ],
    })

    // EventBridge Rule作成
    new Rule(this, `JobSubmitRule`, {
      ruleName: `${this.stackName}-JobSubmitRule`,
      eventPattern: {
        source: ["aws.s3"],
        detailType: ["Object Created"],
        detail: {
          object: {
            key: SUPPORT_MOVIE_SUFFIX.flatMap((val) => {
              return [
                {
                  suffix: val,
                },
                {
                  suffix: val.toUpperCase(),
                },
              ]
            }),
          },
          bucket: {
            name: [source.bucketName],
          },
        },
        resources: [source.bucketArn],
      },
      targets: [
        new LambdaFunction(jobSubmit, {
          event: RuleTargetInput.fromObject({
            id: EventField.eventId,
            account: EventField.account,
            time: EventField.time,
            region: EventField.region,
            "detail-type": EventField.detailType,
            detail: {
              key: EventField.fromPath("$.detail.object.key"),
              size: EventField.fromPath("$.detail.object.size"),
              bucketName: EventField.fromPath("$.detail.bucket.name"),
            },
          }),
        }),
      ],
    })

    const jobComplete = new NodejsFunction(this, "jobComplete", {
      entry: path.join(__dirname, "job-complete-fn.ts"),
      handler: "handler",
      runtime: Runtime.NODEJS_16_X,
      environment: {
        MEDIACONVERT_ENDPOINT: props.mediaConvertEndpoint,
        CLOUDFRONT_DOMAIN: cloudFront.cloudFrontWebDistribution.distributionDomainName,
        SOURCE_BUCKET: source.bucketName,
        STACKNAME: Aws.STACK_NAME,
        SNS_TOPIC_ARN: notificationTopic.topicArn,
      },
      initialPolicy: [
        new PolicyStatement({
          actions: ["mediaconvert:GetJob"],
          resources: [`arn:${Aws.PARTITION}:mediaconvert:${Aws.REGION}:${Aws.ACCOUNT_ID}:*`],
        }),
        new PolicyStatement({
          actions: ["s3:GetObject", "s3:PutObject"],
          resources: [`${source.bucketArn}/*`],
        }),
      ],
    })

    new Rule(this, `jobCompleteRule`, {
      ruleName: `${this.stackName}-jobComplete`,
      eventPattern: {
        source: ["aws.mediaconvert"],
        detailType: ["MediaConvert Job State Change"],
        detail: {
          userMetadata: {
            stackName: [Aws.STACK_NAME],
          },
          status: ["COMPLETE", "ERROR", "CANCELED", "INPUT_INFORMATION"],
        },
      },
      targets: [
        new LambdaFunction(jobComplete, {
          event: RuleTargetInput.fromObject({
            id: EventField.eventId,
            account: EventField.account,
            time: EventField.time,
            region: EventField.region,
            "detail-type": EventField.detailType,
            detail: {
              status: EventField.fromPath("$.detail.status"),
              jobId: EventField.fromPath("$.detail.jobId"),
              outputGroupDetails: EventField.fromPath("$.detail.outputGroupDetails"),
              userMetadata: {
                stackName: EventField.fromPath("$.detail.userMetadata.stackName"),
              },
            },
          }),
        }),
      ],
    })

    new LambdaToSns(this, "Notification", {
      // NOSONAR
      existingLambdaObj: jobSubmit,
      existingTopicObj: notificationTopic,
    })

    new LambdaToSns(this, "CompleteSNS", {
      // NOSONAR
      existingLambdaObj: jobComplete,
      existingTopicObj: notificationTopic,
    })
  }
}

ほとんどは、お手本のリポジトリのコードをコピペですが、変更した箇所を中心に説明します。

SNSをサブスクリプションするメールアドレスの取得方法

以下のように、SNSをサブスクリプションするメールアドレスは、Stackのpropsで渡すように変更しています。

    const notificationTopic = new Topic(this, "Topic", {
      topicName: `${this.stackName}-Topic`,
    })

    notificationTopic.addSubscription(new EmailSubscription(props.adminEmail))

Lambda関数を、NodejsFunctionで作成

2つのLambdaの関数を、NodejsFunctionを使用して作成するようにしています。こちらの記事を参考にして、NodejsFunctionを使用して、LambdaをTypeScriptで記載するようにしています。NodejsFunctionを使うとcdk deploy時にLambdaのソースうまいことデプロイしてくれます。

    const jobSubmit = new NodejsFunction(this, "jobSubmit", {
      entry: path.join(__dirname, "job-submit-fn.ts"),
      handler: "handler",
      runtime: Runtime.NODEJS_16_X,
      environment: {
        MEDIACONVERT_ENDPOINT: props.mediaConvertEndpoint,
        MEDIACONVERT_ROLE: mediaConvertRole.roleArn,
        DESTINATION_BUCKET: destination.bucketName,
        STACKNAME: Aws.STACK_NAME,
        SNS_TOPIC_ARN: notificationTopic.topicArn,
      },
      initialPolicy: [
        new PolicyStatement({
          actions: ["iam:PassRole"],
          resources: [mediaConvertRole.roleArn],
        }),
        new PolicyStatement({
          actions: ["mediaconvert:CreateJob"],
          resources: [`arn:${Aws.PARTITION}:mediaconvert:${Aws.REGION}:${Aws.ACCOUNT_ID}:*`],
        }),
        new PolicyStatement({
          actions: ["s3:GetObject"],
          resources: [source.bucketArn, `${source.bucketArn}/*`],
        }),
      ],
    })
    
    ....
    
    const jobComplete = new NodejsFunction(this, "jobComplete", {
      entry: path.join(__dirname, "job-complete-fn.ts"),
      handler: "handler",
      runtime: Runtime.NODEJS_16_X,
      environment: {
        MEDIACONVERT_ENDPOINT: props.mediaConvertEndpoint,
        CLOUDFRONT_DOMAIN: cloudFront.cloudFrontWebDistribution.distributionDomainName,
        SOURCE_BUCKET: source.bucketName,
        STACKNAME: Aws.STACK_NAME,
        SNS_TOPIC_ARN: notificationTopic.topicArn,
      },
      initialPolicy: [
        new PolicyStatement({
          actions: ["mediaconvert:GetJob"],
          resources: [`arn:${Aws.PARTITION}:mediaconvert:${Aws.REGION}:${Aws.ACCOUNT_ID}:*`],
        }),
        new PolicyStatement({
          actions: ["s3:GetObject", "s3:PutObject"],
          resources: [`${source.bucketArn}/*`],
        }),
      ],
    })

S3のオブジェクトの生成をEventBridgeで受信して、Lambdaを起動する

変換前動画のバケットに、 eventBridgeEnabled: true,を追加して、EventBridgeに通知するように修正し、EventBridgeのルールを作成しています。なお、イベントパターンで、suffixを使用して、アップロードされた動画が、対応可能な拡張子の場合にのみトリガーを起動するようにしています。当然ですが、suffixを複数指定した場合、OR条件になるようです。bucket.nameと、resourceで二重に、対象のバケットをチェックしていますが、どちらか片方でも問題ない気がします。

なお、今回は、変換前動画のバケットのどのフォルダでも、トリガーしていますが、特定のフォルダを使用する場合、prefixを使用することになるように思われますが、prefixとsuffixを同時に使用した場合、どのような条件(AND、OR)になるのかは調べてません。

    /**
     * Source S3 bucket to host source videos and jobSettings JSON files
     */
    const source = new Bucket(this, "Source", {
      serverAccessLogsBucket: logsBucket,
      serverAccessLogsPrefix: "source-bucket-logs/",
      encryption: BucketEncryption.S3_MANAGED,
      publicReadAccess: false,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
+      eventBridgeEnabled: true,
    })
    
    ...
    
    new Rule(this, `JobSubmitRule`, {
      ruleName: `${this.stackName}-JobSubmitRule`,
      eventPattern: {
        source: ["aws.s3"],
        detailType: ["Object Created"],
        detail: {
          object: {
            key: SUPPORT_MOVIE_SUFFIX.flatMap((val) => {
              return [
                {
                  suffix: val,
                },
                {
                  suffix: val.toUpperCase(),
                },
              ]
            }),
          },
          bucket: {
            name: [source.bucketName],
          },
        },
        resources: [source.bucketArn],
      },
      targets: [
        new LambdaFunction(jobSubmit, {
          event: RuleTargetInput.fromObject({
            id: EventField.eventId,
            account: EventField.account,
            time: EventField.time,
            region: EventField.region,
            "detail-type": EventField.detailType,
            detail: {
              key: EventField.fromPath("$.detail.object.key"),
              size: EventField.fromPath("$.detail.object.size"),
              bucketName: EventField.fromPath("$.detail.bucket.name"),
            },
          }),
        }),
      ],
    })

aws-vod-cdk.ts

CDKのappを定義しています。ここで、最初にMediaConvertのエンドポイントを取得しています。これは、エンドポイントを取得処理が非同期であったため、スタックのコンストラクタ内でコールすることができなかったので、このような方法をとっています。

また、SNSをサブスクライブするメールアドレスは、cdk deploy --context admin-email=xxxx@xxxx.xxのようにCDKを実行する際に、オプションとして指定するようにしています。

なお、あまり考えていませんでしたが、おそらく、CDKを実行者の、AWSの認証情報で、MediaConvert関係の権限がないと、エンドポイントの取得に失敗するかもしれません。

MediaConvertのエンドポイントは、AWSのWebコンソールで確認できるので、メールアドレスと同じように実行時にオプションで渡すようにしてもよいかもしれません。

#!/usr/bin/env node
import "source-map-support/register"
import * as cdk from "aws-cdk-lib"
import { MediaConvert } from "aws-sdk"
import { AwsVodCdkStack } from "lib/aws-vod-cdk-stack"

export const getMediaConvertEndpoint = async () => {
  const mediaConvert = new MediaConvert({ apiVersion: "2017-08-29" })
  const ret = await mediaConvert.describeEndpoints({ MaxResults: 0 }).promise()
  if (!!ret.Endpoints && ret.Endpoints.length > 0) {
    return ret.Endpoints[0].Url
  }
  return ""
}

const app = new cdk.App()
const adminEmail = app.node.tryGetContext("admin-email")
if (!adminEmail) {
  console.log("--context admin-email=xxxx@xxxx.xx is required")
} else {
  async function main() {
    const endpoint = await getMediaConvertEndpoint()
    new AwsVodCdkStack(app, "AwsVodCdkStack", {
      mediaConvertEndpoint: endpoint || "",
      adminEmail: adminEmail,
    })
  }

  main()
}

job-submit-fn.ts

MediaConvertのジョブを生成するLambda関数です。

なお、MediaConvertのジョブの定義箇所は、非常に長いので、下のコードでは省略しています。正直、項目が多すぎて、自分で、これを理解して、想定下通りの変換を行うのは、とても大変な気がします。今回は、お手本の定義をそのまま使用しています。

import { EventBridgeHandler } from "aws-lambda"
import AWS, { MediaConvert } from "aws-sdk"
import { v4 as uuid } from "uuid"

export interface Detail {
  key: string
  size: string
  bucketName: string
}

export const handler: EventBridgeHandler<string, Detail, any> = async (event, context) => {
  const { MEDIACONVERT_ENDPOINT, MEDIACONVERT_ROLE, DESTINATION_BUCKET, STACKNAME, SNS_TOPIC_ARN } =
    process.env

  try {
    console.log("EVENT: " + JSON.stringify(event, null, 2))
    const srcVideo = decodeURIComponent(event.detail.key.replace(/\+/g, " "))
    const srcBucket = decodeURIComponent(event.detail.bucketName)
    const guid = uuid()
    const inputPath = `s3://${srcBucket}/${srcVideo}`
    const outputPath = `s3://${DESTINATION_BUCKET}/${guid}`
    const metaData = {
      guid: guid,
      stackName: STACKNAME || "",
    }

    const jobRequest = createJobRequest(inputPath, outputPath, MEDIACONVERT_ROLE || "", metaData)

    const mediaConvert = new MediaConvert({
      apiVersion: "2017-08-29",
      endpoint: MEDIACONVERT_ENDPOINT,
    })

    await mediaConvert.createJob(jobRequest).promise()
    console.log(`job subbmited to MediaConvert:: ${JSON.stringify(jobRequest, null, 2)}`)
  } catch (err) {
    await sendError(SNS_TOPIC_ARN || "", STACKNAME || "", context.logGroupName, err)
  }
  return
}

const createJobRequest = (
  inputPath: string,
  outputPath: string,
  role: string,
  metaData: { guid: string; stackName: string },
) => {
  return {
    Queue: "Default",
    Role: role,
    Settings: {...},
    AccelerationSettings: {
      Mode: "PREFERRED",
    },
    StatusUpdateInterval: "SECONDS_60",
    UserMetadata: metaData,
  }
}

const sendError = async (topic: string, stackName: string, logGroupName: string, err: Error) => {
  console.log(`Sending SNS error notification: ${err}`)
  const sns = new AWS.SNS({
    region: process.env.REGION,
  })
  try {
    const msg = {
      Details: `https://console.aws.amazon.com/cloudwatch/home?region=${process.env.AWS_REGION}#logStream:group=${logGroupName}`,
      Error: err,
    }
    await sns
      .publish({
        TargetArn: topic,
        Message: JSON.stringify(msg, null, 2),
        Subject: `${stackName}: Encoding Job Submit Failed`,
      })
      .promise()
  } catch (err) {
    console.error(err)
    throw err
  }
}

job-complete-fn.ts

MediaConvertの状態の変化毎に、EventBridgeからコールされるLambda関数です。

生成されたファイルのパスが、MediaConvertのgetJob()した結果になぜか含まれていない仕様(このIssueで議論されているのでそのうち修正されるかも?)で、EventBridgeの通知の方から生成結果のプレイリストのS3のファイルパスを取得し、CloudFront経由のURLに修正して、SNSに送信しています。

import { EventBridgeHandler } from "aws-lambda"
import AWS, { MediaConvert } from "aws-sdk"
import { v4 as uuid } from "uuid"

interface Detail {
  status: string
  jobId: string
  outputGroupDetails: any[]
}

export const handler: EventBridgeHandler<string, Detail, any> = async (event, context) => {
  console.log(`REQUEST:: ${JSON.stringify(event, null, 2)}`)
  const { MEDIACONVERT_ENDPOINT, CLOUDFRONT_DOMAIN, SNS_TOPIC_ARN, STACKNAME } = process.env

  if (!MEDIACONVERT_ENDPOINT || !CLOUDFRONT_DOMAIN || !STACKNAME || !SNS_TOPIC_ARN) {
    throw new Error("Invalid environment values")
  }

  try {
    const status = event.detail.status
    switch (status) {
      case "INPUT_INFORMATION":
        break
      case "COMPLETE":
        const jobDetails = await processJobDetails(
          MEDIACONVERT_ENDPOINT || "",
          CLOUDFRONT_DOMAIN || "",
          event.detail,
        )
        await sendSns(SNS_TOPIC_ARN, STACKNAME, status, jobDetails)
        break
      case "CANCELED":
      case "ERROR":
        /**
         * Send error to SNS
         */
        try {
          await sendSns(SNS_TOPIC_ARN, STACKNAME, status, event)
        } catch (err) {
          throw err
        }
        break
      default:
        throw new Error("Unknown job status")
    }
  } catch (err) {
    await sendSns(SNS_TOPIC_ARN || "", STACKNAME || "", "PROCESSING ERROR", err)
    throw err
  }
  return
}

/**
 * Ge the Job details from MediaConvert and process the MediaConvert output details
 * from Cloudwatch
 */
const processJobDetails = async (endpoint: string, cloudfrontUrl: string, detail: Detail) => {
  console.log("Processing MediaConvert outputs")
  const buildUrl = (originalValue: string) => originalValue.slice(5).split("/").splice(1).join("/")
  const mediaconvert = new AWS.MediaConvert({
    endpoint: endpoint,
    customUserAgent: process.env.SOLUTION_IDENTIFIER,
  })

  try {
    const jobData = await mediaconvert.getJob({ Id: detail.jobId }).promise()
    const jobDetails = {
      id: detail.jobId,
      job: jobData.Job,
      outputGroupDetails: detail.outputGroupDetails,
      playlistFile: detail.outputGroupDetails.map(
        (output) => `https://${cloudfrontUrl}/${buildUrl(output.playlistFilePaths[0])}`,
      ),
    }
    console.log(`JOB DETAILS:: ${JSON.stringify(jobDetails, null, 2)}`)
    return jobDetails
  } catch (err) {
    console.error(err)
    throw err
  }
}

/**
 * Send An sns notification for any failed jobs
 */
const sendSns = async (topic: string, stackName: string, status: string, data: any) => {
  const sns = new AWS.SNS({
    region: process.env.REGION,
  })
  try {
    let id, msg

    switch (status) {
      case "COMPLETE":
        /**
         * reduce the data object just send Id,InputFile, Outputs
         */
        id = data.Id
        msg = {
          Id: data.Id,
          InputFile: data.InputFile,
          InputDetails: data.InputDetails,
          Outputs: data.Outputs,
        }
        break
      case "CANCELED":
      case "ERROR":
        /**
         * Adding CloudWatch log link for failed jobs
         */
        id = data.detail.jobId
        msg = {
          Details: `https://console.aws.amazon.com/mediaconvert/home?region=${process.env.AWS_REGION}#/jobs/summary/${id}`,
          ErrorMsg: data,
        }
        break
      case "PROCESSING ERROR":
        /**
         * Edge case where processing the MediaConvert outputs fails.
         */
        id = data.Job.detail.jobId || data.detail.jobId
        msg = data
        break
    }
    console.log(`Sending ${status} SNS notification ${id}`)
    await sns
      .publish({
        TargetArn: topic,
        Message: JSON.stringify(msg, null, 2),
        Subject: `${stackName}: Job ${status} id:${id}`,
      })
      .promise()
  } catch (err) {
    console.error(err)
    throw err
  }
}

試してみる

実際に動かしてみます。現状はS3へのアップロード機能などは作成していないので、AWSのS3コンソールから、変換前動画のバケットに動画ファイルをアップロードします。

すると自動的にMediaConvertのジョブが生成され、しばらくすると、コンバート処理が終了し、登録したメールアドレスに、SNSからメッセージが届きます。

下の方に、

 "playlistFile": [
    "https://xxxxxxxxxxx.cloudfront.net/xxxxx-xxxxx-xxxx-xxxx-xxxxxxxxxxx/AppleHLS1/xxxxx.m3u8"
  ]

のようなURLがあるので、これを以下のindex.htmlファイルを作成して、srcの箇所に、上記のURLを設定してローカルで開くと動画が再生できます。

<html>
  <head>
    <title>Video.js Sample</title>
    <link href="https://vjs.zencdn.net/7.15.4/video-js.css" rel="stylesheet">
  </head>
  <body>
    <video
      id="my-video"
      class="video-js"
      controls
      preload="auto"
      width="640"
      height="360"
      data-setup="{}"
    >
      <source
         src="https://xxxxxxxxxxx.cloudfront.net/xxxxx-xxxxx-xxxx-xxxx-xxxxxxxxxxx/AppleHLS1/xxxxx.m3u8"
         type="application/x-mpegURL">
    </video>
    <script src="https://vjs.zencdn.net/7.15.4/video.js"></script>
  </body>
</html>

最後に

とりあえず、簡単ですが、S3に動画ファイルを保存して、MediaConvertで変換して、CloudFrontで配信するところまでをCDKで作成してみました。今回はお手本のCDKがあったので比較的簡単に作成することができました。

なお、実際に社内での動画共有サービスと考えると、

  • 動画を限られたユーザーにしか公開しない(CloudFrontの署名付きURL機能を使う)
  • ユーザー自身が動画をアップロードする仕組み(Cognito?とフロントエンドの構築)
  • 登録した動画のアセット管理(AppsyncとDynamoDBなどと連動)

も構築する必要がありそうです。

参考サイト

https://aws.amazon.com/jp/solutions/implementations/video-on-demand-on-aws/

https://qiita.com/misaosyushi/items/104445be7d7d3ba304bc

Discussion