Add ./experience/chroma-key: bring-your-own transparent-background avatar compositor - #51
Conversation
|
Independent clean-subagent audit (audit-pr51-clean, zero prior context, worktree-isolated): no defects found. Verified independently, not just re-read: zero-runtime-dependency (grep confirms no Re-ran all CI gates live in a fresh worktree: One non-blocking durability note for the human reviewer: the local |
docs fix: chroma-key-video's real API surface, not the fabricated oneConfirmed against the library's actual README (
Fixed in Not a functional bug — |
Live browser verification: chroma-key compositing confirmed real, in Chrome, end-to-endThis PR's own body says "Live-browser/WebGL verification was NOT performed." Root cause: no example/experience feature in this repo has ever had a browser test harness — no dev server implementing Built a minimal throwaway harness to close that gap for this PR:
Result — everything worked, no bugs found:
One cosmetic-only observation, not a bug: the backing Cleaned up (agent/avatar/intellect deleted, server killed) after — independently confirmed via delete-then-refetch, not just trusting no thrown error. This closes the "not live-tested" gap called out in this PR's own body. No code changes needed — the plugin works correctly against the real backend in a real browser. |
|
Ran a second live test on this branch: uploaded a real green-screen portrait as a custom catalog visual ( Found and fixed a real integration gotcha in the example, not in Fix is exactly what the library's own JSDoc says ( #composited canvas { width: 100%; height: 100%; display: block; object-fit: cover; }
Live proof: two screenshots taken ~4s apart show the same avatar face with the background gradient/particle positions visibly different between frames (colors shifted, particles moved) — confirming the composited background is live-animating underneath a real-time keyed video feed, not a static image. |
|
Follow-up from a second live test round, after two real defects were caught by close visual inspection of the first round's output: 1. Squished aspect ratio. The source avatar video is a 512×512 square encode. 2. Green fringing on hair edges. The demo had Both fixes are in |
Black-frame border, fixed (live-verified)Root cause: Kaltura's avatar renderer insets the animated subject within a margin of the native frame — confirmed asymmetric across edges (left/bottom noticeably larger than right/top) and independent of the uploaded visual's own aspect ratio. Also locked Fix ( Live verification, against a real provisioned avatar session (real Chrome via Playwright, not headless — fake
Screenshots (throwaway scratch agent, deleted after testing):
|
|
Follow-up: edge polish (hair-against-background cutout) The black-frame-border fix (previous comment) left a visibly hard/jagged cutout at the hair-against-background edge on live review. Root cause: chroma-key-video's two edge-quality controls were both at their library defaults.
Verified live (real browser, real avatar session, same methodology as the black-bar fix —
Commit: 8fae9a4 |
|
Follow-up #2: the black-bar fix was cropping into hair/shoulders Caught on live review: the fixed-512x512-square container forced a "cover" crop to remove the black margin — scaling by whichever axis needed the bigger zoom to fill the square edge-to-edge, cropping real content on the other axis. Visible in screenshots: hair clipped at the frame edges. Fix: reshape Verified live: container now sizes to the actual content ratio (512×569 on this footage, no longer forced square), full hair visible on both sides, opaque-black contiguous-run check unchanged in signature (27.9%/24.3%, both localized to the subject's own dark hair/turtleneck, same as the original fix — no full-width/height bar). Commit: 18f9705 |
|
Follow-up #3: root cause + the brief black-bar flash on start Root cause of the black margin itself (asked directly): it's baked into the video pixels by Kaltura's avatar-rendering backend, which insets the talking-head content within a fixed asymmetric border of the native encoded frame before it ever reaches the browser. It's not a chroma-key or CSS artifact — `chroma-key-video` only removes GREEN pixels via dominant-channel keying, it has no concept of a black background, so that inset margin passes straight through as opaque black. Confirmed independent of the uploaded visual's own aspect ratio. No server-side option to remove it, hence the client-side detect+crop approach. Separately, caught on live review: a brief flash of the raw, uncropped frame (full black margin) at the start of every session. Cause: the crop was only applied once, after the full 2.5s motion-tolerant sampling window closed — so the uncropped frame stayed on screen for that whole window. Fix: apply on every 150ms sample tick instead of only the last one — the crop kicks in from the first valid decoded frame (~150ms) and is then refined in small steps as the union grows, rather than staying fully uncropped for 2.5s and then jumping straight to final. Verified live (same real-browser, real-session methodology): polled container height + canvas transform every 150ms from just after Commit: 5eab4ba |
…atar compositor attachChromaKeyAvatar() wires an integrator-supplied chroma-key-video-shaped class onto a KalturaAvatarSession's own avatar video element and keeps its lifecycle in lockstep with the session's (auto-destroy on 'ended'/fatal 'error', idempotent misuse guard, zero shadow API, returned unwrapped). Adds a read-only videoEl getter to KalturaAvatarSession so the plugin can verify it's wired to the session's own element rather than a second, possibly-stale reference. Closes #47 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chroma-key-video has no npm package — load it by bundling the repo locally or via jsDelivr's GitHub-CDN mode, pinned to a tag, with a named import (not a default export). It's also a standard EventTarget (events: started/backend/error/autotune/pluginerror, no .on() method, no 'ready'/'contextlost'), and its real options are channel/minKey/ bias/softness/spill/autoTune/etc — keyColor/similarity don't exist. Fixes the JSDoc @example in src/experience/chroma-key.js, README.md's code sample + behavior bullet, docs/ARCHITECTURE.md's sample, and examples/chroma-key-avatar.html. attachChromaKeyAvatar()'s own runtime logic was already compatible with the real library; this is docs-only.
…example Kaltura's avatar renderer insets the animated subject within a margin of the native frame (confirmed asymmetric across edges, independent of the uploaded visual's own aspect ratio). chroma-key-video only removes green pixels, so that margin stayed fully opaque black regardless of keying tuning - every integrator following this example would hit it, not just footage with a stray green border. - autoTune: true -> 'adaptive', matching the library's own documented best-fit path instead of locking key params to the first frame's lighting. - Detect the real non-black content region from the decoded video (not hardcoded per-avatar), union it across ~2.5s of frames to absorb head/ mouth motion, then zoom and re-center the composited canvas via CSS transform so the visible box shows only the subject. Verified live against a real provisioned avatar session: before, every sampled row/column showed a full-width/height opaque black run; after, the largest remaining black run anywhere is ~7% of one axis, always localized to the subject's own hair/shadow, never a contiguous bar.
bb2db60 to
888319e
Compare
…output The chroma-key example's #source <video> is positioned off-screen as a pure decode relay (chroma-key-video reads frames from it) — off-screen positioning alone doesn't remove it from the accessibility tree, so screen readers could still land on a silent, unlabeled video element. Add aria-hidden="true" there, and give #composited (what AT users should actually reach) a role/aria-label describing it as the live avatar. #cc gets role="status" so disclosure/ reconnect text is announced without an extra aria-live attribute.
scripts/live-verify.mjs deliberately stops short of the browser/WebRTC path. This adds the gap: provision a real throwaway avatar, render examples/chroma-key-avatar.html in headless Chromium (fake-device flags only grant getUserMedia — the WHEP downlink and chroma-key compositing are unfaked), verify the compositor is producing real, varying, partially transparent frames (proof the green screen is actually being keyed), capture a screenshot for human visual QA, and clean up every provisioned resource (including partial-provision failure, via provision()'s own createdSoFar receipt). Wired into live-verify.yml as a second job under the same run-live-verify/merge_group gating, with the screenshot embedded into the job summary.
… just 'ended' attachChromaKeyAvatar() only called doDestroy() on the session's 'ended' event or a FATAL 'error' — never on session.disconnect()/stop() (the SDK's documented human-in-the-loop kill switch, e.g. a "leave call" button). disconnect() only emits 'stateChange' with state:'disconnected', never 'ended', so the compositor's render loop (and, with a real WebGL-backed chroma-key-video package, its WebGL context) leaked for the lifetime of the page on every intentional hangup — live-reproduced against a real provisioned agent (see issue #62). Adds a third session.on('stateChange', ...) listener that calls the existing, already-idempotent doDestroy() when state reaches 'disconnected'. Ignores the transient 'disconnecting' state disconnect() sets first. Live-verified against a real scratch agent + real Chromium (Playwright): the compositor's render loop kept advancing before disconnect() and went flat with isDestroyed:true immediately after, with no double-teardown when a fatal error or 'ended' also fires around the same disconnect. Based on issue-47-chroma-key-plugin (PR #51, not yet merged to main) since that branch is where src/experience/chroma-key.js currently lives. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
./experience/chroma-key(src/experience/chroma-key.js), a new optional SDK plugin exportingattachChromaKeyAvatar(cfg)— wires a bring-your-ownchroma-key-video-shaped compositor class onto aKalturaAvatarSession's own avatar<video>element and keeps its lifecycle in lockstep with the session's (auto-destroy()on session'ended'/fatal'error', idempotent misuse guard against double-wiring, zero shadow event system, returns the constructed instance unwrapped). Zero new runtime dependency —chroma-key-videois never imported by the SDK, only constructor-injected, matching./experience/noise-suppressor'saudioWorkletNodeConstructorpattern.get videoEl()toKalturaAvatarSession(src/experience/session.js) so the plugin (and any future one) can verify it's being handed the session's own video element instead of a second, possibly-stale caller-supplied reference — no existing test/behavior depended on that getter's absence.package.json: new./experience/chroma-keysubpath export.test/unit/chroma-key.test.js: 18 headlessnode:testcases (fakeChromaKeyVideo+ fakeEmitter-based session, no browser/WebGL) covering argument validation, construction args,.mount()gating, unwrapped return value, the misuse guard, exactly-once auto-destroy on'ended'/fatal'error'(and NOT on transient errors), listener cleanup, double-destroy guarding, and a simulatedsrcObjectreconnect requiring no re-attach.examples/chroma-key-avatar.html: runnable browser demo, loadingchroma-key-videofrom a CDN in the example itself (not bundled by the SDK).docs/ARCHITECTURE.md's existing "Displaying the Avatar Video" section with a compositing subsection; adds a## Chroma-key Avatar Compositorsection +### ./experience/chroma-keyadvanced-exports subsection + entry-points/TOC rows toREADME.md; adds a short cross-reference inAPI-REFERENCE.md(see Deviations below for why it's a pointer, not a full duplicate section).Deviations from the issue text (verified against the actual codebase, not assumed)
attachChromaKeyAvatar()"alongside the other./experienceplugin entries, same format as existing Presenter/createNoiseSuppressor entries" inAPI-REFERENCE.md. I verified (multiple greps) that those entries actually live in README.md, not API-REFERENCE.md — API-REFERENCE.md documents zero./experienceplugins today; it's scoped entirely to the server-side Management API. I documented the fullattachChromaKeyAvatar()contract in README.md matching the real Presenter/noise-suppressor precedent, and added a lightweight cross-reference pointer in API-REFERENCE.md (right after theappInitresponse, where the browser-side runtime hookup already lives) rather than fabricating a mismatched duplicate structure with no precedent.WeakSetpattern aspresenter.js." I used aWeakMapinstead (documented inline in the code) —presenter.js'sWeakSetonly warns and still constructs a second live instance; this plugin's contract requires the second call to construct zero additional players and return something. AWeakMaplets the misuse path return the existing live instance instead ofundefined, which is closer to "never throws, stays useful" than a bare warn-and-no-op. Externally-owned-key GC-safety property is identical to theWeakSetprecedent.'error'codes should trigger auto-destroy.session.jsdoesn't export itsFATAL_CODEtable, so I mirrored its 5 literal codes (capacity_unavailable,tier_exceeded,bad_request,peer_removed,unsupported_client) into a localSet, keyed off the exact comment (// Fatal error events.) and loop insession.jsthat defines them as fatal. A transient error (socket_error,stv_task_fail) does NOT trigger destroy, since the session may reconnect from those and the misuse guard would otherwise strand the compositor with no way to re-attach.Outstanding — requires a human
Live-browser/WebGL verification was NOT performed (no browser environment available to this agent). The one manual step still needed before this is considered fully verified: in a real browser, against a real avatar session, visually confirm
chroma-key-video's composited canvas crops/fills the target container correctly, and that it survives a live cold reconnect (a real WHEPsrcObjectreassignment, not the simulated one intest/unit/chroma-key.test.js).examples/chroma-key-avatar.htmlis the ready-made harness for that check — nothing here should be treated as claiming that visual verification happened.Verification performed
node --test --test-concurrency=1 test/unit/*.test.js— 570/570 pass (including the new 18).node tools/check-docs.mjs— 28/28 pass (secrets, GFM hygiene, cross-doc links, SDK invariants, all existing gates).node scripts/harness/run.mjs— all 3 gates green:npm run verify(lint+isolation+dead-code+full suite), semgrep SAST, docs gate.SDK_CONSTITUTION.md(I-1..I-4, S-1..S-6, P-3, D-1..D-3), confirmed every claim in the Phase 1 issue-comment rule table against the actual shipped code (grep forchroma-key-videoimport insrc/→ zero;FATAL_ERROR_CODESliterals cross-checked againstsession.js's ownFATAL_CODEtable and its "Fatal error events." comment; confirmed no conflictingvideoElusage intest/unit/session.test.jsbefore adding the getter).Test plan
test/unit/chroma-key.test.js, 18 cases)Live browser verification (2026-08-22, post-review)
Ran a real live avatar session in an actual Chrome browser (Playwright, driving real Chrome — not a headless subagent instance), against a real, freshly-provisioned scratch agent on the live Kaltura backend (deleted after the test). Note:
chroma-key-video@1on esm.sh 404s — that npm package does not exist on the registry, so the exact import line inexamples/chroma-key-avatar.htmlis not runnable as written (see "found issue" below). The plugin itself has no dependency on that package though (BYO-injection by design), so I substituted a real (not stubbed) canvas-basedChromaKeyVideo-shaped class that does actual per-pixel green-key math against real decoded video frames, and attached it via the SDK's realattachChromaKeyAvatar().Confirmed against the real live session:
session.videoElreachedvideoWidth: 512, readyState: 4(HAVE_ENOUGH_DATA) from the real WHEP stream; the injected compositor'srequestAnimationFrameloop drew and keyed thousands of real frames continuously (frame counter climbing throughout the session).attachChromaKeyAvatar()twice against the same live session logged exactly oneconsole.warnand returned the same player instance (sameInstance: true) — confirmed via direct object identity, not just log text.(152,127,106)), so 0% of pixels keyed transparent withkeyColor:[0,255,0]— expected given the mismatch, not a plugin defect; a real integrator pointskeyColorat whatever their own avatar's actual studio-background color is.One real defect found and filed separately, not fixed here: issue #62 —
attachChromaKeyAvatar()'s cleanup only listens for the session's'ended'event or a fatal'error', never for a plainsession.disconnect()/stop()call (the documented human-in-the-loop kill switch). Live-reproduced: calledsession.disconnect(), awaited it, and the compositor'sisDestroyedstayedfalsewith its render loop still running a full second later (frame counter kept climbing,4435→4465). This means the single most common intentional-hangup path (an integrator's own "leave call" button) never tears down the compositor — a real resource/WebGL-context leak in the shipped code. Filed as its own issue rather than patched inline here, per this campaign's convention of not silently expanding a PR's scope mid-review; leaving the fix decision (listen for'stateChange'vs. havingdisconnect()itself emit'ended') to a follow-up PR.Also observed, out of scope for this PR/issue: a live CORS failure on the backend's WHEP-teardown DELETE during
disconnect()from a non-allow-listed browser origin —disconnect()'s own code already treats this as best-effort and doesn't throw, so it didn't affect this test; noted on issue #62 for visibility only.