@archastro/astroshot-review 0.2.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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/bin/astroshot-review.mjs +17 -0
  4. package/dist/cli.d.ts +12 -0
  5. package/dist/cli.js +240 -0
  6. package/dist/data/friction.d.ts +22 -0
  7. package/dist/data/friction.js +278 -0
  8. package/dist/data/hash-cache.d.ts +17 -0
  9. package/dist/data/hash-cache.js +51 -0
  10. package/dist/data/index-cache.d.ts +22 -0
  11. package/dist/data/index-cache.js +64 -0
  12. package/dist/data/manifest.d.ts +41 -0
  13. package/dist/data/manifest.js +105 -0
  14. package/dist/data/model.d.ts +96 -0
  15. package/dist/data/model.js +1 -0
  16. package/dist/data/paths.d.ts +27 -0
  17. package/dist/data/paths.js +98 -0
  18. package/dist/data/review-store.d.ts +67 -0
  19. package/dist/data/review-store.js +237 -0
  20. package/dist/data/scan.d.ts +32 -0
  21. package/dist/data/scan.js +227 -0
  22. package/dist/data/store.d.ts +92 -0
  23. package/dist/data/store.js +408 -0
  24. package/dist/data/watcher.d.ts +37 -0
  25. package/dist/data/watcher.js +126 -0
  26. package/dist/images/halfblocks.d.ts +10 -0
  27. package/dist/images/halfblocks.js +21 -0
  28. package/dist/images/png.d.ts +14 -0
  29. package/dist/images/png.js +45 -0
  30. package/dist/images/scale.d.ts +31 -0
  31. package/dist/images/scale.js +102 -0
  32. package/dist/images/service.d.ts +54 -0
  33. package/dist/images/service.js +163 -0
  34. package/dist/images/worker.d.ts +21 -0
  35. package/dist/images/worker.js +30 -0
  36. package/dist/index.d.ts +12 -0
  37. package/dist/index.js +9 -0
  38. package/dist/terminal/graphics-stdout.d.ts +9 -0
  39. package/dist/terminal/graphics-stdout.js +36 -0
  40. package/dist/terminal/herdr.d.ts +61 -0
  41. package/dist/terminal/herdr.js +327 -0
  42. package/dist/terminal/image-layer.d.ts +122 -0
  43. package/dist/terminal/image-layer.js +471 -0
  44. package/dist/terminal/kitty.d.ts +74 -0
  45. package/dist/terminal/kitty.js +112 -0
  46. package/dist/terminal/probe.d.ts +49 -0
  47. package/dist/terminal/probe.js +206 -0
  48. package/dist/ui/app.d.ts +6 -0
  49. package/dist/ui/app.js +695 -0
  50. package/dist/ui/chrome.d.ts +53 -0
  51. package/dist/ui/chrome.js +69 -0
  52. package/dist/ui/context.d.ts +16 -0
  53. package/dist/ui/context.js +8 -0
  54. package/dist/ui/detail.d.ts +33 -0
  55. package/dist/ui/detail.js +39 -0
  56. package/dist/ui/friction.d.ts +42 -0
  57. package/dist/ui/friction.js +84 -0
  58. package/dist/ui/help.d.ts +4 -0
  59. package/dist/ui/help.js +61 -0
  60. package/dist/ui/hooks.d.ts +9 -0
  61. package/dist/ui/hooks.js +32 -0
  62. package/dist/ui/movie-player.d.ts +29 -0
  63. package/dist/ui/movie-player.js +119 -0
  64. package/dist/ui/picture.d.ts +19 -0
  65. package/dist/ui/picture.js +100 -0
  66. package/dist/ui/selectors.d.ts +26 -0
  67. package/dist/ui/selectors.js +69 -0
  68. package/dist/ui/settings.d.ts +5 -0
  69. package/dist/ui/settings.js +17 -0
  70. package/dist/ui/stream.d.ts +38 -0
  71. package/dist/ui/stream.js +118 -0
  72. package/dist/ui/system.d.ts +4 -0
  73. package/dist/ui/system.js +34 -0
  74. package/dist/ui/takeover.d.ts +26 -0
  75. package/dist/ui/takeover.js +29 -0
  76. package/dist/ui/text-input.d.ts +8 -0
  77. package/dist/ui/text-input.js +74 -0
  78. package/dist/ui/theme.d.ts +21 -0
  79. package/dist/ui/theme.js +63 -0
  80. package/dist/video/ffmpeg.d.ts +54 -0
  81. package/dist/video/ffmpeg.js +206 -0
  82. package/package.json +71 -0
