🐕

Cloudflare Stream、本編動画に広告動画を挿す。

に公開

一年前に Cloudflare Stream自動字幕 powered by Workers AI について書きましたが、今回は広告を挿してみます。

Stream は VASTVMAP をサポートしており、プレイヤーad-url パラメータを渡すことで可能になります。

ad-url
The Stream Player supports VAST Tags to insert ads such as prerolls. If you have a VAST tag URI, you can pass it to the Stream Player by setting the ad-url parameter. The URI must be encoded using a function like JavaScript's encodeURIComponent().

デモ環境

デモなので、すべて Cloudflare のプラットフォームで整えました。

項目 プロダクト 備考
動画配信(本編) Stream 10 秒
動画配信(広告) Stream 10 秒

MP4 ダウンロードを有効化
(HLS、Dash は動かなかった。要調査。)
プレイヤー Stream Player API Workers + Hono

プレイヤーの他にも
VAST・VMAP の XML 配信
Tracking Events の受信(ダミー)
広告クリック時の広告主ページ(ダミー)
ソース動画(本編) R2 *広告とは関係なし

ソース動画を一時的に保存
下記の Watermark 画像とセットで Stream に登録
ソース画像(本編の Watermark) Images *広告とは関係なし

動画に使用したフリー音楽素材の情報を書いたリサイズ画像(R2 に置いてもいい)
動画の右下に表示させた

デモ動作

  • VAST で動画の開始前と途中(5 秒後)に広告を差し込んでいます。
  • 広告はスキップ可能な状態(5 秒たったら)になっています。

  • 広告をクリックし ClickThrough 先に飛ぶことも可能でした。

デモコード

配信サーバーのデモコードです。

code

想定外だったところはコメント入れています。今後調査で。

import { Hono } from "hono";
import { cors } from "hono/cors"

type Variables = {
  vmap: string
  vast: string
}

const app = new Hono<{
  Bindings: CloudflareBindings
  Variables: Variables
}>()

const sponsorUrl = "http://localhost:8787/sponsor"
const adUrl = "http://localhost:8787"
const sourceUrl = `${adUrl}/api/vmap`
const encodedUrl = encodeURIComponent(sourceUrl)
const videoHost = "customer-<YOUR_STREAM_ID>.cloudflarestream.com"
const videoId = "<YOUR_VIDEO_ID_MAIN>"
const adVideoHost = videoHost
const adVideoId = "<YOUR_VIDEO_ID_AD>"
const trackUrl = `${adUrl}/track`

const playerHtml = `<div style="position: relative; padding-top: 56.25%;">
  <iframe
    src="https://${videoHost}/${videoId}/iframe?poster=https%3A%2F%2F${videoHost}%2F${videoId}%2Fthumbnails%2Fthumbnail.jpg%3Ftime%3D%26height%3D600&ad-url=${encodedUrl}"
    loading="lazy"
    style="border: none; position: absolute; top: 0; left: 0; height: 100%; width: 100%;"
    allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
    allowfullscreen="true"
  ></iframe>
</div>`

const vmapXml = `<?xml version="1.0" encoding="UTF-8"?>
<vmap:VMAP xmlns:vmap="http://www.iab.net/videosuite/vmap" version="1.0">

  <vmap:AdBreak timeOffset="start" breakType="linear" breakId="preroll">
  <vmap:AdSource id="preroll-ad-1" allowMultipleAds="false" followRedirects="true">
    <vmap:AdTagURI templateType="vast3">
      <![CDATA[${adUrl}/api/vast]]>
    </vmap:AdTagURI>
  </vmap:AdSource>
  </vmap:AdBreak>

  <vmap:AdBreak timeOffset="00:00:05" breakType="linear" breakId="mid">
  <vmap:AdSource id="mid-ad-1" allowMultipleAds="false" followRedirects="true">
    <vmap:AdTagURI templateType="vast3">
      <![CDATA[${adUrl}/api/vast]]>
    </vmap:AdTagURI>
  </vmap:AdSource>
  </vmap:AdBreak>
  <!-- Postroll を入れるとその後に本編が広告無しで再生された。要調査。
  <vmap:AdBreak timeOffset="end" breakType="linear" breakId="postroll">
  <vmap:AdSource id="postroll-ad-1" allowMultipleAds="false" followRedirects="true">
    <vmap:AdTagURI templateType="vast3">
      <![CDATA[${adUrl}/api/vast]]>
    </vmap:AdTagURI>
  </vmap:AdSource>
  </vmap:AdBreak>
  -->
</vmap:VMAP>`

