kaloko 0.12.1 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
package/src/lib/build.mjs CHANGED
@@ -97,7 +97,7 @@ async function recEntry (run, e) {
97
97
  if (!r?.files || !Object.values(r.files).some(Boolean)) return null
98
98
  const files = Object.fromEntries(Object.entries(r.files).filter(([k, f]) => f && ['webm', 'mp4', 'preview', 'poster'].includes(k) && existsSync(join(run.dir, f))))
99
99
  if (!files.webm && !files.mp4) return null
100
- return { ...files, duration_ms: r.duration_ms, ...(r.width ? { size: [r.width, r.height] } : {}), codec: r.codec || null, mode: r.mode, source: r.source || null, bytes: Object.values(r.bytes || {}).reduce((n, b) => n + (b || 0), 0), ...(r.failed ? { failed: true } : {}), ...(r.error ? { error: r.error } : {}), ...(r.trimmed ? { trimmed: true } : {}), ...(r.offset_ms ? { offset_ms: r.offset_ms } : {}), ...(r.taps?.length ? { taps: r.taps.slice(0, 200) } : {}), ...(r.chapters ? { chapters: r.chapters } : {}) }
100
+ return { ...files, ...(r.still ? { still: true } : {}), duration_ms: r.duration_ms, ...(r.width ? { size: [r.width, r.height] } : {}), codec: r.codec || null, mode: r.mode, source: r.source || null, bytes: Object.values(r.bytes || {}).reduce((n, b) => n + (b || 0), 0), ...(r.failed ? { failed: true } : {}), ...(r.error ? { error: r.error } : {}), ...(r.trimmed ? { trimmed: true } : {}), ...(r.offset_ms ? { offset_ms: r.offset_ms } : {}), ...(r.taps?.length ? { taps: r.taps.slice(0, 200) } : {}), ...(r.chapters ? { chapters: r.chapters } : {}) }
101
101
  }
102
102
 
103
103
  /**
@@ -11,7 +11,7 @@ import { maskSpec, maskList, inPage, maskInPage, rememberPii, knownPii, maskMail
11
11
  * Encodes a PNG screenshot inside the page (no native dependency): the image as WebP (or PNG, lossless) and a
12
12
  * WebP thumbnail for the canvas grid; `marks` are numbered circles drawn on the image (documentation captures).
13
13
  */
