Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
822 changes: 822 additions & 0 deletions docs/ads-playground.html

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions specs/ads-playground/log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Decision log — Ads playground (VIDEO-21179)

## Engagement
- 2026-09-01: Sherpa engaged on branch `feat/ads-playground`. SpecKit (`.specify/`) and `specs/` added to this branch by explicit developer approval; they will NOT be merged to master (developer strips them before deploy). No new package dependencies allowed. `.sherpa/FLOW.md` also lives on this branch only — the repo's AGENTS.md deliberately carries no Sherpa directive (developer chose not to introduce Sherpa to the repo).

## 1. Context
- Ticket: VIDEO-21179 "Interactive ads playground for the video player (sales/SE demo tool)" — created this session from Raz's request in Slack (https://cloudinary.slack.com/archives/C0BSA33PVKR/p1787718731687349). Story, assigned to Tsachi, component Video Player, standalone (no epic — explicit developer decision).
- Grooming source: the Slack thread itself. Key facts: FT opportunity (~100k) is the driver; customer-facing teams struggle to configure/demo ads; native mobile ads are a separate track owned by Adi (≥1 sprint); SSAI and IAS/DoubleVerify out of scope.
- Existing implementation: `docs/vast-vpaid.html` (static example, hardcoded GAM ad tag, cloud_name demo). Ads plugin: `src/plugins/ima/index.js` wraps videojs-ima + contrib-ads; config surface in `src/config/configSchema.json` (`ads`: adTagUrl, showCountdown, adLabel, locale, prerollTimeout, postrollTimeout, adsInPlaylist) + validators in `src/validators/validators.js`.
- Known bug found while scoping: `ima/index.js` passes `playerOptions.ads.denug` (typo) → `debug` flag never worked. Also `debug`/`autoPlayAdBreaks` accepted by plugin but absent from schema. Fix is in scope of this PR (developer approved single PR).
- UI direction (developer): much richer/more modern than existing dev-oriented example pages. Reference designs: https://cloudinary.com/agents, https://videoapi.cloudinary.com/video-demo/video-transformations, https://videoapi.cloudinary.com/video-demo/zoom-and-pan. Visual refs not yet captured — planned during design/implementation.
- Constraints: single PR incl. the denug fix; work local, push only after approval; no new dependencies; vanilla JS demo page in docs/ served by existing tooling.

### UI reference capture (2026-09-01)
Captured via browser from the three reference pages:

**cloudinary.com/agents** (richest token system, dark theme):
- bg: #070F1A, panel bg2: #0B1623, bg3: #162436; text #fff, dim rgba(255,255,255,.58), faint .30, hairline rgba(255,255,255,.09)
- accent blue #0095FF; secondary indigo #3448C5, pink #FE5981
- font: Inter (800 for display, 700 buttons, 400 body)
- radii: card 16px, card-large 32px, card-small 8px, input 6px, button 32px (pill), badge 4px
- buttons: solid #0095FF pill, 14px/700, pad 10px 22px
- cards: bg2 + 1px hairline border, radius 16, no shadow (dark theme = borders not shadows)
- shadows/highlights: subtle white ring highlights (0 0 0 1px rgba(255,255,255,.04–.14))

