Skip to content

Commit 0ee94a2

Browse files
authored
feat: accept a slotted <video> as the custom element's source (issue #6) (#7)
* feat: accept a slotted <video> as the custom element's source (issue #6) <chroma-key-video> now accepts a light-DOM <video> child as its source, taking precedence over the src attribute. This lets the element key any source the video element itself supports (WebRTC, hls.js/dash.js-attached media, MediaStream via srcObject, etc.) instead of only a plain URL, without adding per-source-type properties. The slotted video is caller-owned: destroy()/disconnection never pauses or clears it, and swapping the slotted element at runtime rebuilds the player. Tests exercise real sources rather than only synthetic captureStream(): a loopback RTCPeerConnection pair for genuine WebRTC SRTP encode/decode, and hls.js loading a real generated VOD fixture (test/assets/hls/). * fix(chromakey): address adversarial audit findings on issue #6 slotted-source - attributeChangedCallback('src', ...) no longer rebuilds the player while a slotted <video> is active, since src is a no-op in that state - ::slotted(video) -> ::slotted(*) so a non-video slotted child stays hidden (pre-PR behavior), avoiding a rendering regression from the new default <slot> - document that autoplay/loop/muted/crossorigin only affect a library-created video, not a slotted one (JSDoc + README) - README: document the slotted-<video> source and its precedence over src - test harness: close WebRTC peer connections and destroy hls.js instances after each test instead of leaking them for the rest of the page's life - add regression test proving a src change is a no-op while a video is slotted
1 parent ec2e57a commit 0ee94a2

11 files changed

Lines changed: 353 additions & 9 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ The output canvas behaves like an `<img>`. Give it a CSS size, or let it default
105105

106106
`defineChromaKeyVideoElement(tagName?)` registers the element.
107107

108+
- **Source:** either a slotted `<video>` child or the `src` attribute. A slotted `<video>` takes precedence over `src` when both are present, and can be any source the video element itself supports — file, `MediaStream`, WebRTC, hls.js/dash.js-attached, etc.:
109+
```html
110+
<chroma-key-video auto-tune><video id="my-live-video"></video></chroma-key-video>
111+
```
112+
The slotted video is caller-owned: `autoplay`/`loop`/`muted`/`crossorigin` have no effect on it (configure it directly), and it's never paused or cleared by `destroy()`/disconnection. Swapping the slotted `<video>` at runtime rebuilds the player against the new source.
108113
- **Attributes:** `src`, `autoplay`, `loop`, `muted`, `channel`, `min-key`, `bias`, `softness`, `spill`, `edge-dissolve`, `auto-tune` (empty = once, `"adaptive"` = continuous), `fade-top`, `fade-bottom`, `max-pixel-ratio`.
109114
- Numeric attributes update live.
110115
- The underlying player is exposed as `element.player`.

package-lock.json

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"license": "MIT",
3636
"devDependencies": {
3737
"@playwright/test": "^1.49.0",
38-
"esbuild": "^0.28.2"
38+
"esbuild": "^0.28.2",
39+
"hls.js": "^1.7.1"
3940
}
4041
}