14
- export async function encodeImage (page, png, { format = 'webp', quality = 0.82, thumbWidth = 320, thumbQuality = 0.7, marks = [], scale = 1, masks = [] } = {}) {
14
+ export async function encodeImage (page, png, { format = 'webp', quality = 0.82, thumbWidth = 640, thumbQuality = 0.8, marks = [], scale = 1, masks = [] } = {}) {
15
15
  const result = await page.evaluate(async ({ dataUrl, format, quality, thumbWidth, thumbQuality, marks, scale, masks }) => {
16
16
  const img = new Image()
17
17
  await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = dataUrl })
@@ -32,9 +32,18 @@ export async function encodeImage (page, png, { format = 'webp', quality = 0.82,
32
32
  }
33
33
  // the blob's own type: WebKit cannot write WebP and silently returns PNG
34
34
  const blob = (c, type, q) => new Promise((res, rej) => c.toBlob((b) => (b ? b.arrayBuffer().then((buf) => res({ bytes: Array.from(new Uint8Array(buf)), type: b.type })) : rej(new Error('encode failed'))), type, q))
35
+ // the card thumbnail: halved step by step with high-quality smoothing (one 4× or 8× jump makes text fall apart),
36
+ // 640 wide so a card stays sharp on a 2× screen
35
37
  const k = Math.min(1, thumbWidth / img.width)
36
- const t = document.createElement('canvas'); t.width = Math.round(img.width * k); t.height = Math.round(img.height * k)
37
- t.getContext('2d').drawImage(full, 0, 0, t.width, t.height)
38
+ const tw = Math.round(img.width * k); const th = Math.round(img.height * k)
39
+ let src = full
40
+ while (src.width / 2 >= tw * 1.4) {
41
+ const half = document.createElement('canvas'); half.width = Math.round(src.width / 2); half.height = Math.round(src.height / 2)
42
+ const hg = half.getContext('2d'); hg.imageSmoothingEnabled = true; hg.imageSmoothingQuality = 'high'; hg.drawImage(src, 0, 0, half.width, half.height)
43
+ src = half
44
+ }
45
+ const t = document.createElement('canvas'); t.width = tw; t.height = th
46
+ const tg = t.getContext('2d'); tg.imageSmoothingEnabled = true; tg.imageSmoothingQuality = 'high'; tg.drawImage(src, 0, 0, tw, th)
38
47
  return { image: await blob(full, format === 'png' ? 'image/png' : 'image/webp', quality), thumb: await blob(t, 'image/webp', thumbQuality) }
39
48
  }, { dataUrl: `data:image/png;base64,${png.toString('base64')}`, format, quality, thumbWidth, thumbQuality, marks, scale, masks })
40
49
  return { image: Buffer.from(result.image.bytes), thumb: Buffer.from(result.thumb.bytes), imageType: result.image.type, thumbType: result.thumb.type }
@@ -86,7 +86,9 @@ export function versionOfTag (pattern, tag) {
86
86
  // ---------- Keep a Changelog ----------
87
87
 
88
88
  export const GROUPS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
89
- const GROUP_ALIASES = { added: 'Added', new: 'Added', features: 'Added', 'minor changes': 'Added', changed: 'Changed', 'patch changes': 'Fixed', 'major changes': 'Changed', improvements: 'Changed', deprecated: 'Deprecated', removed: 'Removed', fixed: 'Fixed', 'bug fixes': 'Fixed', fixes: 'Fixed', security: 'Security', 'performance improvements': 'Changed' }
89
+ const GROUP_ALIASES = { added: 'Added', new: 'Added', features: 'Added', 'minor changes': 'Added', changed: 'Changed', 'patch changes': 'Fixed', 'major changes': 'Changed', improvements: 'Changed', deprecated: 'Deprecated', removed: 'Removed', fixed: 'Fixed', 'bug fixes': 'Fixed', fixes: 'Fixed', security: 'Security', 'performance improvements': 'Changed',
90
+ // Czech Keep a Changelog headings (Squared, other Czech projects)
91
+ 'přidáno': 'Added', 'nové': 'Added', 'změněno': 'Changed', 'zastaralé': 'Deprecated', 'odstraněno': 'Removed', 'opraveno': 'Fixed', 'bezpečnost': 'Security' }
90
92
  /** A group heading as written by people or release tools ("Bug Fixes", "Minor Changes") in Keep a Changelog terms. */
91
93
  export const groupName = (h) => GROUP_ALIASES[String(h || '').trim().toLowerCase()] || String(h || '').trim()
92
94
 
@@ -12,7 +12,7 @@ import { tmpdir } from 'node:os'
12
12
  import { join, relative, dirname } from 'node:path'
13
13
  import { writeJson, localized } from './util.mjs'
14
14
  import { addCapture, runPaths } from './run.mjs'
15
- import { encodeClip, preset, MAX_SECONDS } from './video.mjs'
15
+ import { encodeClip, preset, MAX_SECONDS, MIN_STEP_MS } from './video.mjs'
16
16
  import { PAGE_HOOK, networkEntries, actionEntries, summarizeTrace, cleanText, TRACE_LIMITS } from './trace.mjs'
17
17
  import { maskSpec, scenarioMasks, paintMasks, scrubDeep, loadKnownPii } from './pii.mjs'
18
18
 
@@ -250,11 +250,17 @@ export async function recordingSession ({ page, run, scenario, locale, settings,
250
250
  const keepClip = want && (want.mode !== 'failed' || failed)
251
251
  if (!keepClip) { await obs.prune(t2); return }
252
252
  const clip = obs.clip(s.t0, error ? t2 : t1)
253
+ // the screencast sends a frame only when the picture changes, and a fast step is captured before the new page's
254
+ // frame even arrives: with fewer than two frames in the step nothing moved worth watching, so no video is kept
255
+ // and the canvas shows the screenshot (a failed step keeps its recording whatever it shows)
256
+ const still = clip.frames.filter((f) => f.t > s.t0).length < 2
257
+ if (still && !failed) { await obs.prune(t2); return }
258
+ const end = Math.max(error ? t2 : t1, s.t0 + MIN_STEP_MS)
253
259
  // the frames stay on disk until the encoder has them; pruning waits in the same queue
254
260
  enqueue(async () => {
255
261
  const base = recBase(run.dir, node.id, locale, vp)
256
262
  await mkdir(dirname(base), { recursive: true })
257
- const r = await encodeClip({ page: await encoder(), frames: clip.frames, events: want.cursor === false ? [] : clip.events, start: s.t0, end: error ? t2 : t1, out: base, quality: want.quality, maxSeconds: want.max_seconds, codec: want.codec || 'auto', mp4: want.mp4 ?? 'auto' })
263
+ const r = await encodeClip({ page: await encoder(), frames: clip.frames, events: want.cursor === false ? [] : clip.events, start: s.t0, end, out: base, quality: want.quality, maxSeconds: want.max_seconds, codec: want.codec || 'auto', mp4: want.mp4 ?? 'auto' })
258
264
  // offset_ms: where the clip starts after the step's start (a trimmed long step keeps its end); the trace aligns by it
259
265
  if (r) await saveRecording({ run, node, locale, viewport: vp, result: { ...r, offset_ms: Math.max(0, Math.round(r.start - s.t0)) }, mode: want.mode, failed, error, captured_at: records?.[0]?.captured_at || null, source: 'screencast' })
260
266
  for (const n of r?.notes || []) notes.add(n)
@@ -308,6 +314,8 @@ export async function saveRecording ({ run, node, locale, viewport, result, mode
308
314
  fps: result.fps || null,
309
315
  codec: result.codec,
310
316
  ...(result.trimmed ? { trimmed: true } : {}),
317
+ // nothing moved during the step: the canvas shows the screenshot in its place
318
+ ...(result.still ? { still: true } : {}),
311
319
  ...(result.offset_ms ? { offset_ms: result.offset_ms } : {}),
312
320
  files,
313
321
  bytes: result.bytes || {},
package/src/lib/video.mjs CHANGED
@@ -257,6 +257,8 @@ export async function encoderCaps (page) {
257
257
  * { files: { webm, mp4, preview, poster }, duration_ms, width, height, codec, fps, trimmed, notes }.
258
258
  */
259
259
  // the encoder page holds one clip at a time: parallel locales and named sessions take turns
260
+ // a step clip shorter than this is held on its last frame: a one-second video jumps past before anyone can look
261
+ export const MIN_STEP_MS = 2500
260
262
  const LOCKS = new WeakMap()
261
263
  export function encodeClip (opts) {
262
264
  if (!opts.page) return encodeClipNow(opts)
package/viewer/index.html CHANGED
@@ -1908,7 +1908,7 @@ function shot (n, v, w, h) {
1908
1908
  if (att) return `<span class="shot shot--${v.mobile ? 'm' : 'd'}" data-vp="${esc(v.name)}"${att.preview ? ` data-prev="${esc(asset(att.preview))}"` : ''}><img loading="lazy" src="${esc(asset(att.poster))}" alt="" style="opacity:.75"><span class="shot__rec" title="${esc(T('recFailed'))}">▶ ✕</span><span class="shot__tag">${v.w}</span></span>`;
1909
1909
  if (!c) return `<span class="shot shot--${v.mobile ? 'm' : 'd'} shot--empty" style="width:${w}px;height:${h}px">${T('noCapture')}</span>`;
1910
1910
  const prev = c.rec?.preview && !RUN.recordings_expired ? asset(c.rec.preview) : null;
1911
- return `<span class="shot shot--${v.mobile ? 'm' : 'd'}" data-vp="${esc(v.name)}"${prev ? ` data-prev="${esc(prev)}"` : ''}><img loading="lazy" src="${esc(asset(c.thumb || c.image || c.png))}" alt="">${c.rec ? `<span class="shot__rec" title="${esc(T('recHint'))}">▶</span>` : ''}<span class="shot__tag">${v.w}</span></span>`;
1911
+ return `<span class="shot shot--${v.mobile ? 'm' : 'd'}" data-vp="${esc(v.name)}"${prev ? ` data-prev="${esc(prev)}"` : ''}><img loading="lazy" src="${esc(asset(c.thumb || c.image || c.png))}"${c.thumb && (c.image || c.png) ? ` data-full="${esc(asset(c.image || c.png))}"` : ''} alt="">${c.rec ? `<span class="shot__rec" title="${esc(T('recHint'))}">▶</span>` : ''}<span class="shot__tag">${v.w}</span></span>`;
1912
1912
  }
1913
1913
  /* Quick signals of a capture (probabilities of "yes"): which ones deserve a look. Purpose and language are good
1914
1914
  when high, broken is good when low; the evaluator is calibrated, so 30 % / 70 % are honest cut-offs. */
@@ -2639,7 +2639,24 @@ function setInv (v) {
2639
2639
  INV_CSS ||= $('invCss') || document.head.appendChild(document.createElement('style'));
2640
2640
  INV_CSS.textContent = `#world :focus-visible,#world .pxd,#world .lane-head,#world .node__title,#world .cur{--inv:${v}}`;
2641
2641
  }
2642
- function applyView () { const v = S.view; $('world').style.transform = `translate(${v.x}px,${v.y}px) scale(${v.k})`; { const far = v.k < 0.6; if ($('world').classList.contains('far') !== far) { $('world').classList.toggle('far', far); requestAnimationFrame(drawEdges); } $('world').classList.toggle('tiny', v.k < 0.25); } setInv(String(Math.min(6, 1 / v.k))); $('zVal').textContent = Math.round(v.k * 100) + ' %'; $('vp').style.backgroundSize = `${22 * v.k}px ${22 * v.k}px`; $('vp').style.backgroundPosition = `${v.x}px ${v.y}px`; renderMini(); presenceView(); }
2642
+ /* A card's thumbnail is made for the card at about 100 %; zoomed in (or on a 2× screen with an older, smaller
2643
+ thumbnail) the visible cards load the full screenshot instead of stretching the thumbnail. */
2644
+ let sharpenT = null;
2645
+ function sharpenShots () {
2646
+ clearTimeout(sharpenT);
2647
+ sharpenT = setTimeout(() => {
2648
+ const dpr = window.devicePixelRatio || 1; const vw = innerWidth; const vh = innerHeight;
2649
+ for (const img of document.querySelectorAll('#nodes .shot img[data-full]')) {
2650
+ if (!img.complete || !img.naturalWidth || img.closest('.shot')?.dataset.playing) continue;
2651
+ const r = img.getBoundingClientRect();
2652
+ if (r.right < 0 || r.bottom < 0 || r.left > vw || r.top > vh || !r.width) continue;
2653
+ if (r.width * dpr > img.naturalWidth * 1.15) { img.src = img.dataset.full; delete img.dataset.full; }
2654
+ }
2655
+ }, 250);
2656
+ }
2657
+ // a thumbnail that finishes loading is checked too (cards render before their images arrive)
2658
+ document.addEventListener('load', (e) => { if (e.target?.matches?.('#nodes .shot img[data-full]')) sharpenShots(); }, true);
2659
+ function applyView () { sharpenShots(); const v = S.view; $('world').style.transform = `translate(${v.x}px,${v.y}px) scale(${v.k})`; { const far = v.k < 0.6; if ($('world').classList.contains('far') !== far) { $('world').classList.toggle('far', far); requestAnimationFrame(drawEdges); } $('world').classList.toggle('tiny', v.k < 0.25); } setInv(String(Math.min(6, 1 / v.k))); $('zVal').textContent = Math.round(v.k * 100) + ' %'; $('vp').style.backgroundSize = `${22 * v.k}px ${22 * v.k}px`; $('vp').style.backgroundPosition = `${v.x}px ${v.y}px`; renderMini(); presenceView(); }
2643
2660
  /* minimap: every node as a status-coloured block, the visible area as a frame; click or drag to move */
2644
2661
  let MINI = null, MINI_NODES = null;
2645
2662
  function renderMini () {
@@ -3046,11 +3063,14 @@ function metaSection (n, cap, v) {
3046
3063
 
3047
3064
  /* ---------- recordings and traces (batch D, #12): video of a step, its markers, the trace timeline ---------- */
3048
3065
  /** A step's video: its own recording, a failed attempt's, or its chapter of the flow recording (from/to in s). */
3066
+ /* A clip where nothing moved, or one so short it only flickers (older runs, before clips were held to 2.5 s), shows
3067
+ as the screenshot instead. */
3068
+ const stillRec = (r) => !!r && (r.still === true || (Number(r.duration_ms) > 0 && Number(r.duration_ms) < 1000));
3049
3069
  function recOf (n, cap, v) {
3050
3070
  if (!n || n.kind === 'decision' || n.kind === 'stack') return null;
3051
- if (cap?.rec && (cap.rec.webm || cap.rec.mp4)) return { ...cap.rec, kind: 'step' };
3071
+ if (cap?.rec && (cap.rec.webm || cap.rec.mp4) && !stillRec(cap.rec)) return { ...cap.rec, kind: 'step' };
3052
3072
  const f = (n.recordings || []).filter((r) => r.locale === S.loc && (r.viewport === v?.name || !cap)).pop();
3053
- if (f && (f.webm || f.mp4)) return { ...f, kind: 'failed' };
3073
+ if (f && (f.webm || f.mp4) && !stillRec(f)) return { ...f, kind: 'failed' };
3054
3074
  const fl = RUN.recordings?.flow?.[S.loc]; const ch = fl?.chapters?.find((c) => c.node === n.id);
3055
3075
  if (ch && (fl.webm || fl.mp4) && (!fl.viewport || !v || fl.viewport === v.name)) return { ...fl, kind: 'chapter', from: ch.start / 1000, to: Math.max(ch.capture, ch.start + 300) / 1000, title: ch.title };
3056
3076
  return null;
@@ -3090,7 +3110,7 @@ function wireVideo (root, rec, cap) {
3090
3110
  vid.ontimeupdate = () => {
3091
3111
  const t = vid.currentTime - from;
3092
3112
  if (pos) pos.style.left = `${Math.min(100, Math.max(0, t / d * 100))}%`;
3093
- if (to && vid.currentTime >= to) vid.pause();
3113
+ if (to && vid.currentTime >= to) { vid.dataset.autoPause = '1'; vid.pause(); vid.dispatchEvent(new Event('clipend')); }
3094
3114
  if (taps && rec.taps?.length) taps.innerHTML = rec.taps.filter((x) => Math.abs(x.t / 1000 - (rec.offset_ms || 0) / 1000 - t) < 0.45).map((x) => `<span class="vtap" style="left:${x.x * 100}%;top:${x.y * 100}%"></span>`).join('');
3095
3115
  // the trace row of this moment, when the Technical tab shows the trace of this capture
3096
3116
  const ms = t * 1000 + (rec.kind === 'step' || rec.kind === 'failed' ? (rec.offset_ms || 0) : 0);
@@ -3098,7 +3118,21 @@ function wireVideo (root, rec, cap) {
3098
3118
  for (const r of rows) { r.classList.remove('is-now'); if (Number(r.dataset.t) <= ms) now = r; }
3099
3119
  now?.classList.add('is-now');
3100
3120
  };
3121
+ // once someone plays a video, the next step's video plays by itself (until they pause one)
3122
+ vid.addEventListener('play', () => { S.vAuto = true; });
3123
+ vid.addEventListener('pause', () => { if (!vid.ended && !vid.dataset.autoPause && !(to && vid.currentTime >= to - 0.05)) S.vAuto = false; delete vid.dataset.autoPause; });
3101
3124
  if (S.vseek != null) { const t = S.vseek; S.vseek = null; seekVideo(vid, t, true); }
3125
+ else if (S.vAuto) { vid.muted = true; seekVideo(vid, 0, true); }
3126
+ }
3127
+ /* The next step's video starts loading while this one plays, so moving on does not wait for the network. */
3128
+ const PRELOADED = [];
3129
+ function preloadNext (n, v) {
3130
+ const next = outEdges(n.id).map((e) => byId(e.to)).find((x) => x && x.kind !== 'decision');
3131
+ const rec = next ? recOf(next, next.captures?.[S.loc]?.[v.name], v) : null;
3132
+ const src = rec && (rec.webm && document.createElement('video').canPlayType('video/webm') ? rec.webm : rec.mp4 || rec.webm);
3133
+ if (!src || RUN.recordings_expired || PRELOADED.some((x) => x.dataset.src === src)) return;
3134
+ const pre = document.createElement('video'); pre.preload = 'auto'; pre.muted = true; pre.dataset.src = src; pre.src = asset(src);
3135
+ PRELOADED.push(pre); if (PRELOADED.length > 3) PRELOADED.shift().removeAttribute('src');
3102
3136
  }
3103
3137
  const TRACES = new Map();
3104
3138
  function loadTrace (cap) {
@@ -3165,6 +3199,8 @@ function wireReplayVideo (n, cap, v) {
3165
3199
  const rec = rpVideoOf(n, cap, v); const stage = $('rpStage'); if (!rec || !stage) return;
3166
3200
  wireVideo(stage, rec, cap);
3167
3201
  const vid = stage.querySelector('video.rec'); if (!vid) return;
3202
+ // a step's own video (not the flow's) hands over to the next step when it ends
3203
+ if (rec.kind !== 'flow') { vid.addEventListener('ended', () => rpAutoNext(n)); vid.addEventListener('clipend', () => rpAutoNext(n)); }
3168
3204
  const chaps = rec.chapters || [];
3169
3205
  stage.querySelectorAll('[data-chap]').forEach((b) => { b.onclick = () => seekVideo(vid, Number(b.dataset.seek), true); });
3170
3206
  // the replay's step and the video's chapter move together: a step change seeks, playing past a chapter marks it
@@ -3301,6 +3337,7 @@ function openLb (id, vpName, opts = {}) {
3301
3337
  if ($('lbMode')) seg($('lbMode'), [...(liveSrc(cap) ? [['live', T('liveMode')]] : []), ...(viewSrc(cap) ? [['view', T('htmlView')]] : []), ...(recOf(n, cap, v) ? [['video', T('videoMode')]] : []), ['shot', T('shotMode')]], S.lbMode === 'video' && !recOf(n, cap, v) ? 'shot' : S.lbMode, (m) => { S.lbMode = m; LIVE.mode = null; openLb(id, v.name); });
3302
3338
  // the step's video and the trace timeline (batch D): markers seek, trace rows jump into the video
3303
3339
  wireVideo(lb, recOf(n, cap, v), cap);
3340
+ if (S.lbMode === 'video') preloadNext(n, v);
3304
3341
  if (cap?.trace || cap?.trace_inline) fillTrace(lb, n, cap, v);
3305
3342
  if ($('lbLiveTools')) renderLiveTools();
3306
3343
  if ($('lbProto')) $('lbProto').onclick = () => ptOpen(id, v.name);
@@ -3597,12 +3634,22 @@ function rpOpen (path) {
3597
3634
  $('lb').hidden = true; S.open = null; closeBrowser();
3598
3635
  renderReplay();
3599
3636
  }
3600
- function rpClose () { S.rp = null; $('rp').hidden = true; setHash(null); }
3637
+ function rpClose () { clearTimeout(S.rpTimer); S.rp = null; $('rp').hidden = true; setHash(null); }
3638
+ /* Replay with video plays on by itself: at the end of a step's video (or after a still step has shown for a while) it
3639
+ moves to the next step when there is one way on. A decision waits for the person; the end of the flow stops. */
3640
+ const STILL_MS = 3000;
3641
+ function rpAutoNext (n) {
3642
+ clearTimeout(S.rpTimer);
3643
+ if (!S.rp || !S.rpVideo || !S.vAuto || S.rp.path[S.rp.path.length - 1] !== n.id) return;
3644
+ const opts = rpNextOptions(n);
3645
+ if (opts.length === 1 && opts[0].n) rpGo(opts[0].n.id);
3646
+ }
3601
3647
  function rpGo (id) { if (!byId(id)) return; S.rp.path.push(id); renderReplay(); }
3602
3648
  function rpBack () { if (S.rp.path.length > 1) { S.rp.path.pop(); renderReplay(); } }
3603
3649
  function rpJump (i) { S.rp.path = S.rp.path.slice(0, i + 1); renderReplay(); }
3604
3650
  function rpNextOptions (n) { return outEdges(n.id).map((e, k) => ({ e, n: byId(e.to), k })); }
3605
3651
  function renderReplay () {
3652
+ clearTimeout(S.rpTimer);
3606
3653
  const cur = S.rp.path[S.rp.path.length - 1], n = byId(cur);
3607
3654
  const v = (S.vp === 'mobile' ? mVp() : dVp()) || viewports()[0];
3608
3655
  const cap = n.captures?.[S.loc]?.[v.name];
@@ -3644,7 +3691,12 @@ function renderReplay () {
3644
3691
  $('rp').querySelectorAll('[data-stack-open]').forEach((b) => { b.onclick = () => openStack(b.dataset.stackOpen); });
3645
3692
  $('rp').querySelector('[data-restart]')?.addEventListener('click', () => rpOpen(null));
3646
3693
  if ($('rpVid')) $('rpVid').onclick = () => { S.rpVideo = !S.rpVideo; renderReplay(); };
3647
- if (S.rpVideo) wireReplayVideo(n, cap, v);
3694
+ if (S.rpVideo) {
3695
+ wireReplayVideo(n, cap, v);
3696
+ // a step without a video of its own (a still page) shows its screenshot for a moment, then playing goes on
3697
+ if (S.vAuto && n.kind !== 'decision' && !rpVideoOf(n, cap, v)) S.rpTimer = setTimeout(() => rpAutoNext(n), STILL_MS);
3698
+ preloadNext(n, v);
3699
+ }
3648
3700
  $('rpStage').scrollTop = 0;
3649
3701
  setHash({ replay: 1, step: cur, path: S.rp.path.join(',') });
3650
3702
  focusNode(cur);