dsh-oc-tui 0.1.4 → 0.2.0-pre.13

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/lib/image.js ADDED
@@ -0,0 +1,326 @@
1
+ // Image pipeline: decode, scale to terminal cells, and encode for the best
2
+ // graphics protocol the terminal supports (kitty graphics, iTerm2 inline
3
+ // images, sixel) or ANSI halfblock characters as the universal fallback.
4
+ // sharp is injected by the caller (lazy import) so the module stays loadable
5
+ // in environments without it (tests, degraded installs).
6
+ import { createHash } from 'node:crypto'
7
+
8
+ export function sha16(bytes) {
9
+ return createHash('sha1').update(bytes).digest('hex').slice(0, 16)
10
+ }
11
+
12
+ // Small LRU for rendered payloads keyed by content hash + size + protocol:
13
+ // a scrolling transcript re-emits the same image many times, and sixel/iTerm2
14
+ // payloads are large enough that regenerating them per frame would stall.
15
+ export class PayloadCache {
16
+ constructor(max = 64) {
17
+ this.max = max
18
+ this.map = new Map()
19
+ }
20
+
21
+ get(key) {
22
+ if (!this.map.has(key)) return undefined
23
+ const value = this.map.get(key)
24
+ this.map.delete(key)
25
+ this.map.set(key, value)
26
+ return value
27
+ }
28
+
29
+ set(key, value) {
30
+ if (this.map.has(key)) this.map.delete(key)
31
+ else if (this.map.size >= this.max) this.map.delete(this.map.keys().next().value)
32
+ this.map.set(key, value)
33
+ }
34
+ }
35
+
36
+ // Fit an image of pxW x pxH into maxCellsW x maxCellsH cells without
37
+ // upscaling past its natural cell size. Cells are what the renderer reserves,
38
+ // so fractional cells round to whole ones (never zero).
39
+ export function scaleToCells(pxW, pxH, maxCellsW, maxCellsH, cellW, cellH) {
40
+ let w = pxW / cellW
41
+ let h = pxH / cellH
42
+ const factor = Math.min(maxCellsW / w, maxCellsH / h, 1)
43
+ w *= factor
44
+ h *= factor
45
+ return { cellsW: Math.max(1, Math.round(w)), cellsH: Math.max(1, Math.round(h)) }
46
+ }
47
+
48
+ const hex2 = (n) => n.toString(16).padStart(2, '0')
49
+
50
+ // Compose a source pixel over the background color and return the hex pair
51
+ // the terminal truecolor SGR expects.
52
+ function blendHex(r, g, b, a, bg) {
53
+ const f = a / 255
54
+ const rr = Math.round(r * f + bg[0] * (1 - f))
55
+ const gg = Math.round(g * f + bg[1] * (1 - f))
56
+ const bb = Math.round(b * f + bg[2] * (1 - f))
57
+ return hex2(rr) + hex2(gg) + hex2(bb)
58
+ }
59
+
60
+ // ANSI halfblock renderer: each cell shows two stacked pixel rows through the
61
+ // foreground/background pair of '▀' (a plain space when both rows match).
62
+ // Every glyph is a basic block present in every terminal font, which is what
63
+ // makes this the universal fallback. A cell averages the pixel block it covers
64
+ // (pxW/cellsW wide, pxH/cellsH tall split at half height), so downsampling is
65
+ // done here rather than by sampling a single pixel.
66
+ export function renderHalfblock(rgba, pxW, pxH, cellsW, cellsH, { bgHex = '000000' } = {}) {
67
+ const bg = [0, 0, 0]
68
+ if (/^[0-9a-fA-F]{6}$/.test(bgHex)) {
69
+ bg[0] = parseInt(bgHex.slice(0, 2), 16)
70
+ bg[1] = parseInt(bgHex.slice(2, 4), 16)
71
+ bg[2] = parseInt(bgHex.slice(4, 6), 16)
72
+ }
73
+ const cellPxW = pxW / cellsW
74
+ const cellPxH = pxH / cellsH
75
+ const halfH = Math.max(1, Math.floor(cellPxH / 2))
76
+ // Average an axis-aligned pixel block, alpha-composited over the background.
77
+ const blockColor = (x0, y0, w, h) => {
78
+ let r = 0, g = 0, b = 0, a = 0, n = 0
79
+ const xa = Math.max(0, Math.floor(x0))
80
+ const xb = Math.min(pxW, Math.ceil(x0 + w))
81
+ const ya = Math.max(0, Math.floor(y0))
82
+ const yb = Math.min(pxH, Math.ceil(y0 + h))
83
+ for (let y = ya; y < yb; y++) {
84
+ for (let x = xa; x < xb; x++) {
85
+ const i = (y * pxW + x) * 4
86
+ r += rgba[i]; g += rgba[i + 1]; b += rgba[i + 2]; a += rgba[i + 3]
87
+ n++
88
+ }
89
+ }
90
+ if (n === 0) return blendHex(0, 0, 0, 0, bg)
91
+ return blendHex(r / n, g / n, b / n, a / n, bg)
92
+ }
93
+ const lines = []
94
+ for (let cy = 0; cy < cellsH; cy++) {
95
+ const row = []
96
+ let last = null
97
+ for (let cx = 0; cx < cellsW; cx++) {
98
+ const x0 = cx * cellPxW
99
+ const y0 = cy * cellPxH
100
+ const top = blockColor(x0, y0, cellPxW, halfH)
101
+ const bottom = blockColor(x0, y0 + halfH, cellPxW, halfH)
102
+ let seg
103
+ if (top === bottom) seg = { text: ' ', style: { fg: top, bg: top } }
104
+ else seg = { text: '▀', style: { fg: top, bg: bottom } }
105
+ if (last && last.style.fg === seg.style.fg && last.style.bg === seg.style.bg) {
106
+ last.text += seg.text
107
+ } else {
108
+ row.push(seg)
109
+ last = seg
110
+ }
111
+ }
112
+ lines.push(row)
113
+ }
114
+ return lines
115
+ }
116
+
117
+ // Median-cut color quantization for sixel (which carries at most 256 palette
118
+ // entries). The histogram is sampled and looked up through a coarse 15-bit
119
+ // key so per-pixel palette matching costs one Map read, not a color scan.
120
+ function quantize(rgba, pxW, pxH, maxColors) {
121
+ const indices = new Uint8Array(pxW * pxH)
122
+ const total = pxW * pxH
123
+ const step = Math.max(1, Math.floor(total / 16384))
124
+ const histogram = new Map()
125
+ for (let i = 0; i < total; i += step) {
126
+ const o = i * 4
127
+ const key = ((rgba[o] >> 3) << 10) | ((rgba[o + 1] >> 3) << 5) | (rgba[o + 2] >> 3)
128
+ if (!histogram.has(key)) histogram.set(key, [rgba[o], rgba[o + 1], rgba[o + 2], 1])
129
+ else histogram.get(key)[3]++
130
+ }
131
+ let buckets = [[...histogram.values()]]
132
+ while (buckets.length < maxColors) {
133
+ let bi = -1
134
+ let range = 0
135
+ let chan = 0
136
+ buckets.forEach((b, i) => {
137
+ if (b.length < 2) return
138
+ for (let c = 0; c < 3; c++) {
139
+ let min = 255
140
+ let max = 0
141
+ for (const p of b) {
142
+ if (p[c] < min) min = p[c]
143
+ if (p[c] > max) max = p[c]
144
+ }
145
+ if (max - min > range) { range = max - min; bi = i; chan = c }
146
+ }
147
+ })
148
+ if (bi < 0) break
149
+ const b = buckets[bi]
150
+ b.sort((x, y) => x[chan] - y[chan])
151
+ const mid = b.length >> 1
152
+ buckets.splice(bi, 1, b.slice(0, mid), b.slice(mid))
153
+ }
154
+ const palette = buckets
155
+ .filter((b) => b.length > 0)
156
+ .map((b) => {
157
+ let r = 0, g = 0, bl = 0, n = 0
158
+ for (const p of b) { r += p[0]; g += p[1]; bl += p[2]; n++ }
159
+ return [Math.round(r / n), Math.round(g / n), Math.round(bl / n)]
160
+ })
161
+ // Coarse-key cache: every source color inside one 5-bit cube maps to the
162
+ // same palette entry, which keeps banding visible but the encoder fast.
163
+ const lookup = new Map()
164
+ const nearest = (r, g, b) => {
165
+ const key = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3)
166
+ let idx = lookup.get(key)
167
+ if (idx !== undefined) return idx
168
+ let best = 0
169
+ let bestD = Infinity
170
+ for (let i = 0; i < palette.length; i++) {
171
+ const p = palette[i]
172
+ const dr = r - p[0], dg = g - p[1], db = b - p[2]
173
+ const d = dr * dr + dg * dg + db * db
174
+ if (d < bestD) { bestD = d; best = i }
175
+ }
176
+ lookup.set(key, best)
177
+ return best
178
+ }
179
+ for (let i = 0; i < total; i++) {
180
+ const o = i * 4
181
+ indices[i] = nearest(rgba[o], rgba[o + 1], rgba[o + 2])
182
+ }
183
+ return { palette, indices }
184
+ }
185
+
186
+ // Sixel encoder (DEC graphics). The raster attributes ("1;1;<H>;<W>") force
187
+ // square pixels — the bare DCS P1=0 means 2:1 on some terminals and would
188
+ // stretch every image to twice its reserved rows. P2=1 leaves unset pixels
189
+ // transparent so the text grid behind the image (the bubble/canvas background
190
+ // the renderer already painted) shows through instead of a painted rectangle.
191
+ //
192
+ // Within a color pass, EVERY column emits a data character — blank columns
193
+ // send '?' (bits 0). The sixel cursor only advances on data characters, so
194
+ // skipping gaps would shift everything after the first hole sideways.
195
+ export function encodeSixel(rgba, pxW, pxH) {
196
+ const { palette, indices } = quantize(rgba, pxW, pxH, 256)
197
+ const parts = [`\x1bP0;1;1q"1;1;${pxH};${pxW}`]
198
+ const bands = Math.ceil(pxH / 6)
199
+ for (let band = 0; band < bands; band++) {
200
+ if (band > 0) parts.push('-')
201
+ // Which palette entries appear in this band, most-used first so the
202
+ // dominant color switches cost the least.
203
+ const used = new Map()
204
+ for (let y = band * 6; y < Math.min(pxH, band * 6 + 6); y++) {
205
+ for (let x = 0; x < pxW; x++) {
206
+ const idx = indices[y * pxW + x]
207
+ used.set(idx, (used.get(idx) ?? 0) + 1)
208
+ }
209
+ }
210
+ const order = [...used.entries()].sort((a, b) => b[1] - a[1]).map(([i]) => i)
211
+ for (const idx of order) {
212
+ const p = palette[idx]
213
+ parts.push(`#${idx};2;${Math.round(p[0] / 2.55)};${Math.round(p[1] / 2.55)};${Math.round(p[2] / 2.55)}`)
214
+ let runChar = ''
215
+ let run = 0
216
+ const flush = () => {
217
+ if (run === 0) return
218
+ parts.push(run > 3 ? `!${run}${runChar}` : runChar.repeat(run))
219
+ run = 0
220
+ }
221
+ for (let x = 0; x < pxW; x++) {
222
+ let bits = 0
223
+ for (let r = 0; r < 6; r++) {
224
+ const y = band * 6 + r
225
+ if (y < pxH && indices[y * pxW + x] === idx) bits |= 1 << r
226
+ }
227
+ const ch = String.fromCharCode(0x3F + bits)
228
+ if (ch === runChar) run++
229
+ else { flush(); runChar = ch; run = 1 }
230
+ }
231
+ flush()
232
+ parts.push('$')
233
+ }
234
+ }
235
+ parts.push('\x1b\\')
236
+ return parts.join('')
237
+ }
238
+
239
+ // Kitty graphics protocol. Transmission: base64 PNG in 4096-char chunks with
240
+ // the continuation flag m; the first chunk carries the full control params.
241
+ // Placement happens separately (kittyPlacement) so a scrolled transcript moves
242
+ // an already-transmitted image for free instead of resending pixel data.
243
+ export function encodeKittyTransmission(id, b64) {
244
+ const parts = []
245
+ for (let i = 0; i < b64.length; i += 4096) {
246
+ const chunk = b64.slice(i, i + 4096)
247
+ const last = i + 4096 >= b64.length
248
+ const control = i === 0
249
+ ? `a=T,f=100,q=2,i=${id},m=${last ? 0 : 1}`
250
+ : `m=${last ? 0 : 1}`
251
+ parts.push(`\x1b_G${control};${chunk}\x1b\\`)
252
+ }
253
+ if (parts.length === 0) parts.push(`\x1b_Ga=T,f=100,q=2,i=${id},m=0;\x1b\\`)
254
+ return parts.join('')
255
+ }
256
+
257
+ // Place a previously transmitted image with its top-left cell at (x, y).
258
+ // The CSI prefix moves the (hidden) cursor there; C=1 keeps the placement
259
+ // from advancing it, and q=2 suppresses the terminal's response frames.
260
+ export function kittyPlacement(id, x, y) {
261
+ return `\x1b[${y + 1};${x + 1}H\x1b_Ga=p,i=${id},C=1,q=2\x1b\\`
262
+ }
263
+
264
+ export function kittyDelete(id) {
265
+ return `\x1b_Ga=d,d=i,i=${id},q=2\x1b\\`
266
+ }
267
+
268
+ export function kittyDeleteAll() {
269
+ return '\x1b_Ga=d,d=a,q=2\x1b\\'
270
+ }
271
+
272
+ // iTerm2 inline image (also WezTerm, recent Konsole, mintty). There is no
273
+ // image registry on the terminal side, so moving an image means re-emitting
274
+ // the payload at the new cursor position; callers cache the payload string.
275
+ export function encodeITerm2(b64, pxW, pxH, byteLength = b64.length) {
276
+ return `\x1b]1337;File=inline=1;size=${byteLength};width=${pxW}px;height=${pxH}px:name=x.png:${b64}\x07`
277
+ }
278
+
279
+ // Copy the pixel rows [top, height) out of an RGBA raster. A cropped variant
280
+ // carries exactly the band that is still on screen; re-encoding the full
281
+ // raster would draw the rows that scrolled away on top of the ones below.
282
+ function cropRows(rgba, width, height, top) {
283
+ const out = Buffer.allocUnsafe(width * (height - top) * 4)
284
+ rgba.copy(out, 0, top * width * 4)
285
+ return out
286
+ }
287
+
288
+ // Decode + scale + encode one image for the requested protocol. `cropCells`
289
+ // drops that many cell rows off the TOP before encoding, for the image whose
290
+ // first reserved row has scrolled above the transcript window: what is left is
291
+ // the band the window still shows, and it has to be encoded at that height or
292
+ // it would cover the rows underneath it.
293
+ // Returns { protocol, cellsW, cellsH, hash, payload? | segLines? } on success
294
+ // or { protocol: 'error', error } — never throws.
295
+ export async function renderImage(source, { protocol, maxCellsW, maxCellsH, cellW = 8, cellH = 16, bgHex = '000000', cropCells = 0, sharp: injectedSharp }) {
296
+ const bytes = source?.bytes
297
+ const hash = sha16(bytes ?? Buffer.alloc(0))
298
+ if (!bytes) return { protocol: 'error', error: 'no image bytes', hash }
299
+ try {
300
+ const sharp = injectedSharp ?? (await import('sharp')).default
301
+ const meta = await sharp(bytes, { animated: false }).metadata()
302
+ if (!meta.width || !meta.height) throw new Error('image has no dimensions')
303
+ const { cellsW, cellsH } = scaleToCells(meta.width, meta.height, maxCellsW, maxCellsH, cellW, cellH)
304
+ const clip = Math.max(0, Math.min(Math.floor(cropCells), cellsH - 1))
305
+ const pxW = cellsW * cellW
306
+ const pxH = cellsH * cellH
307
+ const rows = cellsH - clip
308
+ const bandH = pxH - clip * cellH
309
+ const scaled = await sharp(bytes, { animated: false }).resize(pxW, pxH, { fit: 'fill' }).ensureAlpha()
310
+ .raw().toBuffer({ resolveWithObject: true })
311
+ const data = clip > 0 ? cropRows(scaled.data, pxW, pxH, clip * cellH) : scaled.data
312
+ if (protocol === 'kitty' || protocol === 'iterm2') {
313
+ const png = await sharp(data, { raw: { width: pxW, height: bandH, channels: 4 } }).png().toBuffer()
314
+ const b64 = png.toString('base64')
315
+ if (protocol === 'kitty') return { protocol, cellsW, cellsH: rows, hash, payload: { kind: 'kitty', b64, bytes: png.length } }
316
+ return { protocol, cellsW, cellsH: rows, hash, payload: { kind: 'iterm2', b64, bytes: png.length, pxW, pxH: bandH } }
317
+ }
318
+ if (protocol === 'sixel') {
319
+ return { protocol, cellsW, cellsH: rows, hash, payload: { kind: 'sixel', s: encodeSixel(data, pxW, bandH) } }
320
+ }
321
+ const segLines = renderHalfblock(data, pxW, bandH, cellsW, rows, { bgHex })
322
+ return { protocol: 'halfblock', cellsW, cellsH: rows, hash, segLines }
323
+ } catch (e) {
324
+ return { protocol: 'error', error: String(e?.message ?? e), hash }
325
+ }
326
+ }