**videoapi.cloudinary.com/video-demo/*** (the demo-page pattern to follow):
- layout: dark navy hero (gradient 170deg #162436→#2A3E58) with breadcrumb (DEMOS / <name>, blue), 47px/500 Inter h1, one-line subtitle
- source selector: horizontal thumbnail strip, active thumb gets blue border ring; last cell = dashed "upload" tile
- demo panel: video preview left, control rack right; toggle switches (pill 34x18, on=#3549C5) + sliders per option, value labels
- Apply button: #3549C5, radius 4
- note line: "Results are generated in real time…"

**Synthesis for the playground page** (decision): go with the agents-page dark token system (#070F1A family + #0095FF accent, Inter, 16px card radius, pill buttons, hairline borders) applied to the videoapi demo-page layout (hero + breadcrumb, thumbnail source strip, preview-left/controls-right rack with toggle switches). Skip Bootstrap entirely — self-contained CSS on the page. Inter via Google Fonts (font files, not a code dependency).
- 2026-09-01: Standalone page — deliberately NOT linked from docs/index.html (developer decision; sales tool, not a developer example).

## 4. Implement (2026-09-01)
- FR-9 fix: `denug`→`debug` in src/plugins/ima/index.js; `debug` + `autoPlayAdBreaks` added to configSchema.json (props + defaults) and validators.js.
- New unit test test/unit/ima-plugin-options.test.js (4 tests): debug pass-through (would have caught the typo), full option mapping, first-video vs every-video ad-break wiring. Mocks `~/plugins/ima/ima` to avoid loading videojs-ima; mock mirrors videojs-ima's behavior of replacing player.ima with a controller object after init.
- docs/ads-playground.html built: self-contained CSS token system (agents-page palette), videoapi-style layout, presets from Google's public IMA sample tags (single_ad_samples/vmap_ad_samples network 21775744923), event log wired to contrib-ads/ima events, dispose+recreate on Apply, copy-config snippet, URLSearchParams round-trip, reset. Standalone — not linked from index (decision).
- Design-review gate run (static): fixed unused tokens, hardcoded accent hover/active hexes, AA contrast on log empty/timestamps (--faint→--dim), added prefers-reduced-motion, badge radius token. Residual: real ad rendering + exact contrast need a visual pass.
- Testing status: 133/133 unit tests pass. `npm run lint` is BROKEN on a clean tree (ESLint 9 without flat config — pre-existing, out of scope; spawned a separate task chip). Changed files pass with ESLINT_USE_FLAT_CONFIG=false.

## Test stage — "no ads locally" investigation (2026-09-02)

Symptom: on the developer's machine, every ad request failed with IMA AdError 1005
(VAST_LOAD_ERROR, inner "Error: 6" = HTTP_ERROR, status -1) at
http://localhost:3000/ads-playground.html, while content played fine.

Elimination path:
1. Player code exonerated — a raw `google.ima.AdsLoader` harness injected on the same
page (no video player involved) failed identically.
2. Network exonerated — page-context fetch/XHR of the same ad tag returned 200 with a
valid VAST document.
3. Extensions exonerated — uBlock Origin Lite was strict-blocking at first, but after
disabling it (and finally in Incognito with all extensions off) the failure persisted.
4. Discriminator found — Google's own HTTPS IMA demo page loaded ads successfully in the
same Chrome, same minute. Only difference: origin scheme. Chrome had also logged a
COOP warning: "the URL's origin was untrustworthy … deliver the response using HTTPS."

Root cause: the IMA SDK issues its ad request from an SDK-created iframe; on this
machine's Chrome (likely enterprise policy or newer hardening), those requests are
silently killed with a network-level error when the embedding page is a plain-http
origin. Serving the same page over HTTPS (`npx webpack serve --config
webpack/dev.config.js --server-type https --port 3443`, self-signed cert) fixed it —
ads play.

Consequences:
- Local-dev-only issue; the deployed docs site is HTTPS, so SEs are unaffected.
- The page's ad-error notice now appends an "serve over https" hint when
`location.protocol === 'http:'`.

## Second bug found via deploy preview — contrib-ads loadstart race (2026-09-02)

On the PR's Netlify preview the console showed videojs-contrib-ads' "has not seen a
loadstart event 5 seconds after being initialized" error. Developer's hunch (correct):
the plugin's async loading. `imaPlugin` awaits a dynamic `import('./ima')` (a
network-fetched chunk that registers contrib-ads + videojs-ima) while the page sets the
source synchronously — when the media element's `loadstart` beats the chunk download,
contrib-ads misses it, and its preroll state machine waits indefinitely ("Waiting for
loadstart..."), so ads never play. Localhost usually wins the race (fast chunk); a real
CDN deploy usually loses it — which is why the playground worked locally but not on the
preview.

Fix: the plugin body runs synchronously up to the `await`, before any source exists, so
it arms a one-shot `loadstart` recorder there and replays the event after `player.ima()`
initializes (only when it was actually missed). Covered by two new unit tests; verified
on the rebuilt preview — flag set, no error.
121 changes: 121 additions & 0 deletions specs/ads-playground/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Feature Specification: Interactive Ads Playground

**Feature Branch**: `feat/ads-playground`

**Created**: 2026-09-01

**Status**: Approved

**Ticket**: [VIDEO-21179](https://cloudinary.atlassian.net/browse/VIDEO-21179)

**Input**: Raz's request (Slack, C0BSA33PVKR): a simple interactive playground so customer-facing teams can demo client-side ad insertion with different sources and configurations. Driven by the Financial Times opportunity; the general problem is that customer-facing teams struggle to configure and demo ads.

## User Scenarios & Testing *(mandatory)*

### User Story 1 - Run a canned ad demo (Priority: P1)

A sales engineer opens the page, clicks a preset ad scenario (e.g. "Pre-roll") and a
sample video, and plays. The ad plays before/during/after the content exactly as the
preset describes. No code, no configuration knowledge.

**Why this priority**: This is the core ask — "easily show and demo this capability."

**Independent Test**: Open page → pick "Pre-roll" preset → play → pre-roll ad renders,
then content plays.

**Acceptance Scenarios**:

1. **Given** the page loaded fresh, **When** the user presses play, **Then** the default
preset's ad plays against the default sample video.
2. **Given** any preset selected, **When** the user clicks Apply/play, **Then** the
matching ad behavior occurs (pre-roll / mid-roll / post-roll / VMAP pods / skippable).
3. **Given** the "error tag" preset, **When** played, **Then** the player falls back to
content gracefully and the event log shows the ad error.

### User Story 2 - Customize the demo (Priority: P2)

The SE swaps in a customer's own ad tag URL and/or video (public ID or raw URL, or a
playlist tag), tweaks the supported `ads` options (adLabel, locale, countdown, timeouts,
adsInPlaylist, debug), and re-runs.

**Acceptance Scenarios**:

1. **Given** a pasted custom ad tag, **When** Apply is clicked, **Then** the player
re-initializes and requests that tag (visible in the event log).
2. **Given** playlist mode with `adsInPlaylist: every-video`, **When** the playlist
advances, **Then** an ad break plays on each video; with `first-video`, only on the first.
3. **Given** any option change, **When** Apply is clicked, **Then** the rendered config
snippet reflects it exactly.

### User Story 3 - Share and hand off (Priority: P3)

The SE copies the generated `cloudinary.videoPlayer(...)` snippet for the customer, and/or
copies the page URL — which encodes the current settings — to send to a teammate.

**Acceptance Scenarios**:

1. **Given** a configured demo, **When** the URL is opened in a fresh tab, **Then** the
same settings are restored.
2. **Given** a configured demo, **When** "Copy config" is clicked, **Then** the clipboard
holds a valid, runnable player-init snippet matching the live player.

### Edge Cases

- Ad blocker / IMA SDK failed to load → clear inline notice, content still plays.
- Empty/garbage ad tag URL → player plays content; error surfaced in event log.
- Invalid public ID → player error surfaced without breaking the page.
- Mobile viewport → layout stacks; controls usable by touch.

## Requirements *(mandatory)*

### Functional

- **FR-1**: Standalone page `docs/ads-playground.html` — NOT linked from the examples
index (sales tool, not a developer example). Served by the existing docs tooling.
- **FR-2**: Ad tag presets from Google's public IMA sample tags: pre-roll, skippable,
post-roll, VMAP pre+mid+post ("ad rules"), VMAP pods, VPAID 2 JS, and an error tag —
plus a free-text field for any custom tag.
- **FR-3**: Source selection: sample-video thumbnail strip, custom public ID / raw URL
input, and playlist-by-tag mode (default tag `video_race`, cloud `demo`); editable
cloud name.
- **FR-4**: Controls for every supported `ads` option: `adTagUrl`, `showCountdown`,
`adLabel`, `locale`, `prerollTimeout`, `postrollTimeout`, `adsInPlaylist`, `debug`.
- **FR-5**: Apply re-creates the player (dispose + fresh `<video>` + re-init) with the
current settings.
- **FR-6**: Live event log of ad lifecycle + errors, timestamped, clearable.
- **FR-7**: Copyable init snippet, always in sync with the live player.
- **FR-8**: Settings round-trip through the URL query string.
- **FR-9**: Fix `src/plugins/ima/index.js` — `denug` typo → `debug`; add `debug` and
`autoPlayAdBreaks` to `configSchema.json` and validators. Unit-test the plugin's
option mapping.

### Design

- **DR-1**: Dark theme per cloudinary.com/agents tokens: bg `#070F1A`, panels `#0B1623`,
hairline `rgba(255,255,255,.09)`, text `#fff`/`rgba(255,255,255,.58)`, accent
`#0095FF`, Inter, radii 16/8/6px, pill buttons. Single accent, ≤2 font weights + one
display weight.
- **DR-2**: Layout per videoapi.cloudinary.com demo pages: breadcrumb + hero title,
thumbnail source strip (active = accent ring), preview left / control rack right,
stacking to one column on mobile.
- **DR-3**: Self-contained CSS on the page (no Bootstrap). Inter via Google Fonts with
system-ui fallback. No new package dependencies.
- **DR-4**: Accessible: semantic controls, keyboard navigable, visible focus, WCAG AA
contrast on the dark palette, labels on every control.
- **DR-5**: Every state designed: loading, ad playing (accent "Ad" badge state),
error notice, empty log.

### Out of scope

Native SDKs (iOS/Android/RN), SSAI/DAI, IAS/DoubleVerify, changes to the examples index,
new runtime dependencies, player feature work beyond FR-9.

## Success Criteria *(mandatory)*

- **SC-1**: A non-developer can run pre-roll, mid-roll and post-roll demos with zero code
edits (US-1).
- **SC-2**: All presets play against the real Google sample tags in Chrome + Safari.
- **SC-3**: URL sharing restores an identical demo (US-3).
- **SC-4**: `npm run lint` and `npm run test:unit` pass; the new plugin test covers the
`debug` mapping (would have caught the `denug` typo).
- **SC-5**: Page holds at 375px and 1440px widths.
12 changes: 11 additions & 1 deletion src/config/configSchema.json
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,14 @@
"type": "string",
"enum": ["first-video", "every-video"],
"default": "first-video"
},
"autoPlayAdBreaks": {
"type": "boolean",
"default": true
},
"debug": {
"type": "boolean",
"default": false
}
},
"default": {
Expand All @@ -246,7 +254,9 @@
"locale": "en",
"prerollTimeout": 5000,
"postrollTimeout": 5000,
"adsInPlaylist": "first-video"
"adsInPlaylist": "first-video",
"autoPlayAdBreaks": true,
"debug": false
}
},
"autoShowRecommendations": {
Expand Down
19 changes: 18 additions & 1 deletion src/plugins/ima/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import isFunction from 'lodash/isFunction';
import { PLAYER_EVENT } from '~/utils/consts';

export default async function imaPlugin(player, playerOptions) {
// videojs-contrib-ads requires initialization before the first loadstart, but the
// ima module is loaded async — record a loadstart that fires while the chunk is
// still loading so it can be replayed after initialization.
let missedLoadStart = false;
const recordLoadStart = () => {
missedLoadStart = true;
};
player.one(PLAYER_EVENT.LOAD_START, recordLoadStart);

await import(/* webpackChunkName: "ima" */ './ima');

