iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐕

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/

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