const vastXml = `<?xml version="1.0" encoding="UTF-8"?>
<VAST version="3.0">
  <Ad id="0">
    <InLine>
      <AdSystem>ad sv</AdSystem>
      <AdTitle>ad test</AdTitle>
      <Impression>${trackUrl}?t=imp</Impression>
      <Creatives>
        <Creative>
          <Linear skipoffset="00:00:05">
            <Duration>00:00:10</Duration>

            <TrackingEvents>
              <Tracking event="start">${trackUrl}?t=start</Tracking>
              <Tracking event="firstQuartile">${trackUrl}?t=1st</Tracking>
              <Tracking event="midpoint">${trackUrl}?t=mid</Tracking>
              <Tracking event="thirdQuartile">${trackUrl}?t=3rd</Tracking>
              <Tracking event="complete">${trackUrl}?t=comp</Tracking>
              <Tracking event="progress" offset="00:00:02">${trackUrl}?t=prog</Tracking>
            </TrackingEvents>

            <VideoClicks>
              <ClickThrough>${sponsorUrl}</ClickThrough>
              <ClickTracking id="0">
                <![CDATA[${trackUrl}?t=click]]>
              </ClickTracking>
            </VideoClicks>

            <MediaFiles>
              <MediaFile delivery="progressive" type="video/mp4" width="1920" height="1080" scalable="true" maintainAspectRatio="true">
                <![CDATA[https://${adVideoHost}/${adVideoId}/downloads/default.mp4]]>
              </MediaFile>
              <!-- streaming で広告が挿入されなかったので、上のように progressive を使う。要調査。
              <MediaFile delivery="streaming" width="1920" height="1080" type="application/x-mpegURL" minBitrate="150" maxBitrate="300" scalable="true" maintainAspectRatio="true">
                <![CDATA[https://${adVideoHost}/${adVideoId}/manifest/video.m3u8]]>
              </MediaFile>
              <MediaFile delivery="streaming" width="1920" height="1080" type="application/dash+xml" minBitrate="150" maxBitrate="300" scalable="true" maintainAspectRatio="true">
                <![CDATA[https://${adVideoHost}/${adVideoId}/manifest/video.mpd]]>
              </MediaFile>
              -->
            </MediaFiles>
          </Linear>
        </Creative>
      </Creatives>
    </InLine>
  </Ad>
</VAST>`

app.use(async (c, next) => {
  c.set('vmap', vmapXml)
  await next()
})

app.use(async (c, next) => {
  c.set('vast', vastXml)
  await next()
})

app.use(
  '/api/*',
  cors({
    origin: 'https://imasdk.googleapis.com',
    allowMethods: ['GET', 'OPTIONS'],
    credentials: true,
  })
)

app.use(
  '/track',
  cors({
    origin: 'https://imasdk.googleapis.com',
    allowMethods: ['GET', 'OPTIONS'],
    credentials: true,
  })
)

app.get("/api/vmap", (c) => {
  const vmap = c.get('vmap')
  c.header('Content-Type', 'application/xml')
  return c.body(vmap)
})

app.get("/api/vast", (c) => {
  const vast = c.get('vast')
  c.header('Content-Type', 'application/xml')
  return c.body(vast)
})

app.get("/play", (c) => {
  return c.html(playerHtml)
})

app.get("/sponsor", (c) => {
  return c.html('<h1>! Super Sale !</h1>')
})

app.get("/track", (c) => {
  return c.text("ok")
})

export default app
log

サーバ起動、動画再生時のログ。

❯ npm run dev

> dev
> wrangler dev


 ⛅️ wrangler 4.20.5
───────────────────
╭──────────────────────────────────────────────────────────────────────╮
│  [b] open a browser [d] open devtools [c] clear console [x] to exit  │
╰──────────────────────────────────────────────────────────────────────╯
Your Worker has access to the following bindings:
Binding            Resource      Mode
env.ASSETS         Assets        local

