kaloko 0.4.9 → 0.4.11
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/README.md +88 -4
- package/package.json +1 -1
- package/schemas/config.v1.json +35 -0
- package/schemas/scenario.v1.json +30 -1
- package/skills/kaloko/SKILL.md +22 -4
- package/skills/kaloko/reference/acceptance.md +16 -0
- package/skills/kaloko/reference/ci.md +70 -0
- package/skills/kaloko/reference/collaboration.md +9 -1
- package/skills/kaloko/reference/environments.md +5 -0
- package/skills/kaloko/reference/execution.md +56 -0
- package/skills/kaloko/reference/import.md +66 -0
- package/skills/kaloko/reference/mcp.md +2 -0
- package/skills/kaloko/reference/recording.md +95 -0
- package/src/cli.mjs +467 -100
- package/src/lib/affected-core.mjs +242 -0
- package/src/lib/affected.mjs +233 -0
- package/src/lib/anim.mjs +125 -0
- package/src/lib/apps/recording-native.mjs +212 -0
- package/src/lib/build.mjs +60 -4
- package/src/lib/ci.mjs +207 -0
- package/src/lib/config.mjs +2 -0
- package/src/lib/doctor.mjs +5 -1
- package/src/lib/evaluate.mjs +4 -2
- package/src/lib/git.mjs +6 -2
- package/src/lib/heal.mjs +207 -0
- package/src/lib/import.mjs +374 -0
- package/src/lib/init-detect.mjs +219 -0
- package/src/lib/onboarding.mjs +15 -8
- package/src/lib/pr.mjs +24 -2
- package/src/lib/recording.mjs +373 -0
- package/src/lib/scenario.mjs +2 -1
- package/src/lib/service.mjs +7 -1
- package/src/lib/trace.mjs +109 -0
- package/src/lib/unzip.mjs +31 -0
- package/src/lib/upload.mjs +114 -0
- package/src/lib/video-cli.mjs +209 -0
- package/src/lib/video.mjs +427 -0
- package/src/lib/webm.mjs +144 -0
- package/viewer/index.html +186 -7
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Affected steps from changed files (#14): pure functions, no file system or Node APIs, shared by the CLI
|
|
2
|
+
// (`kaloko affected`, `walk --affected`) and the service (MCP get_affected_steps).
|
|
3
|
+
//
|
|
4
|
+
// A changed file reaches a step in one of these ways, and the selection says which:
|
|
5
|
+
// - the step's `affects:` globs name the file (or `affects: always`);
|
|
6
|
+
// - the file is a route file of a known framework (Next.js, Nuxt, SvelteKit, Remix, Astro, Rails) and its route
|
|
7
|
+
// matches a path of the step: its `path` or a URL captured for it in an earlier run;
|
|
8
|
+
// - the file is a step script or the scenario itself changed for that step (the caller passes those);
|
|
9
|
+
// - the file is global (layouts, global styles, dependencies, i18n messages): every step, or every step of one locale;
|
|
10
|
+
// - the file is imported by a route file (the CLI follows imports; the caller passes the route files it found).
|
|
11
|
+
// Files that are none of these are listed as unmapped, never silently dropped.
|
|
12
|
+
|
|
13
|
+
/** Minimal glob: `*` within a segment, `**` across segments, `?` one character. */
|
|
14
|
+
export function globRe (glob) {
|
|
15
|
+
let out = ''
|
|
16
|
+
for (let i = 0; i < glob.length; i++) {
|
|
17
|
+
const c = glob[i]
|
|
18
|
+
if (c === '*' && glob[i + 1] === '*') { out += '.*'; i++; if (glob[i + 1] === '/') i++ } else if (c === '*') out += '[^/]*'
|
|
19
|
+
else if (c === '?') out += '[^/]'
|
|
20
|
+
else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
21
|
+
}
|
|
22
|
+
return new RegExp('^' + out + '$')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CODE = /\.(m?[jt]sx?|cjs|vue|svelte|astro|rb|erb|haml|slim|php|py|css|scss|sass|less|html?)$/i
|
|
26
|
+
const IGNORED = [
|
|
27
|
+
/(^|\/)(test|tests|spec|specs|__tests__|e2e|cypress|playwright|\.storybook|docs?|\.github|\.vscode|\.idea)\//,
|
|
28
|
+
/\.(test|spec|stories)\.[a-z]+$/i, /(^|\/)(README|CHANGELOG|LICENSE|CONTRIBUTING)[^/]*$/i, /\.md$/i,
|
|
29
|
+
/(^|\/)\.(gitignore|gitattributes|editorconfig|prettierrc[^/]*|eslintrc[^/]*|nvmrc|tool-versions)$/, /(^|\/)kaloko\.(ci\.)?config\.yml$/
|
|
30
|
+
]
|
|
31
|
+
const GLOBAL = [
|
|
32
|
+
/^(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|Gemfile|Gemfile\.lock)$/,
|
|
33
|
+
/(^|\/)(tailwind|postcss|next|nuxt|svelte|vite|astro|remix)\.config\.[a-z]+$/,
|
|
34
|
+
/(^|\/)(globals?|app|main|application)\.(css|scss|sass|less)$/, /^(src\/)?styles\//, /^(src\/)?(assets\/)?(css|styles)\/(global|base|reset|variables|tokens)[^/]*$/,
|
|
35
|
+
/^public\//, /^static\//,
|
|
36
|
+
/^(src\/)?app\/(layout|template|not-found|error|global-error)\.[jt]sx?$/, /^(src\/)?pages\/_(app|document)\.[jt]sx?$/,
|
|
37
|
+
/^(app\/)?(app|error)\.vue$/, /^(app\/)?layouts\//, /^src\/app\.(html|d\.ts)$/, /^src\/hooks(\.(server|client))?\.[jt]s$/, /^src\/routes\/\+layout(\.server)?\.(svelte|[jt]s)$/,
|
|
38
|
+
/^app\/root\.[jt]sx?$/, /^src\/layouts\//,
|
|
39
|
+
/^app\/views\/(layouts|shared|application)\//, /^app\/controllers\/application_controller\.rb$/, /^app\/helpers\/application_helper\.rb$/, /^config\/routes(\.rb|\/)/, /^app\/assets\/(stylesheets|config)\//
|
|
40
|
+
]
|
|
41
|
+
// i18n messages: every step, but only in the file's locale when the name says which
|
|
42
|
+
const MESSAGES = /(^|\/)(messages|locales?|lang|i18n|translations)\/(?:.*\/)?([a-z]{2}(?:[-_][A-Za-z]{2})?)(?:\.[a-z]+)?\.(json|ya?ml|po|js|ts)$/
|
|
43
|
+
|
|
44
|
+
/** Route segments: dynamic `[id]`, `$id`, `:id` become `:id`; catch-all `[...slug]` and `$` become `**`. */
|
|
45
|
+
function segment (s, style) {
|
|
46
|
+
if (style === 'next' || style === 'nuxt' || style === 'svelte' || style === 'astro') {
|
|
47
|
+
if (/^\(.*\)$/.test(s) || s.startsWith('@')) return null // route groups and parallel slots are not in the URL
|
|
48
|
+
if (/^\[\[?\.\.\..+\]\]?$/.test(s)) return '**'
|
|
49
|
+
if (/^\[\[.+\]\]$/.test(s)) return '**' // optional parameter: this path and below
|
|
50
|
+
const m = s.match(/^\[(.+?)(=[^\]]+)?\]$/); if (m) return `:${m[1]}`
|
|
51
|
+
return s
|
|
52
|
+
}
|
|
53
|
+
if (style === 'remix') {
|
|
54
|
+
if (s === '_index' || s === 'index') return ''
|
|
55
|
+
if (s.startsWith('_')) return null // pathless layout
|
|
56
|
+
if (s === '$') return '**'
|
|
57
|
+
if (s.startsWith('$')) return `:${s.slice(1)}`
|
|
58
|
+
return s.replace(/^\((.+)\)$/, '$1').replace(/_$/, '')
|
|
59
|
+
}
|
|
60
|
+
return s
|
|
61
|
+
}
|
|
62
|
+
const join = (parts) => '/' + parts.filter((p) => p !== null && p !== '').join('/')
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The route a file serves, by framework convention; { route, scope: 'page'|'prefix', framework } or null.
|
|
66
|
+
* `prefix` means the file shapes every page under the route (layouts, controllers, colocated components).
|
|
67
|
+
*/
|
|
68
|
+
export function routeOf (file) {
|
|
69
|
+
let m
|
|
70
|
+
// Next.js app router
|
|
71
|
+
if ((m = file.match(/^(?:src\/)?app\/(?:(.*)\/)?(page|route|layout|template|loading|error|not-found|default)\.(?:m?[jt]sx?|mdx)$/))) {
|
|
72
|
+
const parts = (m[1] || '').split('/').filter(Boolean)
|
|
73
|
+
if (parts.some((p) => p.startsWith('_'))) return null
|
|
74
|
+
return { route: join(parts.map((p) => segment(p, 'next'))), scope: ['page', 'route'].includes(m[2]) ? 'page' : 'prefix', framework: 'next' }
|
|
75
|
+
}
|
|
76
|
+
// Remix / React Router flat routes
|
|
77
|
+
if ((m = file.match(/^app\/routes\/(.+?)(?:\/route)?\.[jt]sx?$/))) {
|
|
78
|
+
const parts = m[1].split(/[./]/).map((p) => segment(p, 'remix'))
|
|
79
|
+
return { route: join(parts), scope: 'page', framework: 'remix' }
|
|
80
|
+
}
|
|
81
|
+
// SvelteKit
|
|
82
|
+
if ((m = file.match(/^src\/routes\/(?:(.*)\/)?\+(page|layout|server|error)(?:\.server)?\.(svelte|[jt]s)$/))) {
|
|
83
|
+
return { route: join((m[1] || '').split('/').filter(Boolean).map((p) => segment(p, 'svelte'))), scope: ['page', 'server'].includes(m[2]) ? 'page' : 'prefix', framework: 'sveltekit' }
|
|
84
|
+
}
|
|
85
|
+
// Nuxt (pages/*.vue, Nuxt 4 app/pages/*.vue)
|
|
86
|
+
if ((m = file.match(/^(?:app\/)?pages\/(.+)\.vue$/))) {
|
|
87
|
+
const parts = m[1].split('/').map((p) => (p === 'index' ? '' : segment(p, 'nuxt')))
|
|
88
|
+
return { route: join(parts), scope: 'page', framework: 'nuxt' }
|
|
89
|
+
}
|
|
90
|
+
// Next.js pages router, Astro, Gatsby
|
|
91
|
+
if ((m = file.match(/^(src\/)?pages\/(.+)\.(m?[jt]sx?|astro|mdx?)$/))) {
|
|
92
|
+
const style = m[3] === 'astro' || m[1] ? 'astro' : 'next'
|
|
93
|
+
const parts = m[2].split('/')
|
|
94
|
+
if (parts.some((p) => p.startsWith('_'))) return null
|
|
95
|
+
return { route: join(parts.map((p) => (p === 'index' ? '' : segment(p, style)))), scope: 'page', framework: m[3] === 'astro' ? 'astro' : 'next-pages' }
|
|
96
|
+
}
|
|
97
|
+
// Rails: views, controllers and helpers of one controller shape its pages (/admin/products/…)
|
|
98
|
+
if ((m = file.match(/^app\/views\/(.+)\/[^/]+$/))) return { route: '/' + m[1], scope: 'prefix', framework: 'rails' }
|
|
99
|
+
if ((m = file.match(/^app\/controllers\/(.+)_controller\.rb$/))) return { route: '/' + m[1], scope: 'prefix', framework: 'rails' }
|
|
100
|
+
if ((m = file.match(/^app\/helpers\/(.+)_helper\.rb$/))) return { route: '/' + m[1], scope: 'prefix', framework: 'rails' }
|
|
101
|
+
// a component colocated in a route folder shapes that route and everything below it
|
|
102
|
+
if ((m = file.match(/^(?:src\/)?app\/(.+)\/[^/]+\.(?:m?[jt]sx?|css|scss)$/)) && !/(^|\/)(api|lib|components|utils|hooks|composables|stores|plugins|middleware|styles|types|javascript|assets|models|controllers|views|helpers|jobs|mailers|channels|services|policies|serializers)(\/|$)/.test(m[1]) && !m[1].split('/').some((p) => p.startsWith('_'))) {
|
|
103
|
+
return { route: join(m[1].split('/').map((p) => segment(p, 'next'))), scope: 'prefix', framework: 'next' }
|
|
104
|
+
}
|
|
105
|
+
if ((m = file.match(/^src\/routes\/(.+)\/[^/+][^/]*\.(svelte|[jt]s)$/))) return { route: join(m[1].split('/').map((p) => segment(p, 'svelte'))), scope: 'prefix', framework: 'sveltekit' }
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** How one changed file is treated: ignored, global (optionally for one locale), a route, code to follow, or other. */
|
|
110
|
+
export function classify (file) {
|
|
111
|
+
if (IGNORED.some((re) => re.test(file))) return { kind: 'ignored' }
|
|
112
|
+
const msg = file.match(MESSAGES)
|
|
113
|
+
if (msg) return { kind: 'global', locale: msg[3].replace('_', '-').toLowerCase() }
|
|
114
|
+
if (GLOBAL.some((re) => re.test(file))) return { kind: 'global' }
|
|
115
|
+
const r = routeOf(file)
|
|
116
|
+
if (r) return { kind: 'route', ...r }
|
|
117
|
+
if (CODE.test(file)) return { kind: 'code' }
|
|
118
|
+
return { kind: 'other' }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** RegExp for URL paths a route reaches: `:x` one segment, `**` anything below, prefix scope this path and below. */
|
|
122
|
+
export function routeRe (route, scope = 'page') {
|
|
123
|
+
const body = route.replace(/\/$/, '').split('/').filter(Boolean).map((s) => s === '**' ? '(?:/.*)?' : '/' + (s.startsWith(':') ? '[^/]+' : s.replace(/[.+^${}()|[\]\\*?]/g, '\\$&'))).join('')
|
|
124
|
+
if (scope === 'prefix') return new RegExp(`^${body}(?:/.*)?$`)
|
|
125
|
+
return new RegExp(`^${body || '/'}${body ? '/?' : ''}$`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** URL path of a step path or captured URL: no origin, query or hash; `{random}` stands for any one segment. */
|
|
129
|
+
export function pathOf (value) {
|
|
130
|
+
if (!value) return null
|
|
131
|
+
let p = String(value)
|
|
132
|
+
try { if (/^[a-z][a-z0-9+.-]*:\/\//i.test(p)) p = new URL(p).pathname } catch { return null }
|
|
133
|
+
p = p.split(/[?#]/)[0]
|
|
134
|
+
if (!p.startsWith('/')) p = '/' + p
|
|
135
|
+
return p.length > 1 ? p.replace(/\/+$/, '') : p
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Does a route reach this path (with or without a leading locale segment such as /cs/…)? */
|
|
139
|
+
export function routeMatches (route, scope, path, locales = []) {
|
|
140
|
+
const re = routeRe(route, scope)
|
|
141
|
+
const p = path.replaceAll('{random}', 'x')
|
|
142
|
+
if (re.test(p)) return true
|
|
143
|
+
const [first, ...rest] = p.split('/').filter(Boolean)
|
|
144
|
+
const loc = first && (locales.includes(first) || /^[a-z]{2}(-[a-zA-Z]{2})?$/.test(first))
|
|
145
|
+
return !!loc && re.test('/' + rest.join('/'))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Paths of a step: its `path` (every locale) plus URLs captured for it earlier ({ [step]: [url…] }). */
|
|
149
|
+
export function stepPaths (node, captured = {}) {
|
|
150
|
+
const raw = node.path == null ? [] : typeof node.path === 'string' ? [node.path] : Object.values(node.path)
|
|
151
|
+
return [...new Set([...raw, ...(captured[node.id] || [])].map(pathOf).filter(Boolean))]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Steps a step depends on to be reached: predecessors over main edges (not back edges), transitively. */
|
|
155
|
+
export function upstreamOf (scenario, ids) {
|
|
156
|
+
const order = new Map(scenario.nodes.map((n, i) => [n.id, i]))
|
|
157
|
+
const into = new Map()
|
|
158
|
+
for (const e of scenario.edges || []) if ((e.kind || 'main') === 'main' && order.get(e.from) < order.get(e.to)) (into.get(e.to) || into.set(e.to, []).get(e.to)).push(e.from)
|
|
159
|
+
const out = new Map()
|
|
160
|
+
const visit = (id, of) => { for (const p of into.get(id) || []) if (!ids.has(p) && !out.has(p)) { out.set(p, of); visit(p, of) } }
|
|
161
|
+
for (const id of ids) visit(id, id)
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Selects the steps changed files reach.
|
|
167
|
+
* changes: [{ file, kind?, via? }] (`via`: the route file an imported module reaches, from the CLI's import graph)
|
|
168
|
+
* extra: { [step]: [reason] } reasons the caller found itself (changed step scripts, changed scenario steps)
|
|
169
|
+
* captured: { [step]: [url] } URLs captured in earlier runs
|
|
170
|
+
* Returns { steps: [{ id, reasons }], upstream: [{ id, of }], always, unmapped, ignored, global, locales, all }
|
|
171
|
+
*/
|
|
172
|
+
export function selectAffected ({ scenario, changes, captured = {}, extra = {}, upstream = true }) {
|
|
173
|
+
const walkable = scenario.nodes.filter((n) => n.kind !== 'decision')
|
|
174
|
+
const reasons = new Map()
|
|
175
|
+
const add = (id, why) => { const r = reasons.get(id) || []; if (!r.includes(why)) r.push(why); reasons.set(id, r) }
|
|
176
|
+
const unmapped = []; const ignored = []; const global = []
|
|
177
|
+
const localeHits = new Set(); let everyLocale = false
|
|
178
|
+
const locales = scenario.locales || []
|
|
179
|
+
const always = walkable.filter((n) => n.affects === 'always').map((n) => n.id)
|
|
180
|
+
const globs = walkable.filter((n) => Array.isArray(n.affects)).map((n) => ({ id: n.id, res: n.affects.map((g) => [g, globRe(g)]) }))
|
|
181
|
+
const paths = new Map(walkable.map((n) => [n.id, stepPaths(n, captured)]))
|
|
182
|
+
for (const ch of changes) {
|
|
183
|
+
const file = ch.file
|
|
184
|
+
let hit = false
|
|
185
|
+
for (const g of globs) for (const [glob, re] of g.res) if (re.test(file)) { add(g.id, `${file} matches affects: ${glob}`); hit = true; everyLocale = true }
|
|
186
|
+
const c = ch.kind ? ch : { ...ch, ...classify(ch.via || file) }
|
|
187
|
+
if (c.kind === 'ignored' && !hit) { ignored.push(file); continue }
|
|
188
|
+
if (c.kind === 'global') {
|
|
189
|
+
global.push(c.locale ? `${file} (${c.locale})` : file)
|
|
190
|
+
if (c.locale) localeHits.add(c.locale); else everyLocale = true
|
|
191
|
+
for (const n of walkable) add(n.id, `${ch.via ? `${file} → imported by ${ch.via}` : file} is shared by every page${c.locale ? ` in ${c.locale}` : ''}`)
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if (c.kind === 'route') {
|
|
195
|
+
const label = `${ch.via ? `${file} → imported by ${ch.via}` : file} → route ${c.route}${c.scope === 'prefix' ? '/…' : ''}`
|
|
196
|
+
for (const n of walkable) {
|
|
197
|
+
const p = (paths.get(n.id) || []).find((x) => routeMatches(c.route, c.scope, x, locales))
|
|
198
|
+
if (p) { add(n.id, `${label} (${stepPaths(n).includes(p) ? 'step path' : 'captured earlier'} ${p})`); hit = true }
|
|
199
|
+
}
|
|
200
|
+
if (!hit) unmapped.push(`${file} (route ${c.route}: no step shows it)`)
|
|
201
|
+
everyLocale = true
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if (!hit) unmapped.push(file); else everyLocale = true
|
|
205
|
+
}
|
|
206
|
+
for (const [id, list] of Object.entries(extra)) for (const why of list) { add(id, why); everyLocale = true }
|
|
207
|
+
for (const id of always) if (!reasons.has(id)) add(id, 'always walked (affects: always)')
|
|
208
|
+
const picked = new Set(reasons.keys())
|
|
209
|
+
const onlyAlways = (id) => always.includes(id) && reasons.get(id).length === 1 && reasons.get(id)[0].startsWith('always walked')
|
|
210
|
+
const ups = upstream ? upstreamOf(scenario, new Set([...picked].filter((id) => !onlyAlways(id)))) : new Map()
|
|
211
|
+
for (const id of picked) ups.delete(id)
|
|
212
|
+
const order = new Map(scenario.nodes.map((n, i) => [n.id, i]))
|
|
213
|
+
const steps = [...reasons].map(([id, r]) => ({ id, reasons: r })).sort((a, b) => order.get(a.id) - order.get(b.id))
|
|
214
|
+
const real = steps.filter((s) => !onlyAlways(s.id))
|
|
215
|
+
return {
|
|
216
|
+
steps,
|
|
217
|
+
upstream: [...ups].map(([id, of]) => ({ id, of })).sort((a, b) => order.get(a.id) - order.get(b.id)),
|
|
218
|
+
always,
|
|
219
|
+
unmapped, ignored, global,
|
|
220
|
+
// only i18n messages of some locales changed: the walk can stay in those locales
|
|
221
|
+
locales: !everyLocale && localeHits.size ? [...localeHits] : null,
|
|
222
|
+
all: real.length > 0 && real.length === walkable.length,
|
|
223
|
+
none: real.length === 0
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The ids to walk, in scenario order: selected, always and upstream steps. */
|
|
228
|
+
export const selectedIds = (scenario, sel) => scenario.nodes.map((n) => n.id).filter((id) => sel.steps.some((s) => s.id === id) || sel.upstream.some((u) => u.id === id))
|
|
229
|
+
|
|
230
|
+
/** Human explanation of a selection, one line per step. */
|
|
231
|
+
export function formatAffected (sel, { scenario, base = null, changed = 0 } = {}) {
|
|
232
|
+
const lines = [`Affected steps of ${scenario.id}${base ? ` (changes against ${base}` : ' ('}${changed} changed file(s)):`]
|
|
233
|
+
if (sel.none && !sel.always.length) lines.push(' none: no changed file reaches a step of this scenario')
|
|
234
|
+
for (const s of sel.steps) lines.push(` ✓ ${s.id.padEnd(24)} ${s.reasons.slice(0, 3).join('; ')}${s.reasons.length > 3 ? `; +${s.reasons.length - 3} more` : ''}`)
|
|
235
|
+
for (const u of sel.upstream) lines.push(` ↑ ${u.id.padEnd(24)} walked first to reach ${u.of} (upstream; --no-upstream skips it)`)
|
|
236
|
+
const skipped = scenario.nodes.filter((n) => n.kind !== 'decision' && !sel.steps.some((s) => s.id === n.id) && !sel.upstream.some((u) => u.id === n.id)).map((n) => n.id)
|
|
237
|
+
if (skipped.length) lines.push(` · not walked: ${skipped.join(', ')}`)
|
|
238
|
+
if (sel.locales) lines.push(` locales: ${sel.locales.join(', ')} (only their messages changed)`)
|
|
239
|
+
if (sel.unmapped.length) lines.push(` ? unmapped (no route, import or affects: glob reaches a step): ${sel.unmapped.slice(0, 8).join(', ')}${sel.unmapped.length > 8 ? `, +${sel.unmapped.length - 8} more` : ''}\n add affects: [<glob>] to the steps they shape, or walk everything (--if-unmapped all)`)
|
|
240
|
+
if (sel.ignored.length) lines.push(` ignored (tests, docs, CI): ${sel.ignored.length} file(s)`)
|
|
241
|
+
return lines.join('\n')
|
|
242
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// `kaloko affected` and `walk --affected` (#14): changed files from git, imports followed to route files, URLs
|
|
2
|
+
// captured in earlier runs (local and, with a token, the newest shared run), then the pure selection.
|
|
3
|
+
import { execFile } from 'node:child_process'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { readdir, readFile, stat, cp, mkdir } from 'node:fs/promises'
|
|
6
|
+
import { join, relative, resolve, dirname } from 'node:path'
|
|
7
|
+
import yaml from 'js-yaml'
|
|
8
|
+
import { classify, selectAffected, pathOf } from './affected-core.mjs'
|
|
9
|
+
import { readJson, readJsonl, appendJsonl, writeJson } from './util.mjs'
|
|
10
|
+
import { listRuns } from './run.mjs'
|
|
11
|
+
|
|
12
|
+
const exec = promisify(execFile)
|
|
13
|
+
async function git (cwd, args) {
|
|
14
|
+
try { return (await exec('git', args, { cwd, timeout: 20000, maxBuffer: 16 * 1024 * 1024 })).stdout.trim() } catch { return null }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The ref changes are measured against: --base, then CI variables, then origin/main or main. */
|
|
18
|
+
export async function baseRef (cwd, explicit, vars = process.env) {
|
|
19
|
+
const candidates = [
|
|
20
|
+
explicit,
|
|
21
|
+
vars.KALOKO_BASE,
|
|
22
|
+
vars.GITHUB_BASE_REF && `origin/${vars.GITHUB_BASE_REF}`,
|
|
23
|
+
vars.CI_MERGE_REQUEST_DIFF_BASE_SHA,
|
|
24
|
+
vars.CI_MERGE_REQUEST_TARGET_BRANCH_NAME && `origin/${vars.CI_MERGE_REQUEST_TARGET_BRANCH_NAME}`,
|
|
25
|
+
'origin/main', 'origin/master', 'main', 'master'
|
|
26
|
+
].filter(Boolean)
|
|
27
|
+
for (const ref of candidates) if (await git(cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])) return ref
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Files changed since the merge base with `base`, plus uncommitted and untracked ones, relative to `root`. */
|
|
32
|
+
export async function changedFiles (root, base) {
|
|
33
|
+
const top = await git(root, ['rev-parse', '--show-toplevel'])
|
|
34
|
+
if (!top) return { base: null, files: [] }
|
|
35
|
+
const ref = await baseRef(root, base)
|
|
36
|
+
const mb = ref ? await git(root, ['merge-base', 'HEAD', ref]) : null
|
|
37
|
+
const lists = await Promise.all([
|
|
38
|
+
mb ? git(root, ['diff', '--name-only', mb, 'HEAD']) : '',
|
|
39
|
+
git(root, ['diff', '--name-only', 'HEAD']),
|
|
40
|
+
git(root, ['ls-files', '--others', '--exclude-standard', '--full-name'])
|
|
41
|
+
])
|
|
42
|
+
// git names files from the top of the repository; the project may be a folder of it (monorepos)
|
|
43
|
+
const prefix = (await git(root, ['rev-parse', '--show-prefix'])) || ''
|
|
44
|
+
const files = [...new Set(lists.flatMap((l) => (l || '').split('\n').filter(Boolean)))].filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length)).sort()
|
|
45
|
+
return { base: ref, mergeBase: mb, files, top }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SOURCE = /\.(m?[jt]sx?|cjs|vue|svelte|astro)$/
|
|
49
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.output', 'dist', 'build', 'out', 'coverage', 'tmp', 'vendor', '.turbo', '.cache', 'public'])
|
|
50
|
+
const SPEC = /(?:import|export)\s[^'"`]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)|require\s*\(\s*['"]([^'"]+)['"]\s*\)|import\s+['"]([^'"]+)['"]/g
|
|
51
|
+
|
|
52
|
+
async function sourceFiles (root, limit = 30000) {
|
|
53
|
+
const out = []
|
|
54
|
+
const walk = async (dir) => {
|
|
55
|
+
if (out.length >= limit) return
|
|
56
|
+
let entries = []
|
|
57
|
+
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
|
58
|
+
for (const e of entries) {
|
|
59
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) await walk(join(dir, e.name)) } else if (SOURCE.test(e.name)) out.push(join(dir, e.name))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
await walk(root)
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Path aliases: tsconfig/jsconfig `paths` (one level), else @/ and ~/ to src/ or the root. */
|
|
67
|
+
async function aliases (root) {
|
|
68
|
+
const out = []
|
|
69
|
+
for (const f of ['tsconfig.json', 'jsconfig.json']) {
|
|
70
|
+
try {
|
|
71
|
+
const text = (await readFile(join(root, f), 'utf8')).replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1')
|
|
72
|
+
const o = JSON.parse(text).compilerOptions || {}
|
|
73
|
+
for (const [k, v] of Object.entries(o.paths || {})) if (k.endsWith('/*') && v?.[0]?.endsWith('/*')) out.push([k.slice(0, -1), resolve(root, o.baseUrl || '.', v[0].slice(0, -1)) + '/'])
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
const src = await stat(join(root, 'src')).then((s) => s.isDirectory(), () => false)
|
|
77
|
+
for (const a of ['@/', '~/', '$lib/']) if (!out.some(([k]) => k === a)) out.push([a, a === '$lib/' ? join(root, 'src', 'lib') + '/' : (src ? join(root, 'src') : root) + '/'])
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const EXTS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.vue', '.svelte', '.astro', '/index.ts', '/index.tsx', '/index.js', '/index.jsx', '/index.vue']
|
|
82
|
+
|
|
83
|
+
/** Reverse import graph of the project's JS/TS/Vue/Svelte sources: file → files importing it (relative to root). */
|
|
84
|
+
export async function importGraph (root) {
|
|
85
|
+
const files = await sourceFiles(root)
|
|
86
|
+
const known = new Set(files)
|
|
87
|
+
const alias = await aliases(root)
|
|
88
|
+
const resolveSpec = (from, spec) => {
|
|
89
|
+
let base = null
|
|
90
|
+
if (spec.startsWith('.')) base = resolve(dirname(from), spec)
|
|
91
|
+
else { const a = alias.find(([k]) => spec.startsWith(k)); if (a) base = a[1] + spec.slice(a[0].length) }
|
|
92
|
+
if (!base) return null
|
|
93
|
+
const stripped = base.replace(/\.(js|mjs|jsx)$/, '')
|
|
94
|
+
for (const b of [base, stripped]) for (const e of EXTS) if (known.has(b + e)) return b + e
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
const into = new Map()
|
|
98
|
+
for (const f of files) {
|
|
99
|
+
let text
|
|
100
|
+
try { const s = await stat(f); if (s.size > 1024 * 1024) continue; text = await readFile(f, 'utf8') } catch { continue }
|
|
101
|
+
for (const m of text.matchAll(SPEC)) {
|
|
102
|
+
const target = resolveSpec(f, m[1] || m[2] || m[3] || m[4])
|
|
103
|
+
if (!target || target === f) continue
|
|
104
|
+
const k = relative(root, target); const v = relative(root, f)
|
|
105
|
+
;(into.get(k) || into.set(k, new Set()).get(k)).add(v)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return into
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Route or global files that import `file`, directly or through other modules (breadth first, depth ≤ 6). */
|
|
112
|
+
export function importersReaching (graph, file, depth = 6, max = 40) {
|
|
113
|
+
const found = []; const seen = new Set([file]); let frontier = [file]
|
|
114
|
+
for (let d = 0; d < depth && frontier.length && found.length < max; d++) {
|
|
115
|
+
const next = []
|
|
116
|
+
for (const f of frontier) {
|
|
117
|
+
for (const imp of graph.get(f) || []) {
|
|
118
|
+
if (seen.has(imp)) continue
|
|
119
|
+
seen.add(imp)
|
|
120
|
+
const c = classify(imp)
|
|
121
|
+
if (c.kind === 'route' || c.kind === 'global') found.push(imp); else if (c.kind === 'code') next.push(imp)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
frontier = next
|
|
125
|
+
}
|
|
126
|
+
return found
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** URLs captured for each step in earlier runs of the scenario: local run folders, then the newest shared run. */
|
|
130
|
+
export async function capturedRoutes ({ root, output, scenarioId, client = null, org = null }) {
|
|
131
|
+
const out = {}
|
|
132
|
+
const addRun = (m) => {
|
|
133
|
+
for (const n of m.nodes || []) for (const vps of Object.values(n.captures || {})) for (const c of Object.values(vps || {})) {
|
|
134
|
+
const p = pathOf(c?.url); if (p && /^https?:/.test(c.url)) (out[n.id] ||= new Set()).add(p)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const local = (await listRuns(root, output)).filter((r) => r.scenario?.id === scenarioId).sort((a, b) => b.started_at.localeCompare(a.started_at)).slice(0, 5)
|
|
138
|
+
for (const m of local) addRun(m)
|
|
139
|
+
let shared = null
|
|
140
|
+
if (client && org) {
|
|
141
|
+
try {
|
|
142
|
+
const [latest] = await client.request('GET', `/orgs/${org}/runs?scenario=${encodeURIComponent(scenarioId)}&limit=1`)
|
|
143
|
+
if (latest) { addRun(await client.request('GET', `/orgs/${org}/runs/${latest.id}`)); shared = latest.id }
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
return { routes: Object.fromEntries(Object.entries(out).map(([k, v]) => [k, [...v]])), local: local.length, shared }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Steps whose script or definition changed (script files relative to the scenario, the scenario against the base). */
|
|
150
|
+
async function scenarioChanges ({ root, scenario, scenarioFile, files, mergeBase, top }) {
|
|
151
|
+
const extra = {}
|
|
152
|
+
const add = (id, why) => (extra[id] ||= []).push(why)
|
|
153
|
+
const dir = dirname(scenarioFile)
|
|
154
|
+
for (const n of scenario.nodes) {
|
|
155
|
+
const s = n.execution?.script && relative(root, resolve(dir, n.execution.script))
|
|
156
|
+
if (s && files.includes(s)) add(n.id, `step script ${s} changed`)
|
|
157
|
+
}
|
|
158
|
+
const rel = relative(root, scenarioFile)
|
|
159
|
+
if (files.includes(rel)) {
|
|
160
|
+
let before = null
|
|
161
|
+
if (mergeBase && top) { const text = await git(root, ['show', `${mergeBase}:./${relative(root, scenarioFile)}`]); try { before = text ? yaml.load(text) : null } catch {} }
|
|
162
|
+
const old = new Map((before?.nodes || []).map((n) => [n.id, JSON.stringify(n)]))
|
|
163
|
+
// compared as written (packs unexpanded), so a pack update in the CLI does not mark every step
|
|
164
|
+
const now = yaml.load(await readFile(scenarioFile, 'utf8'))
|
|
165
|
+
for (const n of scenario.nodes) {
|
|
166
|
+
if (n.kind === 'decision') continue
|
|
167
|
+
const raw = JSON.stringify((now?.nodes || []).find((x) => x.id === n.id) ?? n)
|
|
168
|
+
if (!old.has(n.id)) add(n.id, before ? 'new step in the scenario' : 'the scenario is new')
|
|
169
|
+
else if (old.get(n.id) !== raw) add(n.id, 'step changed in the scenario')
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return extra
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The full selection for one scenario. `files` overrides git (agents and CI may pass the list they know).
|
|
177
|
+
* Returns { sel, base, files, routes }.
|
|
178
|
+
*/
|
|
179
|
+
export async function affectedFor ({ root, config, scenario, scenarioFile, base, files = null, client = null, upstream = true }) {
|
|
180
|
+
const diff = files ? { base: base || null, files, mergeBase: null, top: null } : await changedFiles(root, base)
|
|
181
|
+
const scenarioRel = relative(root, scenarioFile)
|
|
182
|
+
const graphNeeded = diff.files.some((f) => classify(f).kind === 'code' && SOURCE.test(f))
|
|
183
|
+
const graph = graphNeeded ? await importGraph(root) : new Map()
|
|
184
|
+
const changes = []
|
|
185
|
+
for (const file of diff.files) {
|
|
186
|
+
if (file === scenarioRel) continue
|
|
187
|
+
const c = classify(file)
|
|
188
|
+
if (c.kind === 'code' && SOURCE.test(file)) {
|
|
189
|
+
const via = importersReaching(graph, file)
|
|
190
|
+
if (via.length) { for (const v of via) changes.push({ file, via: v }); continue }
|
|
191
|
+
}
|
|
192
|
+
changes.push({ file })
|
|
193
|
+
}
|
|
194
|
+
// the scenario's own steps folder is mechanics of the walk, never a page of the app
|
|
195
|
+
const stepsDir = relative(root, dirname(scenarioFile)) + '/'
|
|
196
|
+
const appChanges = changes.filter((c) => !c.file.startsWith(stepsDir) || c.via)
|
|
197
|
+
const extra = await scenarioChanges({ root, scenario, scenarioFile, files: diff.files, mergeBase: diff.mergeBase, top: diff.top })
|
|
198
|
+
const { routes, local, shared } = await capturedRoutes({ root, output: config.output, scenarioId: scenario.id, client, org: config.org })
|
|
199
|
+
const sel = selectAffected({ scenario, changes: appChanges, captured: routes, extra, upstream })
|
|
200
|
+
return { sel, base: diff.base, files: diff.files, runs: { local, shared } }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Copies the newest earlier local run's captures of the steps that are not walked into this run (marked
|
|
205
|
+
* carried_from), so the canvas shows the whole flow. Returns { from, steps } or null.
|
|
206
|
+
*/
|
|
207
|
+
export async function carryCaptures ({ root, output, run, scenario, walked }) {
|
|
208
|
+
const base = resolve(root, output)
|
|
209
|
+
let names = []
|
|
210
|
+
try { names = (await readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== run.meta.id).map((e) => e.name) } catch { return null }
|
|
211
|
+
const metas = []
|
|
212
|
+
for (const name of names) { const m = await readJson(join(base, name, 'meta.json'), null); if (m?.scenario?.id === scenario.id && m.environment?.name === run.meta.environment.name) metas.push(m) }
|
|
213
|
+
metas.sort((a, b) => b.started_at.localeCompare(a.started_at))
|
|
214
|
+
const already = new Set((await readJsonl(join(run.dir, 'captures.jsonl'))).map((c) => c.node))
|
|
215
|
+
for (const prev of metas) {
|
|
216
|
+
const entries = await readJsonl(join(base, prev.id, 'captures.jsonl'))
|
|
217
|
+
const take = entries.filter((e) => !walked.has(e.node) && !already.has(e.node) && run.meta.scope.locales.includes(e.locale) && (!e.viewport || run.meta.scope.viewports[e.viewport]))
|
|
218
|
+
if (!take.length) continue
|
|
219
|
+
const steps = [...new Set(take.map((e) => e.node))]
|
|
220
|
+
for (const id of steps) {
|
|
221
|
+
await mkdir(join(run.dir, 'steps'), { recursive: true })
|
|
222
|
+
await cp(join(base, prev.id, 'steps', id), join(run.dir, 'steps', id), { recursive: true, force: true }).catch(() => {})
|
|
223
|
+
}
|
|
224
|
+
for (const e of take) {
|
|
225
|
+
if (e.type === 'attachment') await cp(join(base, prev.id, e.file), join(run.dir, e.file), { force: true }).catch(() => {})
|
|
226
|
+
if (e.type === 'capture') { const cap = await readJson(join(run.dir, e.file), null); if (cap) await writeJson(join(run.dir, e.file), { ...cap, carried_from: prev.id }) }
|
|
227
|
+
await appendJsonl(join(run.dir, 'captures.jsonl'), { ...e, carried_from: prev.id })
|
|
228
|
+
}
|
|
229
|
+
return { from: prev.id, steps }
|
|
230
|
+
}
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
package/src/lib/anim.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Animated previews in pure Node: an animated WebP assembled from single-frame WebP images (the browser encodes each
|
|
2
|
+
// frame, this only writes the container), and a GIF encoder for browsers that cannot encode WebP. Both stay small:
|
|
3
|
+
// a preview is a few seconds of a step at thumbnail size.
|
|
4
|
+
|
|
5
|
+
const chunk = (fourcc, payload) => {
|
|
6
|
+
const pad = payload.length % 2
|
|
7
|
+
const head = Buffer.alloc(8); head.write(fourcc, 0, 'ascii'); head.writeUInt32LE(payload.length, 4)
|
|
8
|
+
return Buffer.concat([head, payload, pad ? Buffer.alloc(1) : Buffer.alloc(0)])
|
|
9
|
+
}
|
|
10
|
+
const u24 = (n) => { const b = Buffer.alloc(3); b.writeUIntLE(n, 0, 3); return b }
|
|
11
|
+
|
|
12
|
+
/** Chunks of a still WebP: { width, height, alpha, frame: Buffer of the ALPH + VP8/VP8L chunks }. */
|
|
13
|
+
export function readWebp (buf) {
|
|
14
|
+
if (buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') throw new Error('not a WebP image')
|
|
15
|
+
let p = 12; let width = 0; let height = 0; let alpha = false
|
|
16
|
+
const keep = []
|
|
17
|
+
while (p + 8 <= buf.length) {
|
|
18
|
+
const fourcc = buf.toString('ascii', p, p + 4); const size = buf.readUInt32LE(p + 4)
|
|
19
|
+
const body = buf.subarray(p + 8, p + 8 + size)
|
|
20
|
+
if (fourcc === 'VP8X') { width = body.readUIntLE(4, 3) + 1; height = body.readUIntLE(7, 3) + 1 } else if (fourcc === 'ALPH') { alpha = true; keep.push(chunk('ALPH', body)) } else if (fourcc === 'VP8 ') {
|
|
21
|
+
// key frame header: 3-byte tag, start code 9d 01 2a, then 14-bit width and height
|
|
22
|
+
if (!width) { width = body.readUInt16LE(6) & 0x3fff; height = body.readUInt16LE(8) & 0x3fff }
|
|
23
|
+
keep.push(chunk('VP8 ', body))
|
|
24
|
+
} else if (fourcc === 'VP8L') {
|
|
25
|
+
if (!width) { const bits = body.readUInt32LE(1); width = (bits & 0x3fff) + 1; height = ((bits >>> 14) & 0x3fff) + 1; alpha = !!((bits >>> 28) & 1) }
|
|
26
|
+
keep.push(chunk('VP8L', body))
|
|
27
|
+
}
|
|
28
|
+
p += 8 + size + (size % 2)
|
|
29
|
+
}
|
|
30
|
+
if (!keep.length) throw new Error('WebP without image data')
|
|
31
|
+
return { width, height, alpha, frame: Buffer.concat(keep) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** frames: [{ webp: Buffer, ms }] → animated WebP looping forever. */
|
|
35
|
+
export function animatedWebp (frames, { loop = 0, background = [255, 255, 255, 255] } = {}) {
|
|
36
|
+
if (!frames.length) throw new Error('no frames')
|
|
37
|
+
const parsed = frames.map((f) => ({ ...readWebp(f.webp), ms: Math.max(20, Math.round(f.ms)) }))
|
|
38
|
+
const W = Math.max(...parsed.map((f) => f.width)); const H = Math.max(...parsed.map((f) => f.height))
|
|
39
|
+
const alpha = parsed.some((f) => f.alpha)
|
|
40
|
+
const vp8x = Buffer.alloc(10); vp8x[0] = 0x02 | (alpha ? 0x10 : 0); vp8x.writeUIntLE(W - 1, 4, 3); vp8x.writeUIntLE(H - 1, 7, 3)
|
|
41
|
+
const anim = Buffer.alloc(6); anim[0] = background[2]; anim[1] = background[1]; anim[2] = background[0]; anim[3] = background[3]; anim.writeUInt16LE(loop, 4)
|
|
42
|
+
// each frame covers the canvas without blending (bit 1) and is kept until the next (no dispose)
|
|
43
|
+
const anmf = parsed.map((f) => chunk('ANMF', Buffer.concat([u24(0), u24(0), u24(f.width - 1), u24(f.height - 1), u24(Math.min(0xffffff, f.ms)), Buffer.from([0x02]), f.frame])))
|
|
44
|
+
const body = Buffer.concat([Buffer.from('WEBP'), chunk('VP8X', vp8x), chunk('ANIM', anim), ...anmf])
|
|
45
|
+
const head = Buffer.alloc(8); head.write('RIFF', 0, 'ascii'); head.writeUInt32LE(body.length, 4)
|
|
46
|
+
return Buffer.concat([head, body])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Frames and loop count of an animated WebP (tests). */
|
|
50
|
+
export function webpFrames (buf) {
|
|
51
|
+
let p = 12; const out = []; let loop = null
|
|
52
|
+
while (p + 8 <= buf.length) {
|
|
53
|
+
const fourcc = buf.toString('ascii', p, p + 4); const size = buf.readUInt32LE(p + 4)
|
|
54
|
+
if (fourcc === 'ANIM') loop = buf.readUInt16LE(p + 12)
|
|
55
|
+
if (fourcc === 'ANMF') out.push({ width: buf.readUIntLE(p + 14, 3) + 1, height: buf.readUIntLE(p + 17, 3) + 1, ms: buf.readUIntLE(p + 20, 3) })
|
|
56
|
+
p += 8 + size + (size % 2)
|
|
57
|
+
}
|
|
58
|
+
return { loop, frames: out }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------- GIF ----------
|
|
62
|
+
// A fixed 6×7×6 colour cube (252 colours) plus four greys, with 4×4 ordered dithering: no per-frame palette search,
|
|
63
|
+
// stable colours from frame to frame, and small files for flat UI screens.
|
|
64
|
+
const LEVELS = [6, 7, 6]
|
|
65
|
+
const PALETTE = (() => {
|
|
66
|
+
const p = []
|
|
67
|
+
for (let r = 0; r < 6; r++) for (let g = 0; g < 7; g++) for (let b = 0; b < 6; b++) p.push([Math.round(r * 255 / 5), Math.round(g * 255 / 6), Math.round(b * 255 / 5)])
|
|
68
|
+
for (const v of [32, 96, 160, 224]) p.push([v, v, v])
|
|
69
|
+
return p
|
|
70
|
+
})()
|
|
71
|
+
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16 - 0.5)
|
|
72
|
+
|
|
73
|
+
/** RGBA pixels → palette indices of the colour cube, dithered. */
|
|
74
|
+
export function quantize (rgba, width, height) {
|
|
75
|
+
const out = new Uint8Array(width * height)
|
|
76
|
+
for (let y = 0; y < height; y++) {
|
|
77
|
+
for (let x = 0; x < width; x++) {
|
|
78
|
+
const i = y * width + x; const d = BAYER[(y & 3) * 4 + (x & 3)]
|
|
79
|
+
const q = (v, n) => Math.max(0, Math.min(n - 1, Math.round(v / 255 * (n - 1) + d)))
|
|
80
|
+
out[i] = q(rgba[i * 4], LEVELS[0]) * 42 + q(rgba[i * 4 + 1], LEVELS[1]) * 6 + q(rgba[i * 4 + 2], LEVELS[2])
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** GIF LZW with 8-bit codes, packed into 255-byte sub-blocks. */
|
|
87
|
+
export function lzw (indices, minCode = 8) {
|
|
88
|
+
const clear = 1 << minCode; const eoi = clear + 1
|
|
89
|
+
const bytes = []; let cur = 0; let nbits = 0
|
|
90
|
+
let size = minCode + 1; let next = eoi + 1
|
|
91
|
+
let dict = new Map()
|
|
92
|
+
const emit = (code) => { cur |= code << nbits; nbits += size; while (nbits >= 8) { bytes.push(cur & 0xff); cur >>>= 8; nbits -= 8 } }
|
|
93
|
+
emit(clear)
|
|
94
|
+
let prefix = indices[0]
|
|
95
|
+
for (let i = 1; i < indices.length; i++) {
|
|
96
|
+
const k = indices[i]; const key = prefix * 256 + k
|
|
97
|
+
const hit = dict.get(key)
|
|
98
|
+
if (hit !== undefined) { prefix = hit; continue }
|
|
99
|
+
emit(prefix)
|
|
100
|
+
if (next < 4096) { dict.set(key, next++); if (next > (1 << size) && size < 12) size++ } else { emit(clear); dict = new Map(); size = minCode + 1; next = eoi + 1 }
|
|
101
|
+
prefix = k
|
|
102
|
+
}
|
|
103
|
+
emit(prefix); emit(eoi)
|
|
104
|
+
if (nbits > 0) bytes.push(cur & 0xff)
|
|
105
|
+
const out = [minCode]
|
|
106
|
+
for (let i = 0; i < bytes.length; i += 255) { const n = Math.min(255, bytes.length - i); out.push(n, ...bytes.slice(i, i + n)) }
|
|
107
|
+
out.push(0)
|
|
108
|
+
return Buffer.from(out)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** frames: [{ rgba, ms }] of one size → looping GIF. */
|
|
112
|
+
export function encodeGif (frames, { width, height, loop = 0 }) {
|
|
113
|
+
const parts = [Buffer.from('GIF89a')]
|
|
114
|
+
const lsd = Buffer.alloc(7); lsd.writeUInt16LE(width, 0); lsd.writeUInt16LE(height, 2); lsd[4] = 0xf7; parts.push(lsd) // global table of 256
|
|
115
|
+
const table = Buffer.alloc(256 * 3); PALETTE.forEach(([r, g, b], i) => { table[i * 3] = r; table[i * 3 + 1] = g; table[i * 3 + 2] = b }); parts.push(table)
|
|
116
|
+
parts.push(Buffer.from([0x21, 0xff, 0x0b, ...Buffer.from('NETSCAPE2.0'), 0x03, 0x01, loop & 0xff, loop >> 8, 0x00]))
|
|
117
|
+
for (const f of frames) {
|
|
118
|
+
const delay = Math.max(2, Math.round(f.ms / 10))
|
|
119
|
+
parts.push(Buffer.from([0x21, 0xf9, 0x04, 0x04, delay & 0xff, delay >> 8, 0x00, 0x00]))
|
|
120
|
+
const desc = Buffer.alloc(10); desc[0] = 0x2c; desc.writeUInt16LE(width, 5); desc.writeUInt16LE(height, 7); parts.push(desc)
|
|
121
|
+
parts.push(lzw(quantize(f.rgba, width, height)))
|
|
122
|
+
}
|
|
123
|
+
parts.push(Buffer.from([0x3b]))
|
|
124
|
+
return Buffer.concat(parts)
|
|
125
|
+
}
|