blanchon commited on
Commit
7f23fd2
·
1 Parent(s): e66c992

Eval: 2-round policy + sync-start gate + no auto-advance

Browse files

- Sample 2 rounds per (match, map) instead of 4: one endpoint (round 1
or last, picked deterministically) plus one PRNG-chosen middle round.
- Eval mode no longer auto-advances when the round ends — reviewers
drive navigation explicitly via the eval bar's Prev/Next.
- New sync-start gate in eval mode: the round starts paused and only
auto-plays once every POV tile has emitted `loadeddata` (first frame
decoded), so the comparison view never starts mid-buffer with some
panes blank. A small "Loading N / 10 POVs…" indicator overlays the
grid while we wait; touching play or leaving eval mode releases the
gate immediately. preloadAllPlayers is force-on in eval mode so the
HTTP-level prefetch runs alongside the MSE attach.

src/lib/components/grid-tile.svelte CHANGED
@@ -23,6 +23,7 @@
23
  onLeaderTime?: (t: number) => void;
24
  onLeaderEnded?: () => void;
25
  onBufferedChange?: (player: number, ranges: BufferedRange[]) => void;
 
26
  }
27
 
28
  let {
@@ -40,7 +41,8 @@
40
  onSelect,
41
  onLeaderTime,
42
  onLeaderEnded,
43
- onBufferedChange
 
44
  }: Props = $props();
45
 
46
  let videoEl = $state<HTMLVideoElement | undefined>();
@@ -127,6 +129,11 @@
127
  onBufferedChange(player, out);
128
  }
129
 
 
 
 
 
 
130
  function onKeyDown(e: KeyboardEvent) {
131
  if (!available) return;
132
  if (e.key === 'Enter' || e.key === ' ') {
@@ -177,6 +184,7 @@
177
  ontimeupdate={onTimeUpdate}
178
  onended={onEnded}
179
  onprogress={onProgress}
 
180
  class="size-full object-cover"
181
  ></video>
182
 
 
23
  onLeaderTime?: (t: number) => void;
24
  onLeaderEnded?: () => void;
25
  onBufferedChange?: (player: number, ranges: BufferedRange[]) => void;
26
+ onReady?: (player: number) => void;
27
  }
28
 
29
  let {
 
41
  onSelect,
42
  onLeaderTime,
43
  onLeaderEnded,
44
+ onBufferedChange,
45
+ onReady
46
  }: Props = $props();
47
 
48
  let videoEl = $state<HTMLVideoElement | undefined>();
 
129
  onBufferedChange(player, out);
130
  }
131
 