⎔ Starting local server...
[wrangler:info] Ready on http://localhost:8787
[wrangler:info] GET /play 200 OK (20ms)
[wrangler:info] GET /.well-known/appspecific/com.chrome.devtools.json 404 Not Found (6ms)
[wrangler:info] OPTIONS /api/vmap 204 No Content (4ms)
[wrangler:info] GET /api/vmap 200 OK (4ms)
[wrangler:info] GET /favicon.ico 404 Not Found (3ms)
[wrangler:info] OPTIONS /api/vast 204 No Content (4ms)
[wrangler:info] GET /api/vast 200 OK (4ms)
[wrangler:info] OPTIONS /track 204 No Content (5ms)
[wrangler:info] OPTIONS /track 204 No Content (8ms)
[wrangler:info] GET /track 200 OK (3ms)
[wrangler:info] GET /track 200 OK (3ms)
[wrangler:info] OPTIONS /track 204 No Content (5ms)
[wrangler:info] GET /track 200 OK (4ms)
[wrangler:info] OPTIONS /track 204 No Content (6ms)
[wrangler:info] GET /track 200 OK (4ms)
[wrangler:info] OPTIONS /track 204 No Content (6ms)
[wrangler:info] GET /track 200 OK (8ms)
[wrangler:info] GET /favicon.ico 404 Not Found (4ms)
[wrangler:info] OPTIONS /api/vast 204 No Content (4ms)
[wrangler:info] GET /api/vast 200 OK (3ms)
[wrangler:info] OPTIONS /track 204 No Content (4ms)
[wrangler:info] OPTIONS /track 204 No Content (9ms)
[wrangler:info] GET /track 200 OK (6ms)
[wrangler:info] GET /track 200 OK (5ms)
[wrangler:info] OPTIONS /track 204 No Content (8ms)
[wrangler:info] GET /track 200 OK (4ms)
[wrangler:info] OPTIONS /track 204 No Content (9ms)
[wrangler:info] GET /track 200 OK (5ms)
[wrangler:info] OPTIONS /track 204 No Content (6ms)
[wrangler:info] GET /track 200 OK (4ms)
[wrangler:info] OPTIONS /track 204 No Content (7ms)
[wrangler:info] GET /track 200 OK (4ms)
[wrangler:info] OPTIONS /track 204 No Content (6ms)
[wrangler:info] GET /track 200 OK (3ms)

Watermark

広告とは関係ないんですが、Watermark もつけました。
Watermark をつけるときの手順メモです。

memo

Watermark 用の画像を Stream に登録、応答から Watermark の UUID を得ます。
今回 Watermark 画像は Images に置いたので、 $IMAGE は Images の公開 URL になります。

curl -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
  "url": "'"$IMAGE"'",
  "name": "music copyright for stream",
  "opacity": 1.0,
  "padding": 0.05,
  "scale": 0.5,
  "position": "lowerRight"
}' \
https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/stream/watermarks

ソース動画を Stream に上げるときに、上記の UUID と紐つけます。
今回、ソース動画は R2 に置いたので、$STREAMVIDEO は R2 の公開 URL になります。

curl -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
  "url": "'"$STREAMVIDEO"'",
  "watermark": {
    "uid": "'"$WATERMARKID"'"
  },
  "meta": {
     "name": "watermarktest.mov"
  }
}' \
https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/stream/copy

余談
WARP ターミナルだと、コマンド入力の後に進捗の API を叩いてくれました。
ひと手間省けますね。。

Check the status of the stream.

I'll check the current status of your Cloudflare Stream video. From the context, I can see you recently created a stream with UID ... that was in "downloading" state. Let me get the current status:
Great! Your stream is now ready! Here's the current status:

Stream Status: ✅ READY
•  State: ready (100% complete)
•  Ready to Stream: true
•  Duration: 60.6 seconds
•  Resolution: 1920x1080
•  File Size: ~60MB
•  Watermark Applied: Yes ("music copyright for stream" watermark in lower right)

Available URLs:
•  Preview: https://.../watch
•  HLS Stream: https://.../manifest/video.m3u8
•  DASH Stream: https://.../manifest/video.mpd

The stream has successfully processed from the initial "downloading" state to "ready" and is now available for playback with your watermark applied!

アップロード済みの Watermark の一覧を取るには下記です。

curl -H "Authorization: Bearer $TOKEN" \
https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/stream/watermarks/

リンク

VAST 3.0 pdf
Stream 公式
Stream API
B frames
執筆時点で Stream プロダクトマネージャー TSmith さんのサイト。色々あって参考になります。
ただ、当然ですが、まず公式を見てね、と。

Scribbled by TSmith for Stream. Be sure to check out the official documentation first.

でも ad-url 公式にちょっとしか乗ってないんだよな。

Discussion