oficina / meet Public

room.js: a frame is not playback, and a control is not to be spoken over

Two rules the browser gate had to teach the render watch, and it took
three red runs to hear both.

**Not to be spoken over.** `playfallback` failed twice: the Play control
§5 offers when the browser's autoplay policy refuses a remote video
never appeared. Guarding the watch on `paused` was not enough, because
the tile itself binds more than once now — audio arrives, then the
camera — and the second bind called `play()` again behind the refusal
and hid the question nobody had answered. `shouldPlay` is the rule:
while the Play control is showing, the gesture is the person's to give.

**A frame is not playback.** `laterolls` failed with the tile painting
one frame every few seconds — rendered totals of `[22,23,23,23,23,25]`
across three seconds — and reading any advance at all as health let a
tile like that reset the watch's budget for ever and never earn the
reset that rebuilds its connection. A second of video is a second of
clock; a frame is thirty milliseconds. `RENDER_MOVED` is 0.25 s, and a
clock that goes *backwards* is this watch's own repair landing, not a
stall to judge. The replay also hands the element a new MediaStream
over the same tracks rather than the object it was already holding,
which is a different source as far as the element is concerned.

Measured on the run that had been failing: `[25,25,25,25,25,25]` —
stopped dead — and then 21 → 26 across two successive intervals once
the watch escalated. Green, with the stall actually happening first.

Four tests: the Play rule and its ordinary cases, the stutter series
taken from the gate's own numbers, and the clock going backwards.