src/chromakey.js

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1373,10 +1373,21 @@ const ELEMENT_OPTION_ATTRIBUTES = {
13731373
/**
13741374
* Register the `<chroma-key-video>` custom element.
13751375
*
1376-
* Supported attributes: `src` (required), `autoplay`, `loop`, `muted`,
1377-
* `channel`, `min-key`, `bias`, `softness`, `spill`, `edge-dissolve`,
1376+
* Source: either a slotted `<video>` child (any source the video element
1377+
* itself supports — file, MediaStream via `srcObject`, WebRTC, hls.js/
1378+
* dash.js-attached, etc.) or the `src` attribute (a plain URL). A slotted
1379+
* video takes precedence over `src` when both are present. Swapping the
1380+
* slotted `<video>` at runtime rebuilds the player; the slotted element is
1381+
* caller-owned throughout, so `destroy()`/disconnection never pauses or
1382+
* clears it (see issue #6).
1383+
*
1384+
* Other supported attributes: `autoplay`, `loop`, `muted`, `channel`,
1385+
* `min-key`, `bias`, `softness`, `spill`, `edge-dissolve`,
13781386
* `auto-tune` (empty = once, `"adaptive"` = continuous), `fade-top`,
1379-
* `fade-bottom`, `max-pixel-ratio`, `stall-timeout`.
1387+
* `fade-bottom`, `max-pixel-ratio`, `stall-timeout`. `autoplay`/`loop`/
1388+
* `muted`/`crossorigin` only affect a video the element creates itself
1389+
* (from `src`) — a slotted `<video>` is caller-owned and caller-configured,
1390+
* so these attributes have no effect on it.
13801391
*
13811392
* The underlying player is exposed as the element's `.player` property for
13821393
* full programmatic control (events, `update()`, the video element, etc.).
@@ -1393,8 +1404,12 @@ export function defineChromaKeyVideoElement(tagName = 'chroma-key-video') {
13931404
super();
13941405
this._root = this.attachShadow({ mode: 'closed' });
13951406
const style = document.createElement('style');
1396-
style.textContent = ':host{display:inline-block;line-height:0}canvas{display:block;width:100%;height:100%}';
1407+
style.textContent = ':host{display:inline-block;line-height:0}canvas{display:block;width:100%;height:100%}::slotted(*){display:none}';
13971408
this._root.appendChild(style);
1409+
this._slot = document.createElement('slot');
1410+
this._slot.addEventListener('slotchange', () => this._handleSlotChange());
1411+
this._root.appendChild(this._slot);
1412+
this._activeVideoSource = null;
13981413
this._player = null;
13991414
}
14001415

@@ -1405,9 +1420,29 @@ export function defineChromaKeyVideoElement(tagName = 'chroma-key-video') {
14051420

14061421
disconnectedCallback() { this._teardown(); }
14071422

1423+
/** First slotted light-DOM <video> child, or null. */
1424+
_getSlottedVideo() {
1425+
for (const child of this.children) {
1426+
if (child instanceof HTMLVideoElement) return child;
1427+
}
1428+
return null;
1429+
}
1430+
1431+
_handleSlotChange() {
1432+
if (!this.isConnected) return;
1433+
const newVideo = this._getSlottedVideo();
1434+
if (newVideo === this._activeVideoSource) return;
1435+
this._teardown();
1436+
this._build();
1437+
}
1438+
14081439
attributeChangedCallback(name, oldValue, newValue) {
14091440
if (!this.isConnected || oldValue === newValue) return;
14101441
if (name === 'src') {
1442+
// A slotted video (if present) always wins over src (see _build()),
1443+
// so a src change is a no-op while one is active — rebuilding here
1444+
// would destroy and recreate the player against the same source.
1445+
if (this._getSlottedVideo()) return;
14111446
this._teardown();
14121447
this._build();
14131448
return;
@@ -1441,9 +1476,11 @@ export function defineChromaKeyVideoElement(tagName = 'chroma-key-video') {
14411476
}
14421477

14431478
_build() {
1444-
const src = this.getAttribute('src');
1445-
if (!src) return;
1446-
this._player = new ChromaKeyVideo(src, this._collectOptions());
1479+
const slottedVideo = this._getSlottedVideo();
1480+
const source = slottedVideo || this.getAttribute('src');
1481+
if (!source) return;
1482+
this._activeVideoSource = slottedVideo;
1483+
this._player = new ChromaKeyVideo(source, this._collectOptions());
14471484
this._root.appendChild(this._player.canvas);
14481485
this._lastAppliedAspect = '';
14491486
this._syncAspect = () => syncAspectRatio(this, this._player.video, this);
@@ -1459,6 +1496,7 @@ export function defineChromaKeyVideoElement(tagName = 'chroma-key-video') {
14591496
this._player.destroy();
14601497
this._player = null;
14611498
}
1499+
this._activeVideoSource = null;
14621500
}
14631501
}
14641502

test/assets/hls/playlist.m3u8

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#EXTM3U
2+
#EXT-X-VERSION:3
3+
#EXT-X-TARGETDURATION:1
4+
#EXT-X-MEDIA-SEQUENCE:0
5+
#EXT-X-PLAYLIST-TYPE:VOD
6+
#EXTINF:1.200000,
7+
segment0.ts
8+
#EXTINF:1.200000,
9+
segment1.ts
10+
#EXTINF:0.600000,
11+
segment2.ts
12+
#EXT-X-ENDLIST

test/assets/hls/segment0.ts

176 KB
Binary file not shown.

test/assets/hls/segment1.ts

157 KB
Binary file not shown.

test/assets/hls/segment2.ts

97.3 KB
Binary file not shown.

test/e2e.spec.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,28 @@ function backend(page, id) {
4444
return page.evaluate((pid) => window.players[pid].player.backend, id);
4545
}
4646

47+
function webrtcVideo(page, channel = 'green') {
48+
return page.evaluate((c) => window.createWebRTCVideo(c), channel);
49+
}
50+
51+
function hlsVideo(page) {
52+
return page.evaluate(() => window.createHlsVideo());
53+
}
54+
55+
function mountElement(page, attrs = {}, videoId = null) {
56+
return page.evaluate(
57+
([a, v]) => window.mountElement(a, v),
58+
[attrs, videoId],
59+
);
60+
}
61+
62+
function sampleElement(page, id, point) {
63+
return page.evaluate(
64+
([eid, [x, y]]) => window.sampleElement(eid, x, y),
65+
[id, point],
66+
);
67+
}
68+
4769
// Resolves with the detail of the next `type` event on player `id`, or
4870
// rejects if it doesn't fire within `timeoutMs`.
4971
function waitForEvent(page, id, type, timeoutMs = 5000) {
@@ -69,6 +91,13 @@ test.describe('chroma-key-video', () => {
6991
await openFixture(page);
7092
});
7193

94+
test.afterEach(async ({ page }) => {
95+
// Releases RTCPeerConnections/hls.js instances/redraw timers created by
96+
// createWebRTCVideo()/createHlsVideo() (issue #6), so they don't linger
97+
// for the rest of this page's lifetime across a whole file's test run.
98+
await page.evaluate(() => window.cleanupMedia());
99+
});
100+
72101
test('WebGL backend keys green to transparent and keeps foreground', async ({ page }) => {
73102
const id = await create(page);
74103
expect(await backend(page, id)).toBe('webgl');
@@ -530,4 +559,119 @@ test.describe('chroma-key-video', () => {
530559

531560
expect(await didFire(page, b, 'stalled', 500)).toBe(false);
532561
});
562+
563+
test('custom element: keys a slotted <video> fed by a real WebRTC track (issue #6)', async ({ page }) => {
564+
const videoId = await webrtcVideo(page);
565+
const id = await mountElement(page, {}, videoId);
566+
567+
const bg = await sampleElement(page, id, SAMPLES.background);
568+
expect(bg[3]).toBeLessThanOrEqual(2);
569+
const fg = await sampleElement(page, id, SAMPLES.stripe);
570+
expect(fg[3]).toBe(255);
571+
expect(fg[0]).toBeGreaterThan(180);
572+
});
573+
574+
test('custom element: keys a slotted <video> loaded by hls.js from a real HLS stream (issue #6)', async ({ page }) => {
575+
const videoId = await hlsVideo(page);
576+
const id = await mountElement(page, { 'auto-tune': true }, videoId);
577+
578+
const metrics = await page.evaluate((eid) => {
579+
const { player } = window.elements[eid];
580+
const c = player.canvas;
581+
const ctx = c.getContext('2d');
582+
const data = ctx.getImageData(0, 0, c.width, c.height).data;
583+
let transparent = 0, opaque = 0;
584+
const total = data.length / 4;
585+
for (let i = 0; i < data.length; i += 4) {
586+
if (data[i + 3] < 8) transparent++;
587+
if (data[i + 3] > 247) opaque++;
588+
}
589+
return { transparentFraction: transparent / total, opaqueFraction: opaque / total };
590+
}, id);
591+
592+
expect(metrics.transparentFraction).toBeGreaterThan(0.45);
593+
expect(metrics.opaqueFraction).toBeGreaterThan(0.15);
594+
});
595+
596+
test('custom element: a slotted <video> takes precedence over the src attribute (issue #6)', async ({ page }) => {
597+
const videoId = await webrtcVideo(page);
598+
const url = await page.evaluate(() => window.makePatternVideoURL());
599+
const id = await mountElement(page, { src: url }, videoId);
600+
601+
const usesSlotted = await page.evaluate(
602+
([eid, vid]) => window.elements[eid].player.video.id === vid,
603+
[id, videoId],
604+
);
605+
expect(usesSlotted).toBe(true);
606+
});
607+
608+
test('custom element: changing src while a video is slotted does not rebuild the player (issue #6)', async ({ page }) => {
609+
const videoId = await webrtcVideo(page);
610+
const url = await page.evaluate(() => window.makePatternVideoURL());
611+
const id = await mountElement(page, {}, videoId);
612+
613+
const rebuilt = await page.evaluate(async ([eid, u]) => {
614+
const el = window.elements[eid];
615+
const player = el.player;
616+
el.setAttribute('src', u);
617+
await new Promise((resolve) => setTimeout(resolve, 300));
618+
return el.player !== player || player.isDestroyed;
619+
}, [id, url]);
620+
expect(rebuilt).toBe(false);
621+
});
622+
623+
test('custom element: swapping the slotted <video> at runtime rebuilds the player without leaking the old one (issue #6)', async ({ page }) => {
624+
const videoIdA = await webrtcVideo(page);
625+
const id = await mountElement(page, {}, videoIdA);
626+
const videoIdB = await webrtcVideo(page);
627+
628+
const result = await page.evaluate(async ([eid, oldVid, newVid]) => {
629+
const el = window.elements[eid];
630+
const oldPlayer = el.player;
631+
el.removeChild(document.getElementById(oldVid));
632+
el.appendChild(document.getElementById(newVid));
633+
await new Promise((resolve, reject) => {
634+
const t = setTimeout(() => reject(new Error('rebuild never happened')), 10000);
635+
const check = () => {
636+
if (el.player && el.player !== oldPlayer && el.player.video.id === newVid) {
637+
clearTimeout(t);
638+
resolve();
639+
return;
640+
}
641+
setTimeout(check, 30);
642+
};
643+
check();
644+
});
645+
return { oldDestroyed: oldPlayer.isDestroyed, newVideoId: el.player.video.id };
646+
}, [id, videoIdA, videoIdB]);
647+
648+
expect(result.oldDestroyed).toBe(true);
649+
expect(result.newVideoId).toBe(videoIdB);
650+
});
651+
652+
test('custom element: destroy() does not pause or dispose an externally-owned slotted <video> (issue #6)', async ({ page }) => {
653+
const videoId = await webrtcVideo(page);
654+
const id = await mountElement(page, {}, videoId);
655+
656+
const before = await page.evaluate((vid) => {
657+
const v = document.getElementById(vid);
658+
return { paused: v.paused, hasSrcObject: !!v.srcObject };
659+
}, videoId);
660+
expect(before.paused).toBe(false);
661+
expect(before.hasSrcObject).toBe(true);
662+
663+
// Reclaim the video back into the document (as if the caller keeps it
664+
// mounted elsewhere) before tearing the element down, so Chromium's
665+
// native "pause on remove from document" behavior for a srcObject video
666+
// doesn't mask what destroy() itself does to a caller-owned video.
667+
const after = await page.evaluate(([eid, vid]) => {
668+
const el = window.elements[eid];
669+
const v = document.getElementById(vid);
670+
document.body.appendChild(v);
671+
el.remove();
672+
return { paused: v.paused, hasSrcObject: !!v.srcObject };
673+
}, [id, videoId]);
674+
expect(after.paused).toBe(false);
675+
expect(after.hasSrcObject).toBe(true);
676+
});
533677
});

0 commit comments

Comments
 (0)