kaloko 0.21.0 → 0.21.1
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
|
@@ -102,9 +102,12 @@ kaloko schedule hook <id> # URL + secret that start the run after a deploy
|
|
|
102
102
|
- Findings: `GET …/scenarios/<id>/findings` (MCP `list_findings`) groups a
|
|
103
103
|
scenario's open results by check, site, locale and step, with a status
|
|
104
104
|
people set (acknowledged, ignored with a reason) and fixed when no page
|
|
105
|
-
fails. A scenario may open one
|
|
106
|
-
(`PUT …/findings/sync { tracker, min_severity
|
|
107
|
-
|
|
105
|
+
fails. A scenario may open one issue per new finding in Jira, Linear,
|
|
106
|
+
GitHub Issues or Plane (`PUT …/findings/sync { tracker, min_severity,
|
|
107
|
+
github?, plane?, routes? }`, Developer / Tester; routes send checks or
|
|
108
|
+
packs to another tracker): at most 20 per run, a comment on big changes,
|
|
109
|
+
closed when fixed. With `traffic:`, findings and issues carry clicks,
|
|
110
|
+
impressions and impact. Switching it
|
|
108
111
|
on or ignoring findings is the team's call, not an agent's.
|
|
109
112
|
- Does not work for: native apps, local or private addresses (localhost, IPs,
|
|
110
113
|
`.local`), header credentials, stacks listed by crawl, script or file.
|
package/src/lib/evaluate.mjs
CHANGED
|
@@ -449,7 +449,7 @@ function triageContext ({ config, root, settings, report, fetchImpl, uncertain =
|
|
|
449
449
|
const video = side?.files?.webm || side?.files?.mp4
|
|
450
450
|
// a recording that cannot be read costs its frames only, never the screen's findings
|
|
451
451
|
if (video) try {
|
|
452
|
-
const frames = await chooseFrames({ page: await page(), file: join(runDir, video), taps: (side.taps || []).map((t) => t.t ?? t), max: Math.min(settings.frames_per_step, framesLeft), maxW: settings.frame_width || 640 })
|
|
452
|
+
const frames = await chooseFrames({ page: await page(), file: join(runDir, video), taps: (side.taps || []).map((t) => t.t ?? t), max: Math.min(settings.frames_per_step, framesLeft), maxW: settings.frame_width || 640, cacheDir })
|
|
453
453
|
framesLeft -= frames.length; report.triage.frames += frames.length
|
|
454
454
|
for (const batch of frameBatches(frames)) {
|
|
455
455
|
const r = await ask(w, { kind: 'motion', context: { ...context, frames: batch.map((f) => ({ t_ms: f.t, why: f.why })) }, images: batch.map((f) => f.data) }, { frames: batch.map((f) => f.t) })
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Pure: no Node APIs. The shapes are documented in help/reference/health-api.md.
|
|
9
9
|
import { parseHealthKey } from './health-index.mjs'
|
|
10
10
|
import { severityRank, SEVERITIES } from './health-core.mjs'
|
|
11
|
+
import { impactOf } from './traffic-core.mjs'
|
|
11
12
|
|
|
12
13
|
export const HEALTH_FINDINGS_SCHEMA = 'kaloko.health-findings.v1'
|
|
13
14
|
/** What a person may set; fixed is never set, it is derived. */
|
|
@@ -45,10 +46,13 @@ export function findingId (fp) {
|
|
|
45
46
|
/**
|
|
46
47
|
* Findings of a flow from its open results. rows: health_open rows ({ key, severity, url, first_run, first_at, misses
|
|
47
48
|
* }); checks: { HC…: { text, pack } } of the newest run's index; edit: "step|page" → edit_url; seen: { run, at } of
|
|
48
|
-
* the newest run (last seen of what it checked)
|
|
49
|
-
*
|
|
49
|
+
* the newest run (last seen of what it checked); traffic: "step|page" → the page's Search Console and GA4 numbers
|
|
50
|
+
* (#157). Returns findings, worst first: { id, fingerprint, check, text, pack, site, locale, step, severity, count,
|
|
51
|
+
* unchecked, samples: [{ page, url, edit_url, traffic? }], first_seen, last_seen, traffic?, impact? }. With traffic,
|
|
52
|
+
* a finding adds up clicks and impressions of its pages that have numbers (`traffic.pages` of them) and its impact
|
|
53
|
+
* (the sum of each page's impact, traffic-core.mjs impactOf); without, it has neither.
|
|
50
54
|
*/
|
|
51
|
-
export function groupFindings (rows, { checks = {}, edit = {}, seen = null } = {}) {
|
|
55
|
+
export function groupFindings (rows, { checks = {}, edit = {}, seen = null, traffic = null } = {}) {
|
|
52
56
|
const out = new Map()
|
|
53
57
|
for (const r of rows || []) {
|
|
54
58
|
const k = parseHealthKey(r.key)
|
|
@@ -64,9 +68,15 @@ export function groupFindings (rows, { checks = {}, edit = {}, seen = null } = {
|
|
|
64
68
|
if (r.misses > 0) f.unchecked++
|
|
65
69
|
else if (seen) f.last_seen = seen
|
|
66
70
|
if (r.first_at && (!f.first_seen || r.first_at < f.first_seen.at)) f.first_seen = { run: r.first_run, at: r.first_at }
|
|
71
|
+
const ek = `${k.step}|${k.page}`
|
|
72
|
+
const t = traffic && Object.hasOwn(traffic, ek) && traffic[ek] && typeof traffic[ek] === 'object' ? traffic[ek] : null
|
|
73
|
+
if (t) {
|
|
74
|
+
const sum = (f.traffic ||= { clicks: 0, impressions: 0, pages: 0 })
|
|
75
|
+
sum.clicks += Number(t.clicks) || 0; sum.impressions += Number(t.impressions) || 0; sum.pages++
|
|
76
|
+
f.impact = (f.impact || 0) + impactOf(t, r.severity)
|
|
77
|
+
}
|
|
67
78
|
if (f.samples.length < SAMPLE_MAX) {
|
|
68
|
-
|
|
69
|
-
f.samples.push({ page: k.page || null, url: r.url || null, edit_url: (edit && Object.hasOwn(edit, ek) ? edit[ek] : null) || null })
|
|
79
|
+
f.samples.push({ page: k.page || null, url: r.url || null, edit_url: (edit && Object.hasOwn(edit, ek) ? edit[ek] : null) || null, ...(t ? { traffic: { clicks: Number(t.clicks) || 0, impressions: Number(t.impressions) || 0 } } : {}) })
|
|
70
80
|
}
|
|
71
81
|
}
|
|
72
82
|
return [...out.values()].sort(worstFirst)
|
|
@@ -79,8 +89,8 @@ export const FINDINGS_SUMMARY_SCHEMA = 'kaloko.health-findings-summary.v1'
|
|
|
79
89
|
* the flow keeps open, plus the findings whose every page the run fixed (`fixed`: the diff's fixed results as rows;
|
|
80
90
|
* count 0, last seen `previous`). An open finding counts the pages the run fixed in `fixed` too.
|
|
81
91
|
*/
|
|
82
|
-
export function summarizeFindings (rows, { checks = {}, edit = {}, seen = null, fixed = [], previous = null } = {}) {
|
|
83
|
-
const list = groupFindings(rows, { checks, edit, seen })
|
|
92
|
+
export function summarizeFindings (rows, { checks = {}, edit = {}, seen = null, fixed = [], previous = null, traffic = null } = {}) {
|
|
93
|
+
const list = groupFindings(rows, { checks, edit, seen, traffic })
|
|
84
94
|
const at = new Map(list.map((f) => [f.id, f]))
|
|
85
95
|
for (const g of groupFindings(fixed, { checks, edit })) {
|
|
86
96
|
const f = at.get(g.id)
|
|
@@ -114,3 +124,54 @@ export const atLeastSeverity = (severity, min) => SEVERITIES.includes(min) && se
|
|
|
114
124
|
* the issue last said.
|
|
115
125
|
*/
|
|
116
126
|
export const countChanged = (before, now) => before != null && Math.abs(now - before) >= Math.max(3, Math.ceil(before * 0.25))
|
|
127
|
+
|
|
128
|
+
// ---------- where issues go (#191) ----------
|
|
129
|
+
|
|
130
|
+
/** Trackers the findings sync can open issues in. */
|
|
131
|
+
export const SYNC_TRACKERS = ['jira', 'linear', 'github', 'plane']
|
|
132
|
+
/** Routes a scenario may have; checks and packs per route. */
|
|
133
|
+
export const ROUTE_MAX = 20
|
|
134
|
+
const ROUTE_LIST_MAX = 50
|
|
135
|
+
const CHECK_PATTERN = /^[A-Z]{1,6}[0-9]{0,6}\*?$/
|
|
136
|
+
const PACK = /^[a-z][a-z0-9_-]{0,30}$/
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Routes of a sync setting from the API: [{ tracker, checks?: ['HC127', 'HC1*'], packs?: ['aeo'] }], in order. A check
|
|
140
|
+
* ends with * to take every check that starts so (HC1* is HC100 to HC199 and HC1000…). Throws an Error that says
|
|
141
|
+
* what is wrong; [] for none.
|
|
142
|
+
*/
|
|
143
|
+
export function cleanRoutes (list) {
|
|
144
|
+
if (list == null) return []
|
|
145
|
+
if (!Array.isArray(list)) throw new Error('routes is a list of { tracker, checks, packs }.')
|
|
146
|
+
if (list.length > ROUTE_MAX) throw new Error(`At most ${ROUTE_MAX} routes.`)
|
|
147
|
+
return list.map((r, i) => {
|
|
148
|
+
if (!r || typeof r !== 'object') throw new Error(`Route ${i + 1} is not an object.`)
|
|
149
|
+
const tracker = String(r.tracker || '')
|
|
150
|
+
if (!SYNC_TRACKERS.includes(tracker)) throw new Error(`Route ${i + 1}: tracker is ${SYNC_TRACKERS.join(', ')}.`)
|
|
151
|
+
const words = (v, name) => {
|
|
152
|
+
if (v == null) return []
|
|
153
|
+
if (!Array.isArray(v) || v.length > ROUTE_LIST_MAX) throw new Error(`Route ${i + 1}: ${name} is a list of at most ${ROUTE_LIST_MAX}.`)
|
|
154
|
+
return [...new Set(v.map((x) => String(x ?? '').trim()))]
|
|
155
|
+
}
|
|
156
|
+
const checks = words(r.checks, 'checks').map((c) => c.toUpperCase())
|
|
157
|
+
const packs = words(r.packs, 'packs').map((p) => p.toLowerCase())
|
|
158
|
+
const badCheck = checks.find((c) => !CHECK_PATTERN.test(c) || c === '*')
|
|
159
|
+
if (badCheck !== undefined) throw new Error(`Route ${i + 1}: ${badCheck.slice(0, 20) || '(empty)'} is not a check id (HC127) or a prefix (HC1*).`)
|
|
160
|
+
const badPack = packs.find((p) => !PACK.test(p))
|
|
161
|
+
if (badPack !== undefined) throw new Error(`Route ${i + 1}: ${badPack.slice(0, 20) || '(empty)'} is not a pack name (seo, aeo).`)
|
|
162
|
+
if (!checks.length && !packs.length) throw new Error(`Route ${i + 1}: name checks or packs.`)
|
|
163
|
+
return { tracker, ...(checks.length ? { checks } : {}), ...(packs.length ? { packs } : {}) }
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const checkMatches = (pattern, check) => pattern.endsWith('*') ? String(check).startsWith(pattern.slice(0, -1)) : pattern === check
|
|
168
|
+
/**
|
|
169
|
+
* The tracker a finding's issue goes to: the first route that names its check (or a prefix of it) or its pack, else
|
|
170
|
+
* the scenario's tracker.
|
|
171
|
+
*/
|
|
172
|
+
export function routeOf (f, routes = [], fallback = null) {
|
|
173
|
+
for (const r of routes || []) {
|
|
174
|
+
if ((r.checks || []).some((c) => checkMatches(c, f.check)) || (f.pack && (r.packs || []).includes(String(f.pack).toLowerCase()))) return r.tracker
|
|
175
|
+
}
|
|
176
|
+
return fallback
|
|
177
|
+
}
|
package/src/lib/health-index.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// (worker/src/health-index.js); the shapes are documented in help/reference/health-api.md.
|
|
9
9
|
import { healthOfNode, levelOf, findingSeverity, severityRank } from './health-core.mjs'
|
|
10
10
|
import { worst } from './manifest-core.mjs'
|
|
11
|
+
import { trafficView } from './traffic-core.mjs'
|
|
11
12
|
|
|
12
13
|
export const HEALTH_INDEX_SCHEMA = 'kaloko.health-index.v1'
|
|
13
14
|
export const HEALTH_AGGREGATE_SCHEMA = 'kaloko.health-aggregate.v1'
|
|
@@ -42,14 +43,16 @@ const firstUrl = (vps) => { for (const c of Object.values(vps || {})) if (c?.url
|
|
|
42
43
|
* [{ locale, step, check, counts, top: [{ page, path, url, verdict, evidence }] }], open: [[key, severity, url]],
|
|
43
44
|
* open_total, truncated, passed, edit }. passed says which checks passed on which page: { "step|locale": { sigs:
|
|
44
45
|
* ["HC103,HC112", …], pages: { <page>: <index into sigs> } } }; only a result that passed counts as fixed later
|
|
45
|
-
* (passedIn). edit maps "step|page" to the edit_url of a stack page with open results (findings link it)
|
|
46
|
+
* (passedIn). edit maps "step|page" to the edit_url of a stack page with open results (findings link it); traffic
|
|
47
|
+
* maps "step|page" to the Search Console and GA4 numbers of such a page when its stack has `traffic:` (#157), for the
|
|
48
|
+
* same pages and bound (findings add them up, an issue shows them).
|
|
46
49
|
*/
|
|
47
50
|
export function buildHealthIndex (manifest, stacks = {}) {
|
|
48
51
|
const checks = Object.create(null); const groups = new Map(); const open = []; const pages = new Set(); const ok = new Map()
|
|
49
52
|
// where a page with open results is edited (a stack item's edit_url): "step|page" → URL, kept below for the open list only
|
|
50
|
-
const edits = new Map()
|
|
53
|
+
const edits = new Map(); const visits = new Map()
|
|
51
54
|
const totals = { results: 0, ...blank() }
|
|
52
|
-
const add = (step, locale, page, path, url, check, verdict, evidence, editUrl = null) => {
|
|
55
|
+
const add = (step, locale, page, path, url, check, verdict, evidence, editUrl = null, traffic = null) => {
|
|
53
56
|
if (!VERDICTS.includes(verdict)) return
|
|
54
57
|
pages.add(`${step}|${page}`)
|
|
55
58
|
const gk = `${locale}\u0000${step}\u0000${check}`
|
|
@@ -63,6 +66,7 @@ export function buildHealthIndex (manifest, stacks = {}) {
|
|
|
63
66
|
if (list.length < TOP_KEEP) list.push({ page, ...(path ? { path } : {}), url: url || null, verdict, ...(evidence ? { evidence: cut(evidence) } : {}) })
|
|
64
67
|
open.push([healthKey(step, locale, check, page), severity, url || null])
|
|
65
68
|
if (editUrl && typeof editUrl === 'string' && /^https?:\/\//i.test(editUrl)) edits.set(`${step}|${page}`, cut(editUrl, 500))
|
|
69
|
+
if (traffic && typeof traffic === 'object') { const t = trafficView(traffic); if (t && Object.keys(t).length) visits.set(`${step}|${page}`, t) }
|
|
66
70
|
}
|
|
67
71
|
for (const n of manifest?.nodes || []) {
|
|
68
72
|
if (n.kind === 'decision') continue
|
|
@@ -85,7 +89,7 @@ export function buildHealthIndex (manifest, stacks = {}) {
|
|
|
85
89
|
if (!was || worst([was.v, x[0]]) !== was.v) per.set(k, { locale, id, v: x[0], e: x[1] })
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
|
-
for (const { locale, id, v, e } of per.values()) add(n.id, locale, String(it.id), it.path || null, it.url || firstUrl(it.captures?.[locale]), id, v, e, it.edit_url)
|
|
92
|
+
for (const { locale, id, v, e } of per.values()) add(n.id, locale, String(it.id), it.path || null, it.url || firstUrl(it.captures?.[locale]), id, v, e, it.edit_url, it.traffic)
|
|
89
93
|
}
|
|
90
94
|
continue
|
|
91
95
|
}
|
|
@@ -115,16 +119,18 @@ export function buildHealthIndex (manifest, stacks = {}) {
|
|
|
115
119
|
// edit links of the pages in the kept open list only, at most EDIT_MAX of them (worst first, like the list)
|
|
116
120
|
const kept = open.slice(0, OPEN_MAX)
|
|
117
121
|
const edit = Object.create(null); let links = 0
|
|
122
|
+
const traffic = Object.create(null); let numbers = 0
|
|
118
123
|
for (const [key] of kept) {
|
|
119
|
-
if (links >= EDIT_MAX) break
|
|
124
|
+
if (links >= EDIT_MAX && numbers >= EDIT_MAX) break
|
|
120
125
|
const { step, page } = parseHealthKey(key)
|
|
121
126
|
const k = `${step}|${page}`
|
|
122
|
-
if (edits.has(k) && !Object.hasOwn(edit, k)) { edit[k] = edits.get(k); links++ }
|
|
127
|
+
if (links < EDIT_MAX && edits.has(k) && !Object.hasOwn(edit, k)) { edit[k] = edits.get(k); links++ }
|
|
128
|
+
if (numbers < EDIT_MAX && visits.has(k) && !Object.hasOwn(traffic, k)) { traffic[k] = visits.get(k); numbers++ }
|
|
123
129
|
}
|
|
124
130
|
return {
|
|
125
131
|
schema: HEALTH_INDEX_SCHEMA, checks, totals: { ...totals, pages: pages.size },
|
|
126
132
|
groups: [...groups.values()].map(({ fails, unsure, ...g }) => ({ ...g, top: [...fails, ...unsure].slice(0, TOP_KEEP) })),
|
|
127
|
-
open: kept, open_total: open.length, truncated: open.length > OPEN_MAX, passed, edit
|
|
133
|
+
open: kept, open_total: open.length, truncated: open.length > OPEN_MAX, passed, edit, ...(numbers ? { traffic } : {})
|
|
128
134
|
}
|
|
129
135
|
}
|
|
130
136
|
|
package/src/lib/triage.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID straight to Workers AI. Findings never change a verdict.
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { readFile } from 'node:fs/promises'
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
7
8
|
import { readJson, writeJson, sha256 } from './util.mjs'
|
|
8
9
|
import { buildTriageRequest, parseTriage, mergeFindings, clefModelId, TRIAGE_PROMPT_VERSION, MAX_TRIAGE_IMAGES } from './triage-core.mjs'
|
|
9
10
|
|
|
@@ -77,15 +78,24 @@ export function hostedTriageSender (config, report) {
|
|
|
77
78
|
return send
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// bump when the choice of frames or their encoding changes, so cached frames are chosen again
|
|
82
|
+
const FRAMES_VERSION = 1
|
|
83
|
+
|
|
80
84
|
/**
|
|
81
85
|
* Frames of a recording for the motion questions, chosen in a browser page: around each tap, where the picture changes
|
|
82
86
|
* most between samples (a jump, a flash, a message), and the last frame. Returns [{ t, why, data }] in time order,
|
|
83
|
-
* at most `max` frames, each a WebP data URL `maxW` wide.
|
|
87
|
+
* at most `max` frames, each a WebP data URL `maxW` wide. With `cacheDir` the frames are kept by the recording's
|
|
88
|
+
* content and the settings, so evaluating the same recording again sends the very same frames (and its triage answers
|
|
89
|
+
* from the cache) even when the browser decodes or encodes a frame differently the second time.
|
|
84
90
|
*/
|
|
85
|
-
export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 1024 }) {
|
|
91
|
+
export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 1024, cacheDir = null }) {
|
|
86
92
|
if (!(max > 0)) return []
|
|
87
|
-
const
|
|
88
|
-
|
|
93
|
+
const bytes = await readFile(file)
|
|
94
|
+
const cacheFile = cacheDir && join(cacheDir, `frames-${createHash('sha256').update(bytes).update(JSON.stringify({ taps, max, maxW, v: FRAMES_VERSION })).digest('hex')}.json`)
|
|
95
|
+
const kept = cacheFile && await readJson(cacheFile, null)
|
|
96
|
+
if (Array.isArray(kept)) return kept
|
|
97
|
+
const src = `data:video/${file.endsWith('.mp4') ? 'mp4' : 'webm'};base64,${bytes.toString('base64')}`
|
|
98
|
+
const frames = await page.evaluate(async ({ src, taps, max, maxW }) => {
|
|
89
99
|
const v = document.createElement('video'); v.muted = true; v.preload = 'auto'; v.src = src
|
|
90
100
|
// every wait has a limit: a stalled video must not hang the evaluation
|
|
91
101
|
const within = (p, ms) => Promise.race([p, new Promise((ok, no) => setTimeout(() => no(new Error('the recording did not answer in time')), ms))])
|
|
@@ -96,13 +106,25 @@ export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 102
|
|
|
96
106
|
const dur = Number.isFinite(v.duration) ? v.duration : 0
|
|
97
107
|
if (!dur) return []
|
|
98
108
|
const seek = (t) => within(new Promise((ok) => { v.onseeked = ok; v.currentTime = Math.min(Math.max(0, t), Math.max(0, dur - 0.01)) }), 5000)
|
|
109
|
+
// `seeked` (even at readyState 4) does not mean the frame can be painted yet: on a busy machine the first draw
|
|
110
|
+
// sometimes paints nothing, which read as a change from an empty picture and moved the chosen frames. A decoded
|
|
111
|
+
// frame is opaque, so the draw is repeated until its middle row holds one.
|
|
112
|
+
const draw = async (g, w, h) => {
|
|
113
|
+
const painted = () => { const row = g.getImageData(0, h >> 1, w, 1).data; for (let i = 3; i < row.length; i += 4) if (row[i]) return true; return false }
|
|
114
|
+
for (let tries = 0; ; tries++) {
|
|
115
|
+
g.clearRect(0, 0, w, h); g.drawImage(v, 0, 0, w, h)
|
|
116
|
+
if (painted()) return
|
|
117
|
+
if (tries >= 100) throw new Error('a frame of the recording could not be drawn')
|
|
118
|
+
await new Promise((ok) => setTimeout(ok, 20))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
99
121
|
const small = document.createElement('canvas'); small.width = 64; small.height = Math.max(1, Math.round(64 * v.videoHeight / v.videoWidth))
|
|
100
122
|
const sg = small.getContext('2d', { willReadFrequently: true })
|
|
101
123
|
// sampled differences: 4 samples a second, at most 240
|
|
102
124
|
const step = Math.max(0.25, dur / 240)
|
|
103
125
|
const diffs = []; let prev = null
|
|
104
126
|
for (let t = 0; t <= dur; t += step) {
|
|
105
|
-
await seek(t); sg
|
|
127
|
+
await seek(t); await draw(sg, small.width, small.height)
|
|
106
128
|
const px = sg.getImageData(0, 0, small.width, small.height).data
|
|
107
129
|
if (prev) { let d = 0; for (let i = 0; i < px.length; i += 4) d += Math.abs(px[i] - prev[i]) + Math.abs(px[i + 1] - prev[i + 1]) + Math.abs(px[i + 2] - prev[i + 2]); diffs.push({ t, d: d / (px.length / 4) / 765 }) }
|
|
108
130
|
prev = px
|
|
@@ -117,11 +139,13 @@ export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 102
|
|
|
117
139
|
const list = chosen.length > max ? [...chosen.slice(0, max - 1), chosen.at(-1)] : chosen
|
|
118
140
|
const big = document.createElement('canvas'); const s = Math.min(1, maxW / v.videoWidth)
|
|
119
141
|
big.width = Math.round(v.videoWidth * s); big.height = Math.round(v.videoHeight * s)
|
|
120
|
-
const bg = big.getContext('2d')
|
|
142
|
+
const bg = big.getContext('2d', { willReadFrequently: true })
|
|
121
143
|
const out = []
|
|
122
|
-
for (const f of list) { await seek(f.t); bg
|
|
144
|
+
for (const f of list) { await seek(f.t); await draw(bg, big.width, big.height); out.push({ t: Math.round(f.t * 1000), why: f.why, data: big.toDataURL('image/webp', 0.8) }) }
|
|
123
145
|
return out
|
|
124
146
|
}, { src, taps, max, maxW })
|
|
147
|
+
if (cacheFile && frames.length) await writeJson(cacheFile, frames)
|
|
148
|
+
return frames
|
|
125
149
|
}
|
|
126
150
|
|
|
127
151
|
/** Frames in requests of MAX_TRIAGE_IMAGES, each overlapping the previous by one frame so a change at the seam is seen. */
|