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 +1 -1
- package/src/lib/build.mjs +1 -1
- package/src/lib/capture.mjs +12 -3
- package/src/lib/changelog-core.mjs +3 -1
- package/src/lib/recording.mjs +10 -2
- package/src/lib/video.mjs +2 -0
- package/viewer/index.html +59 -7
package/package.json
CHANGED
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
|
/**
|
package/src/lib/capture.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
37
|
-
|
|
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
|
|
package/src/lib/recording.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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);
|