132
+ function onLoadedData() {
133
+ // First decoded frame available — the tile is now safe to play.
134
+ onReady?.(player);
135
+ }
136
+
137
  function onKeyDown(e: KeyboardEvent) {
138
  if (!available) return;
139
  if (e.key === 'Enter' || e.key === ' ') {
 
184
  ontimeupdate={onTimeUpdate}
185
  onended={onEnded}
186
  onprogress={onProgress}
187
+ onloadeddata={onLoadedData}
188
  class="size-full object-cover"
189
  ></video>
190
 
src/lib/components/perspective-grid.svelte CHANGED
@@ -19,6 +19,7 @@
19
  onTimeUpdate?: (t: number) => void;
20
  onLeaderEnded?: () => void;
21
  onBufferedChange?: (ranges: BufferedRange[]) => void;
 
22
  }
23
 
24
  let {
@@ -33,7 +34,8 @@
33
  onSelect,
34
  onTimeUpdate,
35
  onLeaderEnded,
36
- onBufferedChange
 
37
  }: Props = $props();
38
 
39
  type PerPlayer = {
@@ -152,6 +154,7 @@
152
  {onLeaderTime}
153
  onLeaderEnded={() => onLeaderEnded?.()}
154
  onBufferedChange={handleTileBuffered}
 
155
  />
156
  <div class="flex items-center gap-1.5 px-0.5 text-xs">
157
  <span
@@ -192,6 +195,7 @@
192
  {onLeaderTime}
193
  onLeaderEnded={() => onLeaderEnded?.()}
194
  onBufferedChange={handleTileBuffered}
 
195
  />
196
  <div class="flex items-center gap-1.5 px-0.5 text-xs">
197
  <span
 
19
  onTimeUpdate?: (t: number) => void;
20
  onLeaderEnded?: () => void;
21
  onBufferedChange?: (ranges: BufferedRange[]) => void;
22
+ onReady?: (player: number) => void;
23
  }
24
 
25
  let {
 
34
  onSelect,
35
  onTimeUpdate,
36
  onLeaderEnded,
37
+ onBufferedChange,
38
+ onReady
39
  }: Props = $props();
40
 
41
  type PerPlayer = {
 
154
  {onLeaderTime}
155
  onLeaderEnded={() => onLeaderEnded?.()}
156
  onBufferedChange={handleTileBuffered}
157
+ {onReady}
158
  />
159
  <div class="flex items-center gap-1.5 px-0.5 text-xs">
160
  <span
 
195
  {onLeaderTime}
196
  onLeaderEnded={() => onLeaderEnded?.()}
197
  onBufferedChange={handleTileBuffered}
198
+ {onReady}
199
  />
200
  <div class="flex items-center gap-1.5 px-0.5 text-xs">
201
  <span
src/lib/eval.ts CHANGED
@@ -218,13 +218,13 @@ function prng(seed: number): number {
218
  }
219
 
220
  /**
221
- * Evaluation policy: per (match, map), sample 4 rounds — first, last, and
222
- * two deterministic random rounds in the middle. Sorted by match_id then
 
223
  * map_index to match the rest of the app's canonical order.
224
  *
225
- * Validating ALL 4 candidates of a (match, map) without flagging implies the
226
- * whole match-map is good; flagging any one of the 4 marks the match-map as
227
- * having issues.
228
  */
229
  export function buildEvalQueue(matches: Match[]): EvalCandidate[] {
230
  const sorted = matches
@@ -236,19 +236,26 @@ export function buildEvalQueue(matches: Match[]): EvalCandidate[] {
236
  const total = m.rounds_played;
237
  if (!total || total < 1) continue;
238
 
239
- const picks = new Set<number>([1, total]);
240
- // Two PRNG-picked middle rounds when there's room for them.
241
- if (total >= 4) {
242
- const seedBase = m.match_id * 31 + m.map_name.length * 17 + (m.map_index ?? 0);
 
 
 
 
 
 
 
243
  const span = total - 2; // pick from [2, total - 1]
244
  let attempts = 0;
245
- while (picks.size < 4 && attempts < 16) {
246
- const r = 2 + Math.floor(prng(seedBase + attempts) * span);
247
  picks.add(r);
248
  attempts++;
249
  }
250
- } else if (total === 3) {
251
- picks.add(2);
252
  }
253
 
254
  for (const round of [...picks].sort((a, b) => a - b)) {
 
218
  }
219
 
220
  /**
221
+ * Evaluation policy: per (match, map), sample 2 rounds — one endpoint
222
+ * (deterministically either the first or the last round) plus one
223
+ * deterministic random round in the middle. Sorted by match_id then
224
  * map_index to match the rest of the app's canonical order.
225
  *
226
+ * Validating BOTH candidates of a (match, map) without flagging implies the
227
+ * whole match-map is good; flagging either one marks it as having issues.
 
228
  */
229
  export function buildEvalQueue(matches: Match[]): EvalCandidate[] {
230
  const sorted = matches
 
236
  const total = m.rounds_played;
237
  if (!total || total < 1) continue;
238
 
239
+ const seedBase = m.match_id * 31 + m.map_name.length * 17 + (m.map_index ?? 0);
240
+
241
+ const picks = new Set<number>();
242
+ // Endpoint: either round 1 or the last round, picked deterministically.
243
+ const endpoint = prng(seedBase) < 0.5 || total === 1 ? 1 : total;
244
+ picks.add(endpoint);
245
+
246
+ // One PRNG-picked middle round when there's room. For tiny matches
247
+ // (≤ 2 rounds) the second pick falls back to the other endpoint so
248
+ // every match-map still contributes 2 candidates when possible.
249
+ if (total >= 3) {
250
  const span = total - 2; // pick from [2, total - 1]
251
  let attempts = 0;
252
+ while (picks.size < 2 && attempts < 16) {
253
+ const r = 2 + Math.floor(prng(seedBase + 100 + attempts) * span);
254
  picks.add(r);
255
  attempts++;
256
  }
257
+ } else if (total === 2) {
258
+ picks.add(endpoint === 1 ? 2 : 1);
259
  }
260
 
261
  for (const round of [...picks].sort((a, b) => a - b)) {
src/routes/match/[matchId]/[mapName]/+page.svelte CHANGED
@@ -81,6 +81,14 @@
81
  let preloadAllPlayers = $state(false);
82
  let preloadedPlayers = $state(new Set<number>());
83
 
 
 
 
 
 
 
 
 
84
  let viewMode = $state<'single' | 'grid'>(readViewFromUrl());
85
 
86
  // Evaluation mode driven by ?eval=1&i=N. Queue is deterministic from the
@@ -146,6 +154,15 @@
146
  activeVideoEnded = false;
147
  bufferedRanges = [];
148
  preloadedPlayers = new Set();
 
 
 
 
 
 
 
 
 
149
  try {
150
  const rows = await listRoundPreviews(data.match.match_id, data.match.map_name, round, {
151
  signal: ctrl.signal
@@ -173,6 +190,39 @@
173
  preloadedPlayers = next;
174
  }
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  // When preloadAllPlayers is on, fetch every (other) player's chunks so
177
  // they're hot in the HTTP cache. The active player's chunks are already
178
  // being fetched by the video player itself.
@@ -253,6 +303,12 @@
253
  }
254
 
255
  function handlePlaybackEnded() {
 
 
 
 
 
 
256
  const ri = data.rounds.findIndex((r) => r.round === currentRoundNum);
257
  if (ri >= 0 && ri < data.rounds.length - 1) {
258
  selectRound(data.rounds[ri + 1].round);
@@ -273,6 +329,9 @@
273
  virtualTime = 0;
274
  activeVideoEnded = false;
275
  }
 
 
 
276
  masterPaused = !masterPaused;
277
  }
278
 
@@ -516,25 +575,35 @@
516
  {initialVirtualTime}
517
  />
518
  {:else}
519
- <PerspectiveGrid
520
- {previews}
521
- {currentPlayer}
522
- {availablePlayers}
523
- {playerDurations}
524
- {virtualTime}
525
- paused={masterPaused}
526
- muted={masterMuted}
527
- playbackRate={masterPlaybackRate}
528
- onPausedChange={(p) => (masterPaused = p)}
529
- onSelect={(p) => {
530
- selectPlayer(p);
531
- // Stay in grid mode after selecting; the new
532
- // current player just becomes the audio leader.
533
- }}
534
- onTimeUpdate={handleTimeUpdate}
535
- onLeaderEnded={handleVideoEnded}
536
- onBufferedChange={(r) => (bufferedRanges = r)}
537
- />
 
 
 
 
 
 
 
 
 
 
538
  {/if}
539
 
540
  {#if previews.length}
 
81
  let preloadAllPlayers = $state(false);
82
  let preloadedPlayers = $state(new Set<number>());
83
 
84
+ // Players whose first decoded frame is on screen — populated by GridTile
85
+ // `loadeddata` events. Used in eval mode to gate auto-play until every
86
+ // POV is showing.
87
+ let readyPlayers = $state(new Set<number>());
88
+ // True between (round change) and (all 10 tiles ready) while in eval
89
+ // mode. Auto-clears once all tiles report ready, which auto-resumes play.
90
+ let awaitingAllReady = $state(false);
91
+
92
  let viewMode = $state<'single' | 'grid'>(readViewFromUrl());
93
 
94
  // Evaluation mode driven by ?eval=1&i=N. Queue is deterministic from the
 
154
  activeVideoEnded = false;
155
  bufferedRanges = [];
156
  preloadedPlayers = new Set();
157
+ readyPlayers = new Set();
158
+ // In eval mode every round starts paused and only auto-plays once
159
+ // every POV tile has its first frame decoded — so reviewers always
160
+ // see all 10 perspectives in sync from t=0.
161
+ if (inEvalMode) {
162
+ masterPaused = true;
163
+ awaitingAllReady = true;
164
+ preloadAllPlayers = true;
165
+ }
166
  try {
167
  const rows = await listRoundPreviews(data.match.match_id, data.match.map_name, round, {
168
  signal: ctrl.signal
 
190
  preloadedPlayers = next;
191
  }
192
 
193
+ function markPlayerVisible(p: number) {
194
+ if (readyPlayers.has(p)) return;
195
+ const next = new Set(readyPlayers);
196
+ next.add(p);
197
+ readyPlayers = next;
198
+ }
199
+
200
+ // Eval-mode auto-play gate: once every POV present in the round has
201
+ // reported its first frame, drop the awaiting flag and resume playback
202
+ // (unless the user has already pressed play, in which case master remains
203
+ // unpaused and this is a no-op).
204
+ $effect(() => {
205
+ if (!awaitingAllReady) return;
206
+ const expected = playerDurations.size;
207
+ if (!expected) return;
208
+ if (readyPlayers.size < expected) return;
209
+ untrack(() => {
210
+ awaitingAllReady = false;
211
+ masterPaused = false;
212
+ });
213
+ });
214
+
215
+ // Leaving eval mode shouldn't strand the player paused with a half-active
216
+ // gate — release both immediately.
217
+ $effect(() => {
218
+ if (inEvalMode) return;
219
+ if (!awaitingAllReady) return;
220
+ untrack(() => {
221
+ awaitingAllReady = false;
222
+ masterPaused = false;
223
+ });
224
+ });
225
+
226
  // When preloadAllPlayers is on, fetch every (other) player's chunks so
227
  // they're hot in the HTTP cache. The active player's chunks are already
228
  // being fetched by the video player itself.
 
303
  }
304
 
305
  function handlePlaybackEnded() {
306
+ // In eval mode, never auto-advance to the next round — the reviewer
307
+ // drives navigation with the eval bar's Prev/Next buttons.
308
+ if (inEvalMode) {
309
+ masterPaused = true;
310
+ return;
311
+ }
312
  const ri = data.rounds.findIndex((r) => r.round === currentRoundNum);
313
  if (ri >= 0 && ri < data.rounds.length - 1) {
314
  selectRound(data.rounds[ri + 1].round);
 
329
  virtualTime = 0;
330
  activeVideoEnded = false;
331
  }
332
+ // User overrides the eval-mode auto-resume gate as soon as they
333
+ // touch the play button.
334
+ awaitingAllReady = false;
335
  masterPaused = !masterPaused;
336
  }
337
 
 
575
  {initialVirtualTime}
576
  />
577
  {:else}
578
+ <div class="relative">
579
+ <PerspectiveGrid
580
+ {previews}
581
+ {currentPlayer}
582
+ {availablePlayers}
583
+ {playerDurations}
584
+ {virtualTime}
585
+ paused={masterPaused}
586
+ muted={masterMuted}
587
+ playbackRate={masterPlaybackRate}
588
+ onPausedChange={(p) => (masterPaused = p)}
589
+ onSelect={(p) => {
590
+ selectPlayer(p);
591
+ // Stay in grid mode after selecting; the new
592
+ // current player just becomes the audio leader.
593
+ }}
594
+ onTimeUpdate={handleTimeUpdate}
595
+ onLeaderEnded={handleVideoEnded}
596
+ onBufferedChange={(r) => (bufferedRanges = r)}
597
+ onReady={markPlayerVisible}
598
+ />
599
+ {#if awaitingAllReady && playerDurations.size > 0}
600
+ <div
601
+ class="pointer-events-none absolute top-3 right-3 z-30 rounded-md border border-amber-500/40 bg-amber-500/15 px-2.5 py-1 font-mono text-[11px] text-amber-700 tabular-nums shadow-sm backdrop-blur-sm dark:text-amber-200"
602
+ >
603
+ Loading {readyPlayers.size} / {playerDurations.size} POVs…
604
+ </div>
605
+ {/if}
606
+ </div>
607
  {/if}
608
 
609
  {#if previews.length}