feat(ui)!: add UITimeline widget, Media vocabulary and design-system styles - #74
feat(ui)!: add UITimeline widget, Media vocabulary and design-system styles#74thomasjammet wants to merge 5 commits into
Conversation
…styles
Add UITimeline, a self-contained reception-timeline canvas widget that
visualizes received media sequences over time (one row per track, media or
reception axis, drag-to-pan and an overview minimap), alongside the existing
UIMetrics. Move Media vocabulary module (Type, Codec, Sample, Tracks,
Resolution, typeToString + screen helpers) from wrts-client, this is the
protocol-agnostic input contract UITimeline consumes, so it depends only on a
plain data shape.
Split the package into two entry points:
- "." pure logic (no DOM/CSS) -> dist/web-utils.*
- "./ui" DOM/canvas components (UIMetrics, -> dist/ui/web-utils-ui.*
UITimeline)
Add the Ceeblue design system as cascade-layered stylesheets built into
dist/css/: tokens.css, foundation.css and components.css. Add an exports map,
sideEffects and files:["dist"] so the bundles and stylesheets publish cleanly
to npm and CDNs.
BREAKING CHANGE: UIMetrics is no longer exported from the package root. Import
it (and the new UITimeline) from "@ceeblue/web-utils/ui".
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Beyond the two inline nits, I also checked a few other candidate issues raised during review and ruled them out: the shared _videoSeq counter is fine because the widget models a single active video track (per Media.Tracks, video selection is one index at a time, not concurrent multi-track), so there's no cross-row corruption; the _viewEnd === 0 falsy check doesn't actually cause an unwanted snap-to-live in practice since _snap/_following gate the reset separately; and the audio/data-before-video n=0 merge at stream start is the same narrow, self-correcting startup window already covered by the inline video-sequence-collision finding, not a separate bug.
Extended reasoning...
Bugs found this run were both nits confined to a new diagnostic widget (UITimeline), and the inline comments already convey what's needed there. This note is just to record, for future reviewers, which additional candidate issues raised by the finder agents were examined and refuted this run, so they aren't re-investigated from scratch.
| } else { | ||
| // No video track to reference yet: detect sequences from reception gaps (each WebRTS | ||
| // sequence is a distinct request/burst), adapting the threshold to the track's cadence. | ||
| const dt = row.lastRecv ? now - row.lastRecv : 0; | ||
| boundary = !row.cur || (row.lastRecv > 0 && dt > Math.max(12, (row.avgDelta || 10) * 3)); | ||
| if (!boundary && row.lastRecv) { | ||
| row.avgDelta = row.avgDelta ? row.avgDelta * 0.8 + dt * 0.2 : dt; | ||
| } | ||
| n = boundary ? row.seqCounter++ : (row.cur as Sequence).n; |
There was a problem hiding this comment.
🟡 In UITimeline._push, the no-video reception-gap heuristic seeds avgDelta at 0 and only updates it inside the !boundary branch, so if a track's steady per-sample cadence is above the initial ~30ms threshold (e.g. HE-AAC/AAC frames ~42-46ms, Opus 40/60ms frames, sparse data/subtitle tracks), every sample is judged a boundary forever and avgDelta can never grow past its seed. This is a genuine self-reinforcing dead-end for any audio-only or data-only stream (no video track), producing a fresh one-sample Sequence per push instead of grouping bursts. Fix: seed/update avgDelta from the first observed gap regardless of the boundary decision (or seed it directly from the sample duration when known).
Extended reasoning...
The bug: In the no-video branch of _push (src/ui/UITimeline.ts:356-364), sequence boundaries for audio/data-only tracks (rows where _hasVideo is false) are detected via a reception-gap heuristic:
const dt = row.lastRecv ? now - row.lastRecv : 0;
boundary = !row.cur || (row.lastRecv > 0 && dt > Math.max(12, (row.avgDelta || 10) * 3));
if (!boundary && row.lastRecv) {
row.avgDelta = row.avgDelta ? row.avgDelta * 0.8 + dt * 0.2 : dt;
}row.avgDelta starts at 0 (see the Row literal built earlier in _push) and is only written inside the if (!boundary && row.lastRecv) branch. Because avgDelta is falsy at start, the very first threshold evaluates to Math.max(12, (0 || 10) * 3) = 30ms. If the track's real, steady per-sample gap is consistently above that ~30ms seed — which is a normal cadence for several audio/data configurations (HE-AAC/AAC frames at ~42-46ms, Opus's 40/60ms frame sizes, or a sparse subtitle/ID3/JSON data track) — every single sample satisfies dt > 30, so boundary is always true. Since the update to avgDelta only runs when !boundary, it can never leave 0, which pins the threshold at 30ms forever. There is no other code path anywhere in the class that mutates avgDelta, so once the loop starts, it cannot escape on its own — the only way out is a run of samples arriving faster than 30ms apart, which never happens for a track with a steady slower cadence.
Trigger path: any row with no video reference (either a genuinely audio/data-only track, since _hasVideo only flips true once an actual video sample is pushed, or any row before the very first video sample arrives) whose steady inter-sample arrival exceeds ~30ms. Every pushAudio/pushData call for that row then hits the boundary = true path, so n = row.seqCounter++ fires every time and a brand-new one-sample Sequence object is pushed onto row.seqs on every single call.
Why nothing else prevents it: _hasVideo is a one-way latch that only becomes true when a video sample is actually pushed — it does not protect audio-only or data-only deployments, which are an explicitly supported use of this generic, protocol-agnostic widget (it consumes any producer of Media.Sample, and the PR description doesn't restrict it to audio+video streams). MAX_SEQUENCES trimming bounds memory growth but does not fix the underlying grouping — it just silently drops the oldest of the many spurious one-sample sequences.
Impact: the widget's entire purpose is to group consecutive samples into burst rectangles so reception structure is visible at a glance. For any affected row, this degenerates into a wall of one-sample slivers (visually and in the CSV export via toCSV()), which defeats that purpose specifically for audio-only or data-only streams — a plausible, real deployment shape for this widget.
Concrete walkthrough:
- Row created with
avgDelta: 0,lastRecv: 0. - Sample 1 arrives:
row.lastRecvis falsy sodt = 0;boundary = !row.cur = true(nocuryet). A newSequenceis created.row.lastRecvis set tonow. - Sample 2 arrives 45ms later (e.g. one HE-AAC frame later):
dt = 45. Threshold isMath.max(12, (0 || 10) * 3) = 30. Since45 > 30,boundary = trueagain — theif (!boundary && row.lastRecv)branch that would updateavgDeltais skipped, soavgDeltastays0. A second, brand-new one-sampleSequenceis pushed. - Sample 3, 4, 5... arrive at the same steady ~45ms cadence: the threshold never moves off 30ms because the only place that raises it (
avgDelta) never executes. Every sample becomes its ownSequenceindefinitely.
Fix suggestion: update avgDelta from the observed gap unconditionally (or at least seed it from the first real gap) rather than gating the update on !boundary, e.g. maintain the running average every time dt > 0 and only use boundary to decide whether to start a new Sequence, not whether to learn the cadence.
Severity note: all five independent verifiers confirmed the logic flaw as real and self-reinforcing with no escape path, but converged on "nit": this is a brand new diagnostic/debug visualization widget, the failure is confined to the no-video, steady-slower-than-30ms-cadence case (most tracked streams carry video and use the keyframe path instead), memory is bounded by MAX_SEQUENCES, and the only observable consequence is degraded rendering (one-sliver-per-sample) rather than a crash, data loss, or any effect on the actual media pipeline. I agree with that assessment and am not marking this normal-severity.
| let boundary: boolean; | ||
| if (type === Media.Type.VIDEO) { | ||
| this._hasVideo = true; | ||
| if (sample.isKeyFrame) { | ||
| ++this._videoSeq; // advance the shared sequence number on each GOP | ||
| } | ||
| n = this._videoSeq < 0 ? 0 : this._videoSeq; | ||
| boundary = !row.cur || !!sample.isKeyFrame; | ||
| } else if (this._hasVideo) { | ||
| // Reference the video track: this sample belongs to the current video sequence. | ||
| n = this._videoSeq < 0 ? 0 : this._videoSeq; | ||
| boundary = !row.cur || row.cur.n !== n; | ||
| } else { |
There was a problem hiding this comment.
🟡 In UITimeline._push, the shared video sequence counter _videoSeq starts at -1, and n is computed as this._videoSeq < 0 ? 0 : this._videoSeq. If the first video sample received is not a keyframe (e.g. joining a live stream mid-GOP), a stub Sequence is created with n=0; when the real first keyframe then arrives, _videoSeq advances from -1 to 0, so a second, distinct Sequence is also labeled n=0 — two adjacent video boxes share seq 0 in the tooltip/CSV, and any audio/data samples received in between get merged into one box straddling the real GOP boundary. This is a pre-existing edge case confined to stream startup and self-corrects once the second keyframe advances _videoSeq to 1 — purely cosmetic, not a functional break.
Extended reasoning...
The bug: _videoSeq is initialized to -1 as a "not yet started" sentinel, and the displayed sequence number is computed with n = this._videoSeq < 0 ? 0 : this._videoSeq (src/ui/UITimeline.ts:348-350). This folds the sentinel state into the same displayed value (0) as the first real sequence, so the sentinel and the first real GOP become indistinguishable by number.
Trigger and trace: this only manifests when the very first video sample delivered to pushVideo is not a keyframe — realistic when a viewer joins a live stream mid-GOP and the player forwards buffered delta frames before the next IDR/keyframe arrives.
- First sample,
isKeyFramefalsy:_videoSeqstays-1→n = 0.boundary = !row.cur || !!sample.isKeyFrameistrue(via!row.cur) → aSequence{n:0}is pushed intorow.seqs. - Next sample is the real first keyframe:
++this._videoSeqmakes it0.n = (0 < 0 ? 0 : 0) = 0— still0.boundaryis nowtruevia!!sample.isKeyFrame→ a second, distinctSequence{n:0}is pushed. - Result:
row.seqscontains two adjacent rectangles on the video row, both labeled "seq 0" in the hover tooltip and in the CSV export (toCSV()at line ~245 just printss.nverbatim), even though they represent different reception windows. - Cross-track effect: audio/data rows key their own boundary purely on
row.cur.n !== n(line ~356). Since both the pre-keyframe stub and the true first GOP sharen=0, any audio/data samples arriving in that window are merged into a single audioSequencethat straddles the real GOP boundary in the video row — contradicting the doc comment on theSequencetype ("so they line up vertically under the matching video sequence").
Why nothing upstream catches this: there's no dedup or validation on n — it's just used as an opaque display/grouping label, and the boundary conditions for video (!row.cur || !!sample.isKeyFrame) and audio/data (row.cur.n !== n) are evaluated independently with no shared "did the video row just open a new sequence" signal, only the numeric label.
Impact: this is confined to the reception timeline's diagnostic/visualization canvas (a debugging widget), not application logic. The effect is (a) a duplicated "seq 0" label shown briefly at stream startup, cosmetic only, and (b) one audio/data box that is slightly too wide, spanning a boundary it shouldn't. Both self-correct as soon as the second keyframe arrives and _videoSeq advances to 1, so there's no lasting misalignment, no crash, and no data loss — every sample is still counted and byte totals are still correct, just grouped into an extra/wider box.
Suggested fix: avoid folding the sentinel into a value that will be reused, e.g. skip creating a video sequence at all until the first keyframe is seen (only start counting/pushing once isKeyFrame has been true at least once), or initialize _videoSeq at 0 and always increment-before-labeling on the very first sample regardless of keyframe status coupled with a distinct "unknown/pre-key" bucket that never numerically collides with 0.
Given the narrow, self-correcting, cosmetic nature of the issue in a new diagnostic widget, this is a nit rather than a blocking issue.
Add Media.spec.ts (100% coverage of the vocabulary helpers) and UITimeline.spec.ts, which drives the widget through its public API: sequence grouping (video keyframes, audio/data under the current video sequence, reception-gap fallback), MAX_SEQUENCES trimming, toCSV export, reset, following/onFollowingChange, windowDuration clamping and axis switching. It also invokes render() and the mouse handlers under jsdom (using node-canvas) so the drawing and drag/scrub paths execute, bringing UITimeline to ~88% lines.
The reception-gap heuristic seeded avgDelta at 0 and only updated it on non-boundary samples, so an audio- or data-only track with a steady cadence above ~30ms (AAC ~42-46ms, Opus 40/60ms, sparse data) was flagged a new boundary on every sample forever — one sliver per sample instead of grouped sequences. Replace it with fixed 2s media-time buckets (mirroring the server's fallback GOP): a new sequence starts when a sample crosses a bucket boundary. Removes the now-dead avgDelta/lastRecv row state. Addresses the UITimeline review nit.
Canvas can't consume CSS, so resolve the design tokens to values at render time (--accent, --ok/--warn/--err, --txt, --border, --f-body/--f-mono) with fallbacks equal to the previous hardcoded defaults, so the widget follows the light/dark theme when the stylesheets are loaded and stays self-contained otherwise. The DOM tooltip is themed the same way from the surface tokens. Add the widget-specific --track-N palette and move the custom JS-driven widget styles (UIMetrics, UITimeline) into a clearly marked section at the end of components.css, apart from the generic UI kit.
Prefix every design-system custom property with --cb- (--cb-accent, --cb-bg, --cb-txt, --cb-r-sm, --cb-f-body, --cb-track-N, …) and every component class with cb- (.cb-btn, .cb-inp, .cb-shell, .cb-tab-btn, .cb-modal-box, state modifiers .cb-on/.cb-off, …). Rename the theme attribute html[data-theme] to html[data-cb-theme]. UITimeline's runtime getComputedStyle reads, its cb-uitl-canvas/cb-uitl-tip class hooks and the docstring follow suit. These live on the global :root / document scope, where generic names collide silently with the host app or other libraries; the prefix isolates them, as Bootstrap (--bs-), Shoelace (--sl-) and others do. Consumers must update to the cb- prefixed tokens, classes and data-cb-theme attribute. (not a breaking change since no one use the css files for now)
Summary
First piece of consolidating Ceeblue's shared UI components and styles into
web-utils.src/Media.ts) — moved from wrts-client:Type,Codec,Sample,Tracks,Resolution,typeToString(+screenResolution/overScreenSize). It is the protocol-agnostic input contractUITimelineconsumes, so the widget depends only on a plain data shape — any producer that emitsMedia.Samplecan feed it.dist/css/:tokens.css(variables + light/dark themes),foundation.css(reset/base),components.css(class-based UI kit).Package entry points
@ceeblue/web-utilsdist/web-utils.*@ceeblue/web-utils/uiUIMetrics,UITimeline→dist/ui/web-utils-ui.*@ceeblue/web-utils/{tokens,foundation,components}.cssdist/css/*sideEffects: ["**/*.css"]keeps DOM/CSS out of pure consumers via tree-shaking;files: ["dist"]ships only the built artifacts.UIMetricsis no longer exported from the package root — import it (and the newUITimeline) from@ceeblue/web-utils/ui. Triggers a major release (8.0.0).Follow-ups (separate PRs)
UIMetricsimports to@ceeblue/web-utils/ui, and consumeMedia/ styles from web-utils.Verification
Build clean (both entries +
dist/css/*),exportsresolve for././ui/ CSS, 217 tests pass, eslint + prettier clean, docs build 0 errors, tarball ships onlydist/*+ package/README/LICENSE.