iTranslated by AI
Inserting Ad Videos into Cloudflare Stream Content
A year ago, I wrote about automatic subtitles powered by Workers AI using Cloudflare Stream. This time, I will try inserting advertisements.
Stream supports VAST and VMAP, which can be enabled by passing the ad-url parameter to the player.
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().
Demo Environment
Since this is a demo, I set everything up on the Cloudflare platform.

| Item | Product | Notes |
|---|---|---|
| Video Distribution (Main) | Stream | 10 seconds |
| Video Distribution (Ad) | Stream | 10 seconds Enable MP4 download (HLS and Dash did not work; requires further investigation.) |
| Player | Stream Player API |
Workers + Hono In addition to the player: VAST/VMAP XML delivery Tracking Events reception (dummy) Advertiser page on ad click (dummy) |
| Source Video (Main) | R2 | *Unrelated to ads Temporarily store the source video Register to Stream along with the Watermark image below |
| Source Image (Main Watermark) | Images | *Unrelated to ads A resized image containing info about free music used in the video (can also be placed in R2) Displayed at the bottom right of the video |
Demo Operation
- Ads are inserted before and during the video (at 5 seconds) using VAST.
- Ads are skippable (after 5 seconds).

- It was also possible to click the ad and navigate to the ClickThrough destination.

Demo Code
Here is the demo code for the delivery server.
code
I have added comments where behavior was unexpected. Will investigate further.
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>
<!-- When Postroll was added, the main video played afterward without ads. Needs investigation.
<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>
<!-- Since ads didn't insert via streaming, I used progressive as shown above. Needs investigation.
<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
Logs when starting the server and playing the video.
❯ 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
Although unrelated to ads, I also added a Watermark.
Here are my notes on the procedure for applying a Watermark.
memo
Register the Watermark image with Stream to get the Watermark UUID from the response.
Since I placed the Watermark image in Images, $IMAGE is the public URL of the Images.
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
When uploading the source video to Stream, link it to the UUID above.
Since I placed the source video in R2, $STREAMVIDEO is the public URL of the R2 asset.
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
Aside
When using WARP Terminal, it automatically called the progress API after I input the command.
Saves a step!
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!
To retrieve a list of uploaded Watermarks, use the following:
curl -H "Authorization: Bearer $TOKEN" \
https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/stream/watermarks/
Links
VAST 3.0 pdf
Stream Official
Stream API
B frames
The site of Stream Product Manager TSmith at the time of writing. It's very informative.
However, as always, check the official documentation first.
Scribbled by TSmith for Stream. Be sure to check out the official documentation first.
But the official documentation only has a little bit of information on ad-url.
Discussion