node --check, 206 node tests, make ci.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev d0a0fe206fcd7486bb71bf5c54c6ccf04f36f0f8 parent 3d2e880
2 files changed, +88 −6
  • internal/meet/static/room.js +38 −4
  • test/browser/room-lane.test.mjs +50 −2
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js
index 32fab0f..b741b6b 100644
--- a/internal/meet/static/room.js
+++ b/internal/meet/static/room.js
@@ -173,6 +173,13 @@
// instead (Codex round 2, the branch after S4-9).
const RENDER_STALL_POLLS = 3;
const RENDER_REPLAYS = 2;
+ // How far an element's own clock must advance in a poll to count as
+ // playing rather than twitching. A second of video is a second of
+ // clock; a single frame is thirty milliseconds, and a tile painting
+ // one frame every three seconds is a stall with a tic, not a picture.
+ // Reading any advance at all as health let such a tile reset the
+ // budget below for ever and never earn its reset.
+ const RENDER_MOVED = 0.25;
// §5.4: the tile key a peer's screen gets, built by shareKey.
const SHARE_SUFFIX = '#share';
const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' &&
@@ -3639,7 +3646,7 @@
t.video.srcObject = stream || null;
t.stream = stream || null;
t.track = shown;
- if (stream && !isSelf) startPlaying(key, t);
+ if (shouldPlay(t, isSelf)) startPlaying(key, t);
}
// The frame counter follows the stream, not the element: a tile
// whose track arrives after it was drawn must still be counted, and
@@ -4138,12 +4145,20 @@
return '';
}
const time = t.video.currentTime || 0;
- const moving = time !== t.renderTime;
+ const moving = time - t.renderTime >= RENDER_MOVED;
+ const restarted = time < t.renderTime;
const arriving = decoded > t.renderDecoded;
const first = t.renderDecoded < 0;
t.renderTime = time;
t.renderDecoded = decoded;
if (first) return '';
+ if (restarted) {
+ // The clock went backwards: this element has just been handed its
+ // media again, by this watch or by a tile rebinding. Nothing to
+ // judge until it has run for a poll.
+ t.renderStalls = 0;
+ return '';
+ }
if (moving) {
// Painting. Whatever was wrong is not wrong now, and a replay
// that worked is forgotten rather than counted against the next
@@ -4171,6 +4186,19 @@
return '';
}
+ // shouldPlay answers whether this file may start a tile playing, and
+ // the interesting half is when it may not: while the Play control is
+ // showing, the gesture is the person's to give. §5 offers that
+ // control when the browser's autoplay policy refuses, and a tile
+ // bound more than once — audio arriving before the camera does
+ // exactly that — would otherwise call play() again behind the control
+ // and hide a question nobody had answered. The browser gate's
+ // `playfallback` is what says so out loud. Pure, and lifted by name
+ // in the node test.
+ function shouldPlay(t, isSelf) {
+ return !isSelf && !!t.stream && (!t.play || t.play.hidden);
+ }
+
// replayTile hands an element its media again: the stream taken away
// and given back, and play() asked for once more. Cheaper than a
// renegotiation and enough for a decoder pipeline that has stopped
@@ -4178,12 +4206,18 @@
function replayTile(key, t) {
try {
t.video.srcObject = null;
- t.video.srcObject = t.stream;
+ // A *new* MediaStream over the same tracks, not the one the
+ // element was already holding: assigning the same object back is
+ // where the first draft of this stopped, and it bought a frame or
+ // two before the picture stalled again. A different object is a
+ // different source as far as the element is concerned, and it
+ // rebuilds the whole pipeline rather than nudging it.
+ t.video.srcObject = new MediaStream(t.stream.getTracks());
} catch {
// An element that has gone: the next render builds another.
return;
}
- startPlaying(key, t);
+ if (shouldPlay(t, false)) startPlaying(key, t);
}
// pollFrames is one getStats walk per connection, once a second, and
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs
index 583fcbe..a6fd933 100644
--- a/test/browser/room-lane.test.mjs
+++ b/test/browser/room-lane.test.mjs
@@ -972,12 +972,13 @@ test('the numbers these tests assert against are the ones room.js ships', () =>
const RENDER_STALL_POLLS = pin('RENDER_STALL_POLLS');
const RENDER_REPLAYS = pin('RENDER_REPLAYS');
+const RENDER_MOVED = Number(/const RENDER_MOVED = ([\d.]+);/.exec(src)[1]);
const renderBox = new Function(
- 'RENDER_STALL_POLLS', 'RENDER_REPLAYS',
+ 'RENDER_STALL_POLLS', 'RENDER_REPLAYS', 'RENDER_MOVED',
lift(src, 'judgeRender') + '\nreturn { judgeRender };',
);
-const render = renderBox(RENDER_STALL_POLLS, RENDER_REPLAYS);
+const render = renderBox(RENDER_STALL_POLLS, RENDER_REPLAYS, RENDER_MOVED);
// A tile as judgeRender sees one: an element with a clock, and the
// track it is bound to.
@@ -1004,6 +1005,27 @@ function paint(t, polls) {
});
}
+const play = new Function(lift(src, 'shouldPlay') + '\nreturn shouldPlay;')();
+
+test('a tile is not played behind the Play control the person was offered', () => {
+ // §5 offers a Play control when the browser's autoplay policy refuses
+ // a remote video, and the gesture is then the person's to give. A
+ // tile can be bound more than once — audio arriving before the camera
+ // does exactly that — and calling play() again behind the control
+ // hides a question nobody has answered. The browser gate's
+ // `playfallback` failed twice before this rule existed.
+ const offered = { stream: {}, play: { hidden: false } };
+ assert.equal(play(offered, false), false);
+
+ // Every other case is ordinary: a tile with media and no control
+ // showing is played, a control that has been answered is hidden
+ // again, and the local preview is never this file's to start.
+ assert.equal(play({ stream: {}, play: { hidden: true } }, false), true);
+ assert.equal(play({ stream: {}, play: null }, false), true);
+ assert.equal(play({ stream: null, play: null }, false), false);
+ assert.equal(play({ stream: {}, play: { hidden: true } }, true), false);
+});
+
test('a tile that paints what it decodes is never touched', () => {
const t = fakeTile();
const said = paint(t, [[10, 0.1], [20, 0.4], [30, 0.7], [40, 1.0], [50, 1.3]]);
@@ -1032,6 +1054,32 @@ test('a tile that decodes and does not paint is replayed, then reset', () => {
assert.deepEqual(paint(t, [[130, 1.5], [140, 1.5], [150, 1.5], [160, 1.5]]), ['', '', '', '']);
});
+test('a tile painting one frame every few seconds is stalling, not playing', () => {
+ // The browser gate's own numbers: rendered totals of [22,23,23,23,23,25]
+ // across three seconds. Reading any advance at all as health let a
+ // tile like this reset the budget for ever and never earn the reset
+ // that would rebuild its connection. A second of video is a second of
+ // clock; a frame is thirty milliseconds.
+ const t = fakeTile();
+ const said = paint(t, [[10, 1.50], [20, 1.53], [30, 1.56], [40, 1.59],
+ [50, 1.62], [60, 1.65], [70, 1.68], [80, 1.71], [90, 1.74], [100, 1.77],
+ [110, 1.80], [120, 1.83]]);
+ assert.equal(said.filter((v) => v === 'repair' || v === 'replay').length, RENDER_REPLAYS,
+ JSON.stringify(said));
+ assert.equal(said.filter((v) => v === 'reset').length, 1, JSON.stringify(said));
+});
+
+test('a clock that goes backwards is a re-attach, not a stall', () => {
+ // What this watch's own repair does to the element it repairs.
+ const t = fakeTile();
+ paint(t, [[10, 1.5], [20, 1.5]]);
+ assert.equal(t.renderStalls, 1);
+ assert.equal(render.judgeRender(t, 30), '', 'the clock is where it was');
+ t.video.currentTime = 0;
+ assert.equal(render.judgeRender(t, 40), '');
+ assert.equal(t.renderStalls, 0, 'a re-attached element was judged on its old clock');
+});
+
test('a tile that starts painting again forgets the replay it needed', () => {
const t = fakeTile();
paint(t, [[10, 1.5], [20, 1.5], [30, 1.5], [40, 1.5]]);