@@ -0,0 +1,408 @@
1
+ /**
2
+ * The tray's model: every shot and friction log under the roots, newest
3
+ * arrival first, kept fresh by the filesystem watcher, with the review
4
+ * actions the UI exposes. Mutations run one at a time so a rescan and a
5
+ * live event can never interleave.
6
+ */
7
+ import path from "node:path";
8
+ import { LOG_FILE, loadFrictionLogs } from "./friction.js";
9
+ import { HashCache } from "./hash-cache.js";
10
+ import { loadIndex, reconcileArrivalOrder, saveIndex } from "./index-cache.js";
11
+ import { MAX_SCAN_DEPTH, frictionLogsDir } from "./paths.js";
12
+ import { addComment, markSeen } from "./review-store.js";
13
+ import { findAstroshotDirs, rebuildShot, scanFeatureDir, scanTree } from "./scan.js";
14
+ import { watchRoots } from "./watcher.js";
15
+ /** Depth of the quick pass that finds repo-level trees almost instantly. */
16
+ export const SHALLOW_DEPTH = 3;
17
+ /** A deep walk this recent is skipped at startup; `r` always forces one. */
18
+ export const FULL_SCAN_TTL_MS = 30 * 60 * 1000;
19
+ export class ReviewStore {
20
+ state;
21
+ listeners = new Set();
22
+ trees = new Map();
23
+ shotsByPath = new Map();
24
+ arrivalOrder = [];
25
+ hashes;
26
+ watcher = null;
27
+ queue = Promise.resolve();
28
+ options;
29
+ index = null;
30
+ disposed = false;
31
+ saveTimer = null;
32
+ fullScanAt = null;
33
+ /** Arrival order frozen at scan start so batches do not reorder the stream. */
34
+ scanBaseOrder = null;
35
+ constructor(options) {
36
+ this.options = options;
37
+ this.hashes = new HashCache();
38
+ this.state = {
39
+ roots: options.roots,
40
+ shots: [],
41
+ frictionLogs: [],
42
+ treeCount: 0,
43
+ scanning: false,
44
+ phase: "idle",
45
+ watching: false,
46
+ unreadCount: 0,
47
+ lastEvent: null,
48
+ error: null,
49
+ revision: 0,
50
+ };
51
+ }
52
+ getState() {
53
+ return this.state;
54
+ }
55
+ subscribe(listener) {
56
+ this.listeners.add(listener);
57
+ return () => this.listeners.delete(listener);
58
+ }
59
+ publish(patch = {}) {
60
+ this.state = { ...this.state, ...patch, revision: this.state.revision + 1 };
61
+ for (const listener of this.listeners)
62
+ listener();
63
+ }
64
+ log(message) {
65
+ this.options.onLog?.(message);
66
+ }
67
+ /** Serialize mutations. */
68
+ enqueue(task) {
69
+ const run = this.queue.then(task, task);
70
+ this.queue = run.then(() => undefined, () => undefined);
71
+ return run;
72
+ }
73
+ async start() {
74
+ if (this.options.useIndex !== false) {
75
+ this.index = await loadIndex(this.options.roots, this.options.indexPath);
76
+ if (this.index) {
77
+ this.arrivalOrder = this.index.arrivalOrder;
78
+ this.hashes.seed(this.index.hashes);
79
+ this.fullScanAt = this.index.fullScanAt ?? null;
80
+ }
81
+ }
82
+ if (this.options.watch !== false) {
83
+ this.watcher = watchRoots(this.options.roots, (event) => void this.handleEvent(event), {
84
+ settleMs: this.options.settleMs,
85
+ onError: (root, error) => {
86
+ this.log(`watch ${root}: ${error.message}`);
87
+ this.publish({ watching: this.watcher?.supported ?? false, error: `watch: ${error.message}` });
88
+ },
89
+ });
90
+ this.publish({ watching: this.watcher.supported });
91
+ }
92
+ await this.rescan();
93
+ }
94
+ /**
95
+ * Warm scan from the index, a shallow walk for repo-level trees, then a
96
+ * deep walk when the index is stale (or when forced by the user).
97
+ */
98
+ rescan(options = {}) {
99
+ return this.enqueue(async () => {
100
+ const cachedDirs = this.index?.astroshotDirs ?? [];
101
+ const known = new Set();
102
+ this.scanBaseOrder = [...this.arrivalOrder];
103
+ if (cachedDirs.length > 0) {
104
+ this.publish({ scanning: true, phase: "warm" });
105
+ await this.scanTrees(cachedDirs);
106
+ for (const dir of cachedDirs)
107
+ if (this.trees.has(dir))
108
+ known.add(dir);
109
+ this.recompute();
110
+ }
111
+ const discover = async (maxDepth, phase) => {
112
+ this.publish({ scanning: true, phase });
113
+ const discovered = new Set();
114
+ const pending = [];
115
+ let flushing = Promise.resolve();
116
+ await findAstroshotDirs(this.options.roots, {
117
+ maxDepth,
118
+ concurrency: phase === "full" ? 6 : 16,
119
+ onFound: (astroshotDir) => {
120
+ discovered.add(astroshotDir);
121
+ if (known.has(astroshotDir))
122
+ return;
123
+ known.add(astroshotDir);
124
+ pending.push(astroshotDir);
125
+ // Stream results into the tray while the walk continues.
126
+ flushing = flushing.then(async () => {
127
+ const batch = pending.splice(0, pending.length);
128
+ if (batch.length === 0)
129
+ return;
130
+ await this.scanTrees(batch);
131
+ this.recompute();
132
+ });
133
+ },
134
+ });
135
+ await flushing;
136
+ return discovered;
137
+ };
138
+ const shallow = await discover(SHALLOW_DEPTH, "shallow");
139
+ const stale = options.force ||
140
+ !this.fullScanAt ||
141
+ Date.now() - Date.parse(this.fullScanAt) > FULL_SCAN_TTL_MS ||
142
+ Number.isNaN(Date.parse(this.fullScanAt));
143
+ let complete = shallow;
144
+ if (stale) {
145
+ complete = await discover(MAX_SCAN_DEPTH, "full");
146
+ this.fullScanAt = new Date().toISOString();
147
+ }
148
+ // Trees that vanished from disk leave the stream; cached deep trees
149
+ // survive a shallow-only start because they were re-verified above.
150
+ for (const dir of [...this.trees.keys()]) {
151
+ if (!complete.has(dir) && (stale || !cachedDirs.includes(dir)))
152
+ this.trees.delete(dir);
153
+ }
154
+ // Cached trees may have changed while the tray was closed.
155
+ await this.scanTrees(cachedDirs.filter((dir) => this.trees.has(dir)));
156
+ this.recompute();
157
+ this.scanBaseOrder = null;
158
+ this.publish({ scanning: false, phase: "idle" });
159
+ this.scheduleSave();
160
+ });
161
+ }
162
+ async scanTrees(dirs) {
163
+ const concurrency = 6;
164
+ let cursor = 0;
165
+ const worker = async () => {
166
+ while (cursor < dirs.length) {
167
+ const dir = dirs[cursor++];
168
+ try {
169
+ const tree = await scanTree(dir, this.hashes);
170
+ this.trees.set(dir, tree);
171
+ this.watcher?.watchTree(dir);
172
+ }
173
+ catch (error) {
174
+ this.log(`scan ${dir}: ${error instanceof Error ? error.message : String(error)}`);
175
+ }
176
+ if (cursor % 8 === 0)
177
+ this.recompute();
178
+ }
179
+ };
180
+ await Promise.all(Array.from({ length: Math.min(concurrency, dirs.length) }, worker));
181
+ }
182
+ /** Rebuild the flat, ordered shot and friction lists from the trees. */
183
+ recompute() {
184
+ const all = [];
185
+ const frictionLogs = [];
186
+ for (const tree of this.trees.values()) {
187
+ all.push(...tree.shots);
188
+ frictionLogs.push(...tree.frictionLogs);
189
+ }
190
+ this.shotsByPath.clear();
191
+ for (const shot of all)
192
+ this.shotsByPath.set(shot.path, shot);
193
+ this.arrivalOrder = reconcileArrivalOrder(this.scanBaseOrder ?? this.arrivalOrder, all);
194
+ frictionLogs.sort((a, b) => b.updatedAt - a.updatedAt);
195
+ this.publish({
196
+ shots: this.orderedShots(),
197
+ frictionLogs,
198
+ treeCount: this.trees.size,
199
+ });
200
+ }
201
+ orderedShots() {
202
+ const shots = [];
203
+ for (const shotPath of this.arrivalOrder) {
204
+ const shot = this.shotsByPath.get(shotPath);
205
+ if (shot)
206
+ shots.push(shot);
207
+ }
208
+ return shots;
209
+ }
210
+ scheduleSave() {
211
+ if (this.options.useIndex === false)
212
+ return;
213
+ if (this.saveTimer)
214
+ clearTimeout(this.saveTimer);
215
+ this.saveTimer = setTimeout(() => {
216
+ this.saveTimer = null;
217
+ void this.saveIndexNow();
218
+ }, 500);
219
+ this.saveTimer.unref();
220
+ }
221
+ async saveIndexNow() {
222
+ this.hashes.retain(new Set(this.shotsByPath.keys()), (filePath) => filePath.endsWith("log.jsonl"));
223
+ const document = {
224
+ version: 1,
225
+ roots: this.options.roots,
226
+ astroshotDirs: [...this.trees.keys()].sort(),
227
+ arrivalOrder: this.arrivalOrder,
228
+ hashes: this.hashes.toJSON(),
229
+ updatedAt: new Date().toISOString(),
230
+ fullScanAt: this.fullScanAt ?? undefined,
231
+ };
232
+ try {
233
+ await saveIndex(document, this.options.indexPath);
234
+ this.index = document;
235
+ }
236
+ catch (error) {
237
+ this.log(`index save: ${error instanceof Error ? error.message : String(error)}`);
238
+ }
239
+ }
240
+ handleEvent(event) {
241
+ return this.enqueue(async () => {
242
+ try {
243
+ switch (event.kind) {
244
+ case "shot":
245
+ await this.ingestShot(event);
246
+ break;
247
+ case "feature":
248
+ await this.refreshFeature(event.featureDir, event.astroshotDir);
249
+ break;
250
+ case "friction":
251
+ await this.refreshFriction(event.astroshotDir);
252
+ break;
253
+ case "tree":
254
+ await this.refreshTree(event.astroshotDir);
255
+ break;
256
+ }
257
+ }
258
+ catch (error) {
259
+ this.log(`event ${event.kind}: ${error instanceof Error ? error.message : String(error)}`);
260
+ }
261
+ this.scheduleSave();
262
+ });
263
+ }
264
+ treeFor(astroshotDir) {
265
+ const existing = this.trees.get(astroshotDir);
266
+ if (existing)
267
+ return existing;
268
+ const worktreePath = path.dirname(astroshotDir);
269
+ const tree = {
270
+ astroshotDir,
271
+ worktreePath,
272
+ worktree: path.basename(worktreePath),
273
+ shots: [],
274
+ frictionLogs: [],
275
+ };
276
+ this.trees.set(astroshotDir, tree);
277
+ return tree;
278
+ }
279
+ async ingestShot(event) {
280
+ const tree = this.treeFor(event.astroshotDir);
281
+ const shot = await rebuildShot(event.path, {
282
+ worktreePath: tree.worktreePath,
283
+ worktree: tree.worktree,
284
+ feature: path.basename(event.featureDir),
285
+ featureDir: event.featureDir,
286
+ }, this.hashes);
287
+ const wasKnown = this.shotsByPath.has(event.path);
288
+ tree.shots = tree.shots.filter((candidate) => candidate.path !== event.path);
289
+ if (!shot) {
290
+ // Image vanished: drop it everywhere.
291
+ this.shotsByPath.delete(event.path);
292
+ this.arrivalOrder = this.arrivalOrder.filter((entry) => entry !== event.path);
293
+ this.publish({ shots: this.orderedShots() });
294
+ return;
295
+ }
296
+ tree.shots.push(shot);
297
+ this.shotsByPath.set(shot.path, shot);
298
+ this.arrivalOrder = [shot.path, ...this.arrivalOrder.filter((entry) => entry !== shot.path)];
299
+ this.publish({
300
+ shots: this.orderedShots(),
301
+ treeCount: this.trees.size,
302
+ unreadCount: wasKnown ? this.state.unreadCount : this.state.unreadCount + 1,
303
+ lastEvent: { kind: wasKnown ? "updated-shot" : "new-shot", shot, at: Date.now() },
304
+ });
305
+ }
306
+ async refreshFeature(featureDir, astroshotDir) {
307
+ const tree = this.treeFor(astroshotDir);
308
+ const shots = await scanFeatureDir(featureDir, { worktreePath: tree.worktreePath, worktree: tree.worktree }, this.hashes);
309
+ const previous = new Set(tree.shots.filter((shot) => shot.featureDir === featureDir).map((shot) => shot.path));
310
+ tree.shots = [...tree.shots.filter((shot) => shot.featureDir !== featureDir), ...shots];
311
+ const fresh = shots.filter((shot) => !previous.has(shot.path));
312
+ this.recompute();
313
+ if (fresh.length > 0) {
314
+ const newest = fresh.sort((a, b) => b.capturedAt - a.capturedAt)[0];
315
+ this.publish({
316
+ unreadCount: this.state.unreadCount + fresh.length,
317
+ lastEvent: { kind: "new-shot", shot: newest, at: Date.now() },
318
+ });
319
+ }
320
+ }
321
+ async refreshFriction(astroshotDir) {
322
+ const tree = this.treeFor(astroshotDir);
323
+ tree.frictionLogs = await loadFrictionLogs(frictionLogsDir(astroshotDir), { worktreePath: tree.worktreePath, worktree: tree.worktree }, this.hashes);
324
+ this.recompute();
325
+ }
326
+ async refreshTree(astroshotDir) {
327
+ try {
328
+ const tree = await scanTree(astroshotDir, this.hashes);
329
+ if (tree.shots.length === 0 && tree.frictionLogs.length === 0) {
330
+ this.trees.delete(astroshotDir);
331
+ }
332
+ else {
333
+ this.trees.set(astroshotDir, tree);
334
+ }
335
+ }
336
+ catch {
337
+ this.trees.delete(astroshotDir);
338
+ }
339
+ this.recompute();
340
+ }
341
+ /** The user opened the stream: clear the unread badge. */
342
+ markOpened() {
343
+ if (this.state.unreadCount !== 0)
344
+ this.publish({ unreadCount: 0 });
345
+ }
346
+ async markShotSeen(shot, comment) {
347
+ await markSeen({ directory: shot.featureDir, fileName: shot.fileName, runId: shot.runId, targetPath: shot.path }, { comment });
348
+ return this.reloadShot(shot);
349
+ }
350
+ async addShotComment(shot, body) {
351
+ await addComment({ directory: shot.featureDir, fileName: shot.fileName, runId: shot.runId, targetPath: shot.path }, body);
352
+ return this.reloadShot(shot);
353
+ }
354
+ reloadShot(shot) {
355
+ return this.enqueue(async () => {
356
+ const tree = this.treeFor(path.dirname(shot.featureDir));
357
+ const rebuilt = await rebuildShot(shot.path, { worktreePath: shot.worktreePath, worktree: shot.worktree, feature: shot.feature, featureDir: shot.featureDir }, this.hashes);
358
+ if (!rebuilt)
359
+ return shot;
360
+ tree.shots = tree.shots.map((candidate) => (candidate.path === shot.path ? rebuilt : candidate));
361
+ if (!tree.shots.includes(rebuilt))
362
+ tree.shots.push(rebuilt);
363
+ this.shotsByPath.set(rebuilt.path, rebuilt);
364
+ // Sidecar writes for one shot also re-scope siblings on a run change.
365
+ this.publish({ shots: this.orderedShots() });
366
+ return rebuilt;
367
+ });
368
+ }
369
+ /** Mark many shots seen; failures are counted, never fatal. */
370
+ async markManySeen(shots) {
371
+ let ok = 0;
372
+ let failed = 0;
373
+ for (const shot of shots) {
374
+ try {
375
+ await this.markShotSeen(shot);
376
+ ok += 1;
377
+ }
378
+ catch {
379
+ failed += 1;
380
+ }
381
+ }
382
+ // Reload every touched feature so run resets propagate.
383
+ const features = new Map();
384
+ for (const shot of shots)
385
+ features.set(shot.featureDir, shot);
386
+ for (const shot of features.values()) {
387
+ await this.handleEvent({ kind: "feature", featureDir: shot.featureDir, astroshotDir: path.dirname(shot.featureDir) });
388
+ }
389
+ return { ok, failed };
390
+ }
391
+ async markFrictionRunSeen(log, run) {
392
+ if (!run.logPath)
393
+ throw new Error("This run has no log.jsonl to acknowledge");
394
+ await markSeen({ directory: run.directory, fileName: LOG_FILE, runId: run.runId, targetPath: run.logPath });
395
+ await this.handleEvent({ kind: "friction", astroshotDir: path.join(log.worktreePath, ".astroshot") });
396
+ }
397
+ async dispose() {
398
+ if (this.disposed)
399
+ return;
400
+ this.disposed = true;
401
+ this.watcher?.close();
402
+ if (this.saveTimer) {
403
+ clearTimeout(this.saveTimer);
404
+ this.saveTimer = null;
405
+ await this.saveIndexNow();
406
+ }
407
+ }
408
+ }
@@ -0,0 +1,37 @@
1
+ export type WatchEvent = {
2
+ kind: "shot";
3
+ path: string;
4
+ featureDir: string;
5
+ astroshotDir: string;
6
+ } | {
7
+ kind: "feature";
8
+ featureDir: string;
9
+ astroshotDir: string;
10
+ } | {
11
+ kind: "friction";
12
+ astroshotDir: string;
13
+ } | {
14
+ kind: "tree";
15
+ astroshotDir: string;
16
+ };
17
+ export declare function classifyPath(fullPath: string): WatchEvent | null;
18
+ export declare function eventKey(event: WatchEvent): string;
19
+ export interface WatchOptions {
20
+ settleMs?: number;
21
+ onError?: (root: string, error: Error) => void;
22
+ /**
23
+ * Watch each root recursively. Cheap where the OS offers a single
24
+ * recursive stream (FSEvents on macOS, ReadDirectoryChangesW on Windows);
25
+ * elsewhere every directory costs an inotify watch, so only discovered
26
+ * `.astroshot` trees are watched and new trees need a rescan.
27
+ */
28
+ recursiveRoots?: boolean;
29
+ }
30
+ export interface RootWatcher {
31
+ close(): void;
32
+ /** Whether at least one watch is live. */
33
+ readonly supported: boolean;
34
+ /** Follow one `.astroshot` tree (no-op when roots are watched recursively). */
35
+ watchTree(astroshotDir: string): void;
36
+ }
37
+ export declare function watchRoots(roots: string[], onEvent: (event: WatchEvent) => void, options?: WatchOptions): RootWatcher;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Recursive filesystem watching over the roots, routed the same way the app
3
+ * routes FSEvents: image files ingest individually, sidecars refresh their
4
+ * feature, friction-log paths refresh the whole friction namespace, and new
5
+ * or vanished `.astroshot` directories rescan the tree.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { ASTROSHOT_DIR, FRICTION_DIR, VIDEO_EXTENSIONS, extensionOf, isImageFile } from "./paths.js";
10
+ export function classifyPath(fullPath) {
11
+ const parts = fullPath.split(path.sep);
12
+ const index = parts.indexOf(ASTROSHOT_DIR);
13
+ if (index === -1)
14
+ return null;
15
+ const astroshotDir = parts.slice(0, index + 1).join(path.sep);
16
+ const rest = parts.slice(index + 1);
17
+ if (rest.length === 0)
18
+ return { kind: "tree", astroshotDir };
19
+ if (rest[0] === FRICTION_DIR)
20
+ return { kind: "friction", astroshotDir };
21
+ if (rest[0].startsWith("."))
22
+ return null;
23
+ const featureDir = path.join(astroshotDir, rest[0]);
24
+ if (rest.length === 1)
25
+ return { kind: "feature", featureDir, astroshotDir };
26
+ if (rest.length === 2) {
27
+ const name = rest[1];
28
+ if (name === "manifest.json" || name === "review.json")
29
+ return { kind: "feature", featureDir, astroshotDir };
30
+ if (isImageFile(name))
31
+ return { kind: "shot", path: fullPath, featureDir, astroshotDir };
32
+ if (VIDEO_EXTENSIONS.includes(extensionOf(name)))
33
+ return { kind: "feature", featureDir, astroshotDir };
34
+ }
35
+ return null;
36
+ }
37
+ export function eventKey(event) {
38
+ switch (event.kind) {
39
+ case "shot":
40
+ return `shot:${event.path}`;
41
+ case "feature":
42
+ return `feature:${event.featureDir}`;
43
+ case "friction":
44
+ return `friction:${event.astroshotDir}`;
45
+ case "tree":
46
+ return `tree:${event.astroshotDir}`;
47
+ }
48
+ }
49
+ export function watchRoots(roots, onEvent, options = {}) {
50
+ const settleMs = options.settleMs ?? 250;
51
+ const recursiveRoots = options.recursiveRoots ?? (process.platform === "darwin" || process.platform === "win32");
52
+ const timers = new Map();
53
+ const pending = new Map();
54
+ const watchers = new Map();
55
+ let supported = true;
56
+ const attach = (target, recursive) => {
57
+ if (watchers.has(target))
58
+ return true;
59
+ try {
60
+ const watcher = fs.watch(target, { recursive, persistent: false }, (_type, filename) => {
61
+ if (!filename)
62
+ return;
63
+ const fullPath = path.join(target, filename.toString());
64
+ const event = classifyPath(fullPath);
65
+ if (event)
66
+ schedule(event);
67
+ });
68
+ watcher.on("error", (error) => {
69
+ watchers.delete(target);
70
+ watcher.close();
71
+ if (watchers.size === 0)
72
+ supported = false;
73
+ options.onError?.(target, error);
74
+ });
75
+ watchers.set(target, watcher);
76
+ return true;
77
+ }
78
+ catch (error) {
79
+ options.onError?.(target, error instanceof Error ? error : new Error(String(error)));
80
+ return false;
81
+ }
82
+ };
83
+ const schedule = (event) => {
84
+ const key = eventKey(event);
85
+ pending.set(key, event);
86
+ const existing = timers.get(key);
87
+ if (existing)
88
+ clearTimeout(existing);
89
+ const timer = setTimeout(() => {
90
+ timers.delete(key);
91
+ const queued = pending.get(key);
92
+ pending.delete(key);
93
+ if (queued)
94
+ onEvent(queued);
95
+ }, settleMs);
96
+ timer.unref();
97
+ timers.set(key, timer);
98
+ };
99
+ let attached = 0;
100
+ for (const root of roots) {
101
+ if (attach(root, recursiveRoots))
102
+ attached += 1;
103
+ }
104
+ supported = attached > 0;
105
+ return {
106
+ get supported() {
107
+ return supported;
108
+ },
109
+ watchTree(astroshotDir) {
110
+ if (recursiveRoots)
111
+ return;
112
+ if (attach(astroshotDir, true))
113
+ supported = true;
114
+ // A sibling `.astroshot` appearing next to a known tree shows up too.
115
+ attach(path.dirname(astroshotDir), false);
116
+ },
117
+ close() {
118
+ for (const timer of timers.values())
119
+ clearTimeout(timer);
120
+ timers.clear();
121
+ for (const watcher of watchers.values())
122
+ watcher.close();
123
+ watchers.clear();
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Fallback renderer for terminals without a graphics protocol: two pixels per
3
+ * cell using the upper-half block and truecolor foreground/background.
4
+ */
5
+ export interface CellArtOptions {
6
+ width: number;
7
+ height: number;
8
+ rgb: Buffer;
9
+ }
10
+ export declare function rgbToHalfBlockLines(options: CellArtOptions): string[];
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Fallback renderer for terminals without a graphics protocol: two pixels per
3
+ * cell using the upper-half block and truecolor foreground/background.
4
+ */
5
+ export function rgbToHalfBlockLines(options) {
6
+ const { width, height, rgb } = options;
7
+ const lines = [];
8
+ for (let y = 0; y < height; y += 2) {
9
+ let line = "";
10
+ for (let x = 0; x < width; x += 1) {
11
+ const top = (y * width + x) * 3;
12
+ const bottomRow = y + 1 < height ? y + 1 : y;
13
+ const bottom = (bottomRow * width + x) * 3;
14
+ line += `\x1b[38;2;${rgb[top]};${rgb[top + 1]};${rgb[top + 2]}m` +
15
+ `\x1b[48;2;${rgb[bottom]};${rgb[bottom + 1]};${rgb[bottom + 2]}m▀`;
16
+ }
17
+ line += "\x1b[0m";
18
+ lines.push(line);
19
+ }
20
+ return lines;
21
+ }
@@ -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
+ }