const loaded = {
Expand All @@ -17,6 +26,7 @@ export default async function imaPlugin(player, playerOptions) {
if (!loaded.imaAdsLoaded) {
console.warn('imaSdk is not loaded');
}
player.off(PLAYER_EVENT.LOAD_START, recordLoadStart);
return false;
}

Expand All @@ -30,9 +40,16 @@ export default async function imaPlugin(player, playerOptions) {
adLabel: playerOptions.ads.adLabel || 'Advertisement',
locale: playerOptions.ads.locale || 'en',
autoPlayAdBreaks: playerOptions.ads.autoPlayAdBreaks !== false,
debug: playerOptions.ads.denug
debug: playerOptions.ads.debug
});

player.off(PLAYER_EVENT.LOAD_START, recordLoadStart);
if (missedLoadStart) {
// Replay the loadstart that contrib-ads missed, otherwise its ad state machine
// waits for one indefinitely and prerolls never play.
player.trigger(PLAYER_EVENT.LOAD_START);
}

if (Object.keys(playerOptions.ads).length > 0 && typeof player.ima === 'object') {
if (playerOptions.ads.adsInPlaylist === 'first-video') {
player.one(PLAYER_EVENT.SOURCE_CHANGED, () => {
Expand Down
1 change: 1 addition & 0 deletions src/utils/consts.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const PLAYER_EVENT = {
CAN_PLAY_THROUGH: 'canplaythrough',
CLD_SOURCE_CHANGED: 'cldsourcechanged',
SOURCE_CHANGED: 'sourcechanged',
LOAD_START: 'loadstart',
LOADED_METADATA: 'loadedmetadata',
LOADED_DATA: 'loadeddata',
REFRESH_TEXT_TRACKS: 'refreshTextTracks',
Expand Down
4 changes: 3 additions & 1 deletion src/validators/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ export const playerValidators = {
locale: validator.isString,
prerollTimeout: validator.isNumber,
postrollTimeout: validator.isNumber,
adsInPlaylist: validator.isString(ADS_IN_PLAYLIST)
adsInPlaylist: validator.isString(ADS_IN_PLAYLIST),
autoPlayAdBreaks: validator.isBoolean,
debug: validator.isBoolean
},
schedule: {
weekly: validator.isArrayOfObjects({
Expand Down
Loading
Loading