astroshot 0.2.1 → 0.2.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.
Files changed (99) hide show
  1. package/node_modules/@archastro/astroshot/README.md +4 -0
  2. package/node_modules/@archastro/astroshot/bin/astroshot.mjs +56 -1
  3. package/node_modules/@archastro/astroshot/bin/demo.mjs +72 -16
  4. package/node_modules/@archastro/astroshot/package.json +5 -4
  5. package/node_modules/@archastro/astroshot-review/LICENSE +21 -0
  6. package/node_modules/@archastro/astroshot-review/README.md +19 -0
  7. package/node_modules/@archastro/astroshot-review/bin/astroshot-review.mjs +17 -0
  8. package/node_modules/@archastro/astroshot-review/dist/cli.d.ts +12 -0
  9. package/node_modules/@archastro/astroshot-review/dist/cli.js +240 -0
  10. package/node_modules/@archastro/astroshot-review/dist/data/friction.d.ts +22 -0
  11. package/node_modules/@archastro/astroshot-review/dist/data/friction.js +278 -0
  12. package/node_modules/@archastro/astroshot-review/dist/data/hash-cache.d.ts +17 -0
  13. package/node_modules/@archastro/astroshot-review/dist/data/hash-cache.js +51 -0
  14. package/node_modules/@archastro/astroshot-review/dist/data/index-cache.d.ts +22 -0
  15. package/node_modules/@archastro/astroshot-review/dist/data/index-cache.js +64 -0
  16. package/node_modules/@archastro/astroshot-review/dist/data/manifest.d.ts +41 -0
  17. package/node_modules/@archastro/astroshot-review/dist/data/manifest.js +105 -0
  18. package/node_modules/@archastro/astroshot-review/dist/data/model.d.ts +96 -0
  19. package/node_modules/@archastro/astroshot-review/dist/data/model.js +1 -0
  20. package/node_modules/@archastro/astroshot-review/dist/data/paths.d.ts +27 -0
  21. package/node_modules/@archastro/astroshot-review/dist/data/paths.js +98 -0
  22. package/node_modules/@archastro/astroshot-review/dist/data/review-store.d.ts +67 -0
  23. package/node_modules/@archastro/astroshot-review/dist/data/review-store.js +237 -0
  24. package/node_modules/@archastro/astroshot-review/dist/data/scan.d.ts +32 -0
  25. package/node_modules/@archastro/astroshot-review/dist/data/scan.js +227 -0
  26. package/node_modules/@archastro/astroshot-review/dist/data/store.d.ts +92 -0
  27. package/node_modules/@archastro/astroshot-review/dist/data/store.js +408 -0
  28. package/node_modules/@archastro/astroshot-review/dist/data/watcher.d.ts +37 -0
  29. package/node_modules/@archastro/astroshot-review/dist/data/watcher.js +126 -0
  30. package/node_modules/@archastro/astroshot-review/dist/images/halfblocks.d.ts +10 -0
  31. package/node_modules/@archastro/astroshot-review/dist/images/halfblocks.js +21 -0
  32. package/node_modules/@archastro/astroshot-review/dist/images/png.d.ts +14 -0
  33. package/node_modules/@archastro/astroshot-review/dist/images/png.js +45 -0
  34. package/node_modules/@archastro/astroshot-review/dist/images/scale.d.ts +31 -0
  35. package/node_modules/@archastro/astroshot-review/dist/images/scale.js +102 -0
  36. package/node_modules/@archastro/astroshot-review/dist/images/service.d.ts +54 -0
  37. package/node_modules/@archastro/astroshot-review/dist/images/service.js +163 -0
  38. package/node_modules/@archastro/astroshot-review/dist/images/worker.d.ts +21 -0
  39. package/node_modules/@archastro/astroshot-review/dist/images/worker.js +30 -0
  40. package/node_modules/@archastro/astroshot-review/dist/index.d.ts +12 -0
  41. package/node_modules/@archastro/astroshot-review/dist/index.js +9 -0
  42. package/node_modules/@archastro/astroshot-review/dist/terminal/graphics-stdout.d.ts +9 -0
  43. package/node_modules/@archastro/astroshot-review/dist/terminal/graphics-stdout.js +36 -0
  44. package/node_modules/@archastro/astroshot-review/dist/terminal/herdr.d.ts +61 -0
  45. package/node_modules/@archastro/astroshot-review/dist/terminal/herdr.js +327 -0
  46. package/node_modules/@archastro/astroshot-review/dist/terminal/image-layer.d.ts +122 -0
  47. package/node_modules/@archastro/astroshot-review/dist/terminal/image-layer.js +471 -0
  48. package/node_modules/@archastro/astroshot-review/dist/terminal/kitty.d.ts +74 -0
  49. package/node_modules/@archastro/astroshot-review/dist/terminal/kitty.js +112 -0
  50. package/node_modules/@archastro/astroshot-review/dist/terminal/probe.d.ts +49 -0
  51. package/node_modules/@archastro/astroshot-review/dist/terminal/probe.js +206 -0
  52. package/node_modules/@archastro/astroshot-review/dist/ui/app.d.ts +6 -0
  53. package/node_modules/@archastro/astroshot-review/dist/ui/app.js +695 -0
  54. package/node_modules/@archastro/astroshot-review/dist/ui/chrome.d.ts +53 -0
  55. package/node_modules/@archastro/astroshot-review/dist/ui/chrome.js +69 -0
  56. package/node_modules/@archastro/astroshot-review/dist/ui/context.d.ts +16 -0
  57. package/node_modules/@archastro/astroshot-review/dist/ui/context.js +8 -0
  58. package/node_modules/@archastro/astroshot-review/dist/ui/detail.d.ts +33 -0
  59. package/node_modules/@archastro/astroshot-review/dist/ui/detail.js +39 -0
  60. package/node_modules/@archastro/astroshot-review/dist/ui/friction.d.ts +42 -0
  61. package/node_modules/@archastro/astroshot-review/dist/ui/friction.js +84 -0
  62. package/node_modules/@archastro/astroshot-review/dist/ui/help.d.ts +4 -0
  63. package/node_modules/@archastro/astroshot-review/dist/ui/help.js +61 -0
  64. package/node_modules/@archastro/astroshot-review/dist/ui/hooks.d.ts +9 -0
  65. package/node_modules/@archastro/astroshot-review/dist/ui/hooks.js +32 -0
  66. package/node_modules/@archastro/astroshot-review/dist/ui/movie-player.d.ts +29 -0
  67. package/node_modules/@archastro/astroshot-review/dist/ui/movie-player.js +119 -0
  68. package/node_modules/@archastro/astroshot-review/dist/ui/picture.d.ts +19 -0
  69. package/node_modules/@archastro/astroshot-review/dist/ui/picture.js +100 -0
  70. package/node_modules/@archastro/astroshot-review/dist/ui/selectors.d.ts +26 -0
  71. package/node_modules/@archastro/astroshot-review/dist/ui/selectors.js +69 -0
  72. package/node_modules/@archastro/astroshot-review/dist/ui/settings.d.ts +5 -0
  73. package/node_modules/@archastro/astroshot-review/dist/ui/settings.js +17 -0
  74. package/node_modules/@archastro/astroshot-review/dist/ui/stream.d.ts +38 -0
  75. package/node_modules/@archastro/astroshot-review/dist/ui/stream.js +118 -0
  76. package/node_modules/@archastro/astroshot-review/dist/ui/system.d.ts +4 -0
  77. package/node_modules/@archastro/astroshot-review/dist/ui/system.js +34 -0
  78. package/node_modules/@archastro/astroshot-review/dist/ui/takeover.d.ts +26 -0
  79. package/node_modules/@archastro/astroshot-review/dist/ui/takeover.js +29 -0
  80. package/node_modules/@archastro/astroshot-review/dist/ui/text-input.d.ts +8 -0
  81. package/node_modules/@archastro/astroshot-review/dist/ui/text-input.js +74 -0
  82. package/node_modules/@archastro/astroshot-review/dist/ui/theme.d.ts +21 -0
  83. package/node_modules/@archastro/astroshot-review/dist/ui/theme.js +63 -0
  84. package/node_modules/@archastro/astroshot-review/dist/video/ffmpeg.d.ts +54 -0
  85. package/node_modules/@archastro/astroshot-review/dist/video/ffmpeg.js +206 -0
  86. package/node_modules/@archastro/astroshot-review/package.json +71 -0
  87. package/node_modules/@archastro/movie-harness/package.json +1 -1
  88. package/node_modules/@archastro/react-shot/package.json +4 -4
  89. package/node_modules/@archastro/tui-shot/README.md +9 -0
  90. package/node_modules/@archastro/tui-shot/dist/index.d.ts +2 -0
  91. package/node_modules/@archastro/tui-shot/dist/index.js +1 -0
  92. package/node_modules/@archastro/tui-shot/dist/kitty-graphics.d.ts +45 -0
  93. package/node_modules/@archastro/tui-shot/dist/kitty-graphics.js +279 -0
  94. package/node_modules/@archastro/tui-shot/dist/pty-shot.js +25 -3
  95. package/node_modules/@archastro/tui-shot/dist/shot.d.ts +2 -0
  96. package/node_modules/@archastro/tui-shot/dist/shot.js +3 -1
  97. package/node_modules/@archastro/tui-shot/dist/types.d.ts +6 -0
  98. package/node_modules/@archastro/tui-shot/package.json +1 -1
  99. package/package.json +10 -5
