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
2 files changed,
+88
−6
internal/meet/static/room.js+38 −4test/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]]); |