Cloudflare Stream、本編動画に広告動画を挿す。
一年前に Cloudflare Stream で 自動字幕 powered by Workers AI について書きましたが、今回は広告を挿してみます。
Stream は VAST・VMAP をサポートしており、プレイヤーに 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