@@ -0,0 +1,14 @@
1
+ export interface ImageSize {
2
+ width: number;
3
+ height: number;
4
+ }
5
+ /** Read width/height from a PNG's IHDR chunk without decoding pixels. */
6
+ export declare function readPngSize(bytes: Buffer): ImageSize | null;
7
+ /** Read just enough of a file to learn its pixel size. */
8
+ export declare function readPngSizeFromFile(filePath: string): Promise<ImageSize | null>;
9
+ export declare function isPngPath(filePath: string): boolean;
10
+ /**
11
+ * Largest box that fits inside `bounds` while keeping `source`'s aspect
12
+ * ratio. Never scales up: a small source keeps its own size.
13
+ */
14
+ export declare function fitInside(source: ImageSize, bounds: ImageSize): ImageSize;
@@ -0,0 +1,45 @@
1
+ import fs from "node:fs";
2
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
3
+ /** Read width/height from a PNG's IHDR chunk without decoding pixels. */
4
+ export function readPngSize(bytes) {
5
+ if (bytes.length < 24)
6
+ return null;
7
+ if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE))
8
+ return null;
9
+ if (bytes.toString("ascii", 12, 16) !== "IHDR")
10
+ return null;
11
+ const width = bytes.readUInt32BE(16);
12
+ const height = bytes.readUInt32BE(20);
13
+ if (width === 0 || height === 0)
14
+ return null;
15
+ return { width, height };
16
+ }
17
+ /** Read just enough of a file to learn its pixel size. */
18
+ export async function readPngSizeFromFile(filePath) {
19
+ const handle = await fs.promises.open(filePath, "r");
20
+ try {
21
+ const header = Buffer.alloc(32);
22
+ const { bytesRead } = await handle.read(header, 0, 32, 0);
23
+ return readPngSize(header.subarray(0, bytesRead));
24
+ }
25
+ finally {
26
+ await handle.close();
27
+ }
28
+ }
29
+ export function isPngPath(filePath) {
30
+ return /\.png$/i.test(filePath);
31
+ }
32
+ /**
33
+ * Largest box that fits inside `bounds` while keeping `source`'s aspect
34
+ * ratio. Never scales up: a small source keeps its own size.
35
+ */
36
+ export function fitInside(source, bounds) {
37
+ if (source.width <= bounds.width && source.height <= bounds.height) {
38
+ return { width: source.width, height: source.height };
39
+ }
40
+ const scale = Math.min(bounds.width / source.width, bounds.height / source.height);
41
+ return {
42
+ width: Math.max(1, Math.round(source.width * scale)),
43
+ height: Math.max(1, Math.round(source.height * scale)),
44
+ };
45
+ }
@@ -0,0 +1,31 @@
1
+ import { type ImageSize } from "./png.js";
2
+ export type ScaledFormat = "png" | "rgb";
3
+ export interface Rect {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export interface ScaleRequest {
10
+ bytes: Buffer;
11
+ target: ImageSize;
12
+ /** png keeps alpha; rgb returns packed 3-byte pixels for cell art. */
13
+ format: ScaledFormat;
14
+ /** Optional source-pixel crop applied before scaling (for zoom). */
15
+ crop?: Rect;
16
+ }
17
+ /** Copy a sub-rectangle out of an RGBA buffer, clamped to the image bounds. */
18
+ export declare function cropRgba(source: Buffer, size: ImageSize, rect: Rect): {
19
+ data: Buffer;
20
+ size: ImageSize;
21
+ };
22
+ export interface ScaledImage {
23
+ width: number;
24
+ height: number;
25
+ format: ScaledFormat;
26
+ data: Buffer;
27
+ }
28
+ /** Area-average RGBA resample. Exact for integer ratios, good enough otherwise. */
29
+ export declare function resampleRgba(source: Buffer, sourceSize: ImageSize, target: ImageSize): Buffer;
30
+ export declare function rgbaToRgb(rgba: Buffer, pixelCount: number, background?: number[]): Buffer;
31
+ export declare function scaleImage(request: ScaleRequest): ScaledImage;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Pure-JS PNG decode, area-average downscale, and re-encode. Runs either on
3
+ * a worker thread or inline; it has no dependency on threads itself.
4
+ */
5
+ import { PNG } from "pngjs";
6
+ import { fitInside } from "./png.js";
7
+ /** Copy a sub-rectangle out of an RGBA buffer, clamped to the image bounds. */
8
+ export function cropRgba(source, size, rect) {
9
+ const x = Math.max(0, Math.min(size.width - 1, Math.round(rect.x)));
10
+ const y = Math.max(0, Math.min(size.height - 1, Math.round(rect.y)));
11
+ const width = Math.max(1, Math.min(size.width - x, Math.round(rect.width)));
12
+ const height = Math.max(1, Math.min(size.height - y, Math.round(rect.height)));
13
+ const out = Buffer.alloc(width * height * 4);
14
+ for (let row = 0; row < height; row += 1) {
15
+ const srcStart = ((y + row) * size.width + x) * 4;
16
+ source.copy(out, row * width * 4, srcStart, srcStart + width * 4);
17
+ }
18
+ return { data: out, size: { width, height } };
19
+ }
20
+ /** Area-average RGBA resample. Exact for integer ratios, good enough otherwise. */
21
+ export function resampleRgba(source, sourceSize, target) {
22
+ const { width: sw, height: sh } = sourceSize;
23
+ const { width: tw, height: th } = target;
24
+ const out = Buffer.alloc(tw * th * 4);
25
+ const xRatio = sw / tw;
26
+ const yRatio = sh / th;
27
+ for (let ty = 0; ty < th; ty += 1) {
28
+ const y0 = Math.floor(ty * yRatio);
29
+ const y1 = Math.max(y0 + 1, Math.floor((ty + 1) * yRatio));
30
+ for (let tx = 0; tx < tw; tx += 1) {
31
+ const x0 = Math.floor(tx * xRatio);
32
+ const x1 = Math.max(x0 + 1, Math.floor((tx + 1) * xRatio));
33
+ let r = 0;
34
+ let g = 0;
35
+ let b = 0;
36
+ let a = 0;
37
+ let count = 0;
38
+ for (let y = y0; y < y1 && y < sh; y += 1) {
39
+ let offset = (y * sw + x0) * 4;
40
+ for (let x = x0; x < x1 && x < sw; x += 1) {
41
+ const alpha = source[offset + 3];
42
+ // Premultiply so transparent pixels do not bleed their color.
43
+ r += source[offset] * alpha;
44
+ g += source[offset + 1] * alpha;
45
+ b += source[offset + 2] * alpha;
46
+ a += alpha;
47
+ count += 1;
48
+ offset += 4;
49
+ }
50
+ }
51
+ const index = (ty * tw + tx) * 4;
52
+ if (a > 0) {
53
+ out[index] = Math.round(r / a);
54
+ out[index + 1] = Math.round(g / a);
55
+ out[index + 2] = Math.round(b / a);
56
+ out[index + 3] = Math.round(a / count);
57
+ }
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ export function rgbaToRgb(rgba, pixelCount, background = [24, 24, 28]) {
63
+ const out = Buffer.alloc(pixelCount * 3);
64
+ for (let index = 0; index < pixelCount; index += 1) {
65
+ const alpha = rgba[index * 4 + 3] / 255;
66
+ for (let channel = 0; channel < 3; channel += 1) {
67
+ const value = rgba[index * 4 + channel];
68
+ out[index * 3 + channel] = Math.round(value * alpha + background[channel] * (1 - alpha));
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+ export function scaleImage(request) {
74
+ const decoded = PNG.sync.read(request.bytes);
75
+ let sourceData = decoded.data;
76
+ let sourceSize = { width: decoded.width, height: decoded.height };
77
+ if (request.crop) {
78
+ const cropped = cropRgba(sourceData, sourceSize, request.crop);
79
+ sourceData = cropped.data;
80
+ sourceSize = cropped.size;
81
+ }
82
+ const target = fitInside(sourceSize, request.target);
83
+ const rgba = target.width === sourceSize.width && target.height === sourceSize.height
84
+ ? sourceData
85
+ : resampleRgba(sourceData, sourceSize, target);
86
+ if (request.format === "rgb") {
87
+ return {
88
+ width: target.width,
89
+ height: target.height,
90
+ format: "rgb",
91
+ data: rgbaToRgb(rgba, target.width * target.height),
92
+ };
93
+ }
94
+ const png = new PNG({ width: target.width, height: target.height });
95
+ png.data = rgba;
96
+ return {
97
+ width: target.width,
98
+ height: target.height,
99
+ format: "png",
100
+ data: PNG.sync.write(png, { deflateLevel: 6 }),
101
+ };
102
+ }
@@ -0,0 +1,54 @@
1
+ import { type ImageSize } from "./png.js";
2
+ import type { Rect } from "./scale.js";
3
+ import { type ScaledFormat } from "./scale.js";
4
+ export interface PreparedImage {
5
+ key: string;
6
+ path: string;
7
+ /** Pixel size of the prepared payload. */
8
+ width: number;
9
+ height: number;
10
+ /** Pixel size of the source file. */
11
+ sourceWidth: number;
12
+ sourceHeight: number;
13
+ format: ScaledFormat;
14
+ data: Buffer;
15
+ /** True when `data` is the untouched file, so a file-path transmission is valid. */
16
+ isOriginal: boolean;
17
+ mtimeMs: number;
18
+ size: number;
19
+ }
20
+ export interface ImageServiceOptions {
21
+ /** 0 runs decode inline (tests); default is min(2, cpus-1). */
22
+ workers?: number;
23
+ /** Memory budget for prepared payloads. */
24
+ cacheBytes?: number;
25
+ /** A source at most this many times larger than the target is sent as-is. */
26
+ passthroughRatio?: number;
27
+ }
28
+ export declare class ImageService {
29
+ private readonly cache;
30
+ private readonly cacheOrder;
31
+ private cachedBytes;
32
+ private readonly cacheBytes;
33
+ private readonly passthroughRatio;
34
+ private readonly workerCount;
35
+ private workers;
36
+ private nextWorker;
37
+ private nextJobId;
38
+ private readonly pending;
39
+ private disposed;
40
+ constructor(options?: ImageServiceOptions);
41
+ /** Identity + target size key. Callers can use it for placement bookkeeping. */
42
+ static cacheKey(filePath: string, stat: {
43
+ mtimeMs: number;
44
+ size: number;
45
+ }, target: ImageSize, format: ScaledFormat, crop?: Rect): string;
46
+ prepare(filePath: string, target: ImageSize, format?: ScaledFormat, crop?: Rect): Promise<PreparedImage>;
47
+ private build;
48
+ private scale;
49
+ private pickWorker;
50
+ private touch;
51
+ private account;
52
+ private evict;
53
+ dispose(): Promise<void>;
54
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Prepares image bytes for the terminal: reads the file, decides whether the
3
+ * original PNG is already small enough, otherwise downsamples on a worker
4
+ * thread, and caches the result by file identity and target size.
5
+ */
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import { Worker } from "node:worker_threads";
9
+ import { fitInside, readPngSize } from "./png.js";
10
+ import { scaleImage } from "./scale.js";
11
+ export class ImageService {
12
+ cache = new Map();
13
+ cacheOrder = [];
14
+ cachedBytes = 0;
15
+ cacheBytes;
16
+ passthroughRatio;
17
+ workerCount;
18
+ workers = [];
19
+ nextWorker = 0;
20
+ nextJobId = 1;
21
+ pending = new Map();
22
+ disposed = false;
23
+ constructor(options = {}) {
24
+ this.workerCount =
25
+ options.workers ?? Math.max(0, Math.min(2, os.availableParallelism() - 1));
26
+ this.cacheBytes = options.cacheBytes ?? 96 * 1024 * 1024;
27
+ this.passthroughRatio = options.passthroughRatio ?? 1.35;
28
+ }
29
+ /** Identity + target size key. Callers can use it for placement bookkeeping. */
30
+ static cacheKey(filePath, stat, target, format, crop) {
31
+ const cropKey = crop ? `|${Math.round(crop.x)},${Math.round(crop.y)},${Math.round(crop.width)},${Math.round(crop.height)}` : "";
32
+ return `${filePath}|${Math.round(stat.mtimeMs)}|${stat.size}|${target.width}x${target.height}|${format}${cropKey}`;
33
+ }
34
+ async prepare(filePath, target, format = "png", crop) {
35
+ const stat = await fs.promises.stat(filePath);
36
+ const key = ImageService.cacheKey(filePath, stat, target, format, crop);
37
+ const cached = this.cache.get(key);
38
+ if (cached) {
39
+ this.touch(key);
40
+ return cached;
41
+ }
42
+ const job = this.build(filePath, stat, target, format, key, crop);
43
+ this.cache.set(key, job);
44
+ this.cacheOrder.push(key);
45
+ job.then((prepared) => this.account(key, prepared.data.length), () => this.evict(key));
46
+ return job;
47
+ }
48
+ async build(filePath, stat, target, format, key, crop) {
49
+ const bytes = await fs.promises.readFile(filePath);
50
+ const source = readPngSize(bytes);
51
+ if (!source)
52
+ throw new Error(`Not a PNG image: ${filePath}`);
53
+ const base = {
54
+ key,
55
+ path: filePath,
56
+ sourceWidth: source.width,
57
+ sourceHeight: source.height,
58
+ mtimeMs: stat.mtimeMs,
59
+ size: stat.size,
60
+ };
61
+ if (!crop) {
62
+ const fitted = fitInside(source, target);
63
+ const ratio = Math.max(source.width / fitted.width, source.height / fitted.height);
64
+ if (format === "png" && ratio <= this.passthroughRatio) {
65
+ return { ...base, width: source.width, height: source.height, format: "png", data: bytes, isOriginal: true };
66
+ }
67
+ }
68
+ const scaled = await this.scale(bytes, target, format, crop);
69
+ return { ...base, ...scaled, isOriginal: false };
70
+ }
71
+ async scale(bytes, target, format, crop) {
72
+ if (this.workerCount === 0) {
73
+ const scaled = scaleImage({ bytes, target, format, crop });
74
+ return { width: scaled.width, height: scaled.height, format: scaled.format, data: scaled.data };
75
+ }
76
+ const worker = this.pickWorker();
77
+ const id = this.nextJobId++;
78
+ const payload = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
79
+ const response = await new Promise((resolve) => {
80
+ this.pending.set(id, { resolve });
81
+ const request = {
82
+ id,
83
+ bytes: payload,
84
+ targetWidth: target.width,
85
+ targetHeight: target.height,
86
+ format,
87
+ crop,
88
+ };
89
+ worker.postMessage(request, [payload]);
90
+ });
91
+ if (!response.ok)
92
+ throw new Error(response.error);
93
+ return {
94
+ width: response.width,
95
+ height: response.height,
96
+ format: response.format,
97
+ data: Buffer.from(response.data),
98
+ };
99
+ }
100
+ pickWorker() {
101
+ if (this.workers.length < this.workerCount) {
102
+ const worker = new Worker(new URL("./worker.js", import.meta.url));
103
+ worker.unref();
104
+ worker.on("message", (response) => {
105
+ const job = this.pending.get(response.id);
106
+ if (!job)
107
+ return;
108
+ this.pending.delete(response.id);
109
+ job.resolve(response);
110
+ });
111
+ worker.on("error", (error) => {
112
+ for (const [id, job] of this.pending) {
113
+ job.resolve({ id, ok: false, error: error.message });
114
+ }
115
+ this.pending.clear();
116
+ this.workers = this.workers.filter((candidate) => candidate !== worker);
117
+ });
118
+ this.workers.push(worker);
119
+ return worker;
120
+ }
121
+ const worker = this.workers[this.nextWorker % this.workers.length];
122
+ this.nextWorker += 1;
123
+ return worker;
124
+ }
125
+ touch(key) {
126
+ const index = this.cacheOrder.indexOf(key);
127
+ if (index !== -1) {
128
+ this.cacheOrder.splice(index, 1);
129
+ this.cacheOrder.push(key);
130
+ }
131
+ }
132
+ account(key, bytes) {
133
+ this.cachedBytes += bytes;
134
+ while (this.cachedBytes > this.cacheBytes && this.cacheOrder.length > 1) {
135
+ const oldest = this.cacheOrder[0];
136
+ if (oldest === key)
137
+ break;
138
+ this.evict(oldest);
139
+ }
140
+ }
141
+ evict(key) {
142
+ const job = this.cache.get(key);
143
+ if (!job)
144
+ return;
145
+ this.cache.delete(key);
146
+ const index = this.cacheOrder.indexOf(key);
147
+ if (index !== -1)
148
+ this.cacheOrder.splice(index, 1);
149
+ job.then((prepared) => {
150
+ this.cachedBytes -= prepared.data.length;
151
+ }, () => undefined);
152
+ }
153
+ async dispose() {
154
+ if (this.disposed)
155
+ return;
156
+ this.disposed = true;
157
+ await Promise.all(this.workers.map((worker) => worker.terminate()));
158
+ this.workers = [];
159
+ this.cache.clear();
160
+ this.cacheOrder.length = 0;
161
+ this.cachedBytes = 0;
162
+ }
163
+ }
@@ -0,0 +1,21 @@
1
+ import { type Rect, type ScaledFormat } from "./scale.js";
2
+ export interface WorkerRequest {
3
+ id: number;
4
+ bytes: ArrayBuffer;
5
+ targetWidth: number;
6
+ targetHeight: number;
7
+ format: ScaledFormat;
8
+ crop?: Rect;
9
+ }
10
+ export type WorkerResponse = {
11
+ id: number;
12
+ ok: true;
13
+ width: number;
14
+ height: number;
15
+ format: ScaledFormat;
16
+ data: ArrayBuffer;
17
+ } | {
18
+ id: number;
19
+ ok: false;
20
+ error: string;
21
+ };
@@ -0,0 +1,30 @@
1
+ import { parentPort } from "node:worker_threads";
2
+ import { scaleImage } from "./scale.js";
3
+ parentPort?.on("message", (request) => {
4
+ try {
5
+ const scaled = scaleImage({
6
+ bytes: Buffer.from(request.bytes),
7
+ target: { width: request.targetWidth, height: request.targetHeight },
8
+ format: request.format,
9
+ crop: request.crop,
10
+ });
11
+ const data = scaled.data.buffer.slice(scaled.data.byteOffset, scaled.data.byteOffset + scaled.data.byteLength);
12
+ const response = {
13
+ id: request.id,
14
+ ok: true,
15
+ width: scaled.width,
16
+ height: scaled.height,
17
+ format: scaled.format,
18
+ data,
19
+ };
20
+ parentPort?.postMessage(response, [data]);
21
+ }
22
+ catch (error) {
23
+ const response = {
24
+ id: request.id,
25
+ ok: false,
26
+ error: error instanceof Error ? error.message : String(error),
27
+ };
28
+ parentPort?.postMessage(response);
29
+ }
30
+ });
@@ -0,0 +1,12 @@
1
+ export { main, parseArgs, reviewHelp } from "./cli.js";
2
+ export { ReviewStore } from "./data/store.js";
3
+ export type { StoreState } from "./data/store.js";
4
+ export { findAstroshotDirs, scanTree } from "./data/scan.js";
5
+ export { markSeen, addComment, readReviewDocument, snapshotFromEntry } from "./data/review-store.js";
6
+ export type { Shot, FrictionLog, FrictionRun, FrictionStep, ReviewSnapshot } from "./data/model.js";
7
+ export { probeTerminal, parseProbeResponse } from "./terminal/probe.js";
8
+ export type { TerminalCapabilities } from "./terminal/probe.js";
9
+ export { encodeTransmit, encodePlace, encodeDelete, parseGraphicsCommand } from "./terminal/kitty.js";
10
+ export { ImageLayer } from "./terminal/image-layer.js";
11
+ export { ImageService } from "./images/service.js";
12
+ export { detectFfmpeg, FramePlayer, probeVideo, splitPngStream } from "./video/ffmpeg.js";
@@ -0,0 +1,9 @@
1
+ export { main, parseArgs, reviewHelp } from "./cli.js";
2
+ export { ReviewStore } from "./data/store.js";
3
+ export { findAstroshotDirs, scanTree } from "./data/scan.js";
4
+ export { markSeen, addComment, readReviewDocument, snapshotFromEntry } from "./data/review-store.js";
5
+ export { probeTerminal, parseProbeResponse } from "./terminal/probe.js";
6
+ export { encodeTransmit, encodePlace, encodeDelete, parseGraphicsCommand } from "./terminal/kitty.js";
7
+ export { ImageLayer } from "./terminal/image-layer.js";
8
+ export { ImageService } from "./images/service.js";
9
+ export { detectFfmpeg, FramePlayer, probeVideo, splitPngStream } from "./video/ffmpeg.js";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The stdout Ink renders into. It forwards everything to the real stream and
3
+ * splices kitty placements into each frame so pictures and text arrive in
4
+ * the same synchronized update.
5
+ */
6
+ import type { ImageLayer } from "./image-layer.js";
7
+ export declare function hasPrintableText(chunk: string): boolean;
8
+ export declare function spliceGraphics(chunk: string, graphics: string): string;
9
+ export declare function createGraphicsStdout(real: NodeJS.WriteStream, layer: ImageLayer): NodeJS.WriteStream;
@@ -0,0 +1,36 @@
1
+ const ENTER_ALT_SCREEN = "\x1b[?1049h";
2
+ const END_SYNC = "\x1b[?2026l";
3
+ const ANSI_PATTERN = /\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b_[^\x1b]*\x1b\\|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[@-_]/g;
4
+ export function hasPrintableText(chunk) {
5
+ return chunk.replace(ANSI_PATTERN, "").trim().length > 0;
6
+ }
7
+ export function spliceGraphics(chunk, graphics) {
8
+ if (!graphics)
9
+ return chunk;
10
+ return chunk.endsWith(END_SYNC)
11
+ ? chunk.slice(0, -END_SYNC.length) + graphics + END_SYNC
12
+ : chunk + graphics;
13
+ }
14
+ export function createGraphicsStdout(real, layer) {
15
+ const write = (chunk, ...rest) => {
16
+ let text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
17
+ if (text.includes(ENTER_ALT_SCREEN)) {
18
+ // Ink enters the alternate screen without homing the cursor. Frames
19
+ // must start at the top-left so layout coordinates map to screen cells.
20
+ text = text.replace(ENTER_ALT_SCREEN, `${ENTER_ALT_SCREEN}\x1b[H\x1b[2J`);
21
+ layer.invalidate();
22
+ }
23
+ if (hasPrintableText(text)) {
24
+ text = spliceGraphics(text, layer.render());
25
+ }
26
+ return real.write(text, ...rest);
27
+ };
28
+ return new Proxy(real, {
29
+ get(target, property, receiver) {
30
+ if (property === "write")
31
+ return write;
32
+ const value = Reflect.get(target, property, receiver);
33
+ return typeof value === "function" ? value.bind(target) : value;
34
+ },
35
+ });
36
+ }
@@ -0,0 +1,61 @@
1
+ export interface HerdrAddress {
2
+ socket: string;
3
+ pane: string;
4
+ }
5
+ export declare function herdrAddress(env?: NodeJS.ProcessEnv): HerdrAddress | null;
6
+ /**
7
+ * Confirm herdr accepts a `pane.graphics.stream` layer. A missing ack is
8
+ * treated as success — the open reply may be silent — so only an explicit
9
+ * error reply rejects it. The probe stream is closed immediately, which makes
10
+ * herdr drop its layer.
11
+ */
12
+ export declare function probeHerdrSet(address: HerdrAddress): Promise<{
13
+ ok: boolean;
14
+ reason?: string;
15
+ }>;
16
+ export interface HerdrDiscovery {
17
+ ok: boolean;
18
+ cellWidth?: number;
19
+ cellHeight?: number;
20
+ maxLayers?: number;
21
+ /** Actionable reason when graphics can't be used yet. */
22
+ reason?: string;
23
+ }
24
+ /** Poll `pane.graphics.info` until herdr reports the cell size, or give up. */
25
+ export declare function discoverHerdr(address: HerdrAddress, options?: {
26
+ timeoutMs?: number;
27
+ retryMs?: number;
28
+ onWaiting?: () => void;
29
+ }): Promise<HerdrDiscovery>;
30
+ export interface HerdrPlacement {
31
+ col: number;
32
+ row: number;
33
+ cols: number;
34
+ rows: number;
35
+ z: number;
36
+ }
37
+ /**
38
+ * Places images on herdr via one `pane.graphics.stream` connection PER layer.
39
+ * herdr removes a stream's layer the moment its socket closes, so closing a
40
+ * layer's connection — or the whole process dying — cleans up automatically,
41
+ * with no persistent server-side state to leak (the trap of pane.graphics.set).
42
+ */
43
+ export declare class HerdrSink {
44
+ private readonly address;
45
+ private readonly onError;
46
+ private readonly layers;
47
+ /** Every socket opened and not yet closed, whether or not its layer is still mapped. */
48
+ private readonly connections;
49
+ private disposed;
50
+ private generationCount;
51
+ constructor(address: HerdrAddress, onError: (error: Error) => void);
52
+ /** Bumps when a layer stream drops unexpectedly, so callers re-send. */
53
+ get generation(): number;
54
+ set(layerId: string, png: Buffer, _imageWidth: number, _imageHeight: number, placement: HerdrPlacement): void;
55
+ clear(layerId: string): void;
56
+ private close;
57
+ clearAll(): void;
58
+ private open;
59
+ private flush;
60
+ dispose(): void;
61
+ }