From 88635af60082e3901195b57d4dd279daa68b6230 Mon Sep 17 00:00:00 2001 From: Tsachi Shlidor Date: Wed, 2 Sep 2026 10:10:24 +0300 Subject: [PATCH 1/8] fix(ima): pass debug option correctly and expose debug/autoPlayAdBreaks The plugin read playerOptions.ads.denug (typo), so ads.debug never reached videojs-ima. Fix the typo, add debug and autoPlayAdBreaks to the config schema and validators, and cover the option mapping with unit tests. --- src/config/configSchema.json | 12 +++- src/plugins/ima/index.js | 2 +- src/validators/validators.js | 4 +- test/unit/ima-plugin-options.test.js | 84 ++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 test/unit/ima-plugin-options.test.js diff --git a/src/config/configSchema.json b/src/config/configSchema.json index 3e77e6e1..f4e0693e 100644 --- a/src/config/configSchema.json +++ b/src/config/configSchema.json @@ -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": { @@ -246,7 +254,9 @@ "locale": "en", "prerollTimeout": 5000, "postrollTimeout": 5000, - "adsInPlaylist": "first-video" + "adsInPlaylist": "first-video", + "autoPlayAdBreaks": true, + "debug": false } }, "autoShowRecommendations": { diff --git a/src/plugins/ima/index.js b/src/plugins/ima/index.js index d412ec60..951db9a3 100644 --- a/src/plugins/ima/index.js +++ b/src/plugins/ima/index.js @@ -30,7 +30,7 @@ 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 }); if (Object.keys(playerOptions.ads).length > 0 && typeof player.ima === 'object') { diff --git a/src/validators/validators.js b/src/validators/validators.js index 16744cdb..21249718 100644 --- a/src/validators/validators.js +++ b/src/validators/validators.js @@ -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({ diff --git a/test/unit/ima-plugin-options.test.js b/test/unit/ima-plugin-options.test.js new file mode 100644 index 00000000..2c33124b --- /dev/null +++ b/test/unit/ima-plugin-options.test.js @@ -0,0 +1,84 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import imaPlugin from '~/plugins/ima'; + +vi.mock('~/plugins/ima/ima', () => ({})); + +const createPlayer = () => { + const player = { + ads: () => {}, + el: () => ({ id: 'player' }), + one: vi.fn(), + on: vi.fn() + }; + // like videojs-ima: calling player.ima(options) turns player.ima into the controller object + const imaInit = vi.fn(() => { + player.ima = { playAdBreak: vi.fn(), initCalls: imaInit.mock.calls }; + }); + player.ima = imaInit; + player.imaInit = imaInit; + return player; +}; + +describe('ima plugin options mapping', () => { + let originalGoogle; + + beforeEach(() => { + originalGoogle = global.google; + global.google = { ima: {} }; + return () => { + global.google = originalGoogle; + }; + }); + + it('passes the debug option through to player.ima', async () => { + const player = createPlayer(); + await imaPlugin(player, { ads: { adTagUrl: 'https://example.com/ads', debug: true } }); + + expect(player.imaInit).toHaveBeenCalledTimes(1); + expect(player.imaInit.mock.calls[0][0]).toMatchObject({ + adTagUrl: 'https://example.com/ads', + debug: true + }); + }); + + it('maps all supported ads options', async () => { + const player = createPlayer(); + await imaPlugin(player, { + ads: { + adTagUrl: 'https://example.com/ads', + showCountdown: false, + adLabel: 'Sponsored', + locale: 'fr', + prerollTimeout: 1000, + postrollTimeout: 2000, + autoPlayAdBreaks: false, + debug: false + } + }); + + expect(player.imaInit.mock.calls[0][0]).toMatchObject({ + adTagUrl: 'https://example.com/ads', + showCountdown: false, + adLabel: 'Sponsored', + locale: 'fr', + prerollTimeout: 1000, + postrollTimeout: 2000, + autoPlayAdBreaks: false, + debug: false + }); + }); + + it('plays an ad break only on the first source by default', async () => { + const player = createPlayer(); + await imaPlugin(player, { ads: { adTagUrl: 'x', adsInPlaylist: 'first-video' } }); + expect(player.one).toHaveBeenCalledTimes(1); + expect(player.on).not.toHaveBeenCalled(); + }); + + it('plays an ad break on every source when configured', async () => { + const player = createPlayer(); + await imaPlugin(player, { ads: { adTagUrl: 'x', adsInPlaylist: 'every-video' } }); + expect(player.on).toHaveBeenCalledTimes(1); + expect(player.one).not.toHaveBeenCalled(); + }); +}); From 4e21bd9f9199e5bf9d7a0fd000423fbd8509e31e Mon Sep 17 00:00:00 2001 From: Tsachi Shlidor Date: Wed, 2 Sep 2026 10:10:24 +0300 Subject: [PATCH 2/8] feat(docs): add interactive ads playground demo page Standalone page (not linked from the examples index) for customer-facing teams to demo client-side ad insertion: Google IMA sample-tag presets, custom ad tag / source / playlist inputs, all supported ads options, live ad-event log, copyable init snippet, and URL-encoded shareable state. VIDEO-21179 --- docs/ads-playground.html | 820 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 820 insertions(+) create mode 100644 docs/ads-playground.html diff --git a/docs/ads-playground.html b/docs/ads-playground.html new file mode 100644 index 00000000..d1fec987 --- /dev/null +++ b/docs/ads-playground.html @@ -0,0 +1,820 @@ + + + + + + Ads Playground · Cloudinary Video Player + + + + + + + + + + + + + +
+
+ +

Ads Playground

+

Demo client-side ad insertion with any source, ad tag and configuration — then copy the exact player setup.

+
+
+ +
+ + +
+ +
+
+
+ +
+
+ + Ad playing +
+
+ +
+
+

Event log

+ +
+
+

Ad and player events will appear here.

+
+
+ +
+
+

Player config

+ +
+

+        
+
+ +
+ +
+

Source

+
+
+ + +
+
+
+ +
+
+
+ + +
+ +
+ + +
+
+ +
+

Ad scenario

+
+ + +
+
+ + +
+
+ +
+

Options

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + + +
+ +
+ +
+ + +
+
+ +
+
+
+ +
+ + + + From f7ffb00f3b3cede129011bc574d2a7d91129ba17 Mon Sep 17 00:00:00 2001 From: Tsachi Shlidor Date: Wed, 2 Sep 2026 10:10:24 +0300 Subject: [PATCH 3/8] docs(spec): add ads playground spec and decision log Review-time artifacts; to be removed from the branch before merge. --- specs/ads-playground/log.md | 70 ++++++++++++++++++++ specs/ads-playground/spec.md | 121 +++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 specs/ads-playground/log.md create mode 100644 specs/ads-playground/spec.md diff --git a/specs/ads-playground/log.md b/specs/ads-playground/log.md new file mode 100644 index 00000000..326cf342 --- /dev/null +++ b/specs/ads-playground/log.md @@ -0,0 +1,70 @@ +# 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 / , 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:'`. diff --git a/specs/ads-playground/spec.md b/specs/ads-playground/spec.md new file mode 100644 index 00000000..dc4c90f2 --- /dev/null +++ b/specs/ads-playground/spec.md @@ -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 `