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,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Single-line composer used for feedback. Owns the keyboard while active. */
3
+ import { Box, Text, useInput } from "ink";
4
+ import { useState } from "react";
5
+ import { theme } from "./theme.js";
6
+ export function TextInput({ placeholder, width, onSubmit, onCancel, submitLabel }) {
7
+ const [value, setValue] = useState("");
8
+ const [cursor, setCursor] = useState(0);
9
+ useInput((input, key) => {
10
+ if (key.escape) {
11
+ onCancel();
12
+ return;
13
+ }
14
+ // A paste (or a fast PTY) delivers several characters at once, possibly
15
+ // ending in a newline. Insert the printable part, then submit if asked.
16
+ const pasted = input.replace(/[\r\n]/g, "");
17
+ const wantsSubmit = key.return || /[\r\n]/.test(input);
18
+ if (pasted.length > 1 && !key.ctrl && !key.meta) {
19
+ const next = value.slice(0, cursor) + pasted + value.slice(cursor);
20
+ setValue(next);
21
+ setCursor(cursor + pasted.length);
22
+ if (wantsSubmit)
23
+ onSubmit(next);
24
+ return;
25
+ }
26
+ if (wantsSubmit) {
27
+ onSubmit(value);
28
+ return;
29
+ }
30
+ if (key.backspace || key.delete) {
31
+ if (cursor > 0) {
32
+ setValue(value.slice(0, cursor - 1) + value.slice(cursor));
33
+ setCursor(cursor - 1);
34
+ }
35
+ return;
36
+ }
37
+ if (key.leftArrow) {
38
+ setCursor(Math.max(0, cursor - 1));
39
+ return;
40
+ }
41
+ if (key.rightArrow) {
42
+ setCursor(Math.min(value.length, cursor + 1));
43
+ return;
44
+ }
45
+ if (key.ctrl && input === "a") {
46
+ setCursor(0);
47
+ return;
48
+ }
49
+ if (key.ctrl && input === "e") {
50
+ setCursor(value.length);
51
+ return;
52
+ }
53
+ if (key.ctrl && input === "u") {
54
+ setValue("");
55
+ setCursor(0);
56
+ return;
57
+ }
58
+ if (key.ctrl || key.meta || key.tab || key.upArrow || key.downArrow)
59
+ return;
60
+ if (!input)
61
+ return;
62
+ setValue(value.slice(0, cursor) + input + value.slice(cursor));
63
+ setCursor(cursor + input.length);
64
+ });
65
+ const innerWidth = Math.max(4, width - 2);
66
+ // Keep the caret visible by scrolling the text horizontally.
67
+ const start = Math.max(0, cursor - innerWidth + 1);
68
+ const visible = value.slice(start, start + innerWidth);
69
+ const caretIndex = cursor - start;
70
+ const before = visible.slice(0, caretIndex);
71
+ const at = visible.charAt(caretIndex) || " ";
72
+ const after = visible.slice(caretIndex + 1);
73
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Box, { borderStyle: "round", borderColor: theme.blue, paddingX: 1, width: width, children: value.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { color: theme.muted, children: placeholder.slice(0, innerWidth - 1) })] })) : (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: at }), after] })) }), _jsxs(Text, { color: theme.muted, children: [" ⏎ ", submitLabel ?? "send", " · esc cancel"] })] }));
74
+ }
@@ -0,0 +1,21 @@
1
+ /** Color tokens mirroring the macOS app's palette, expressed for chalk. */
2
+ export declare const theme: {
3
+ readonly brand: "#54e0a0";
4
+ readonly amber: "#f0b86e";
5
+ readonly blue: "#7aa2f7";
6
+ readonly green: "#54e0a0";
7
+ readonly purple: "#b9a8ff";
8
+ readonly red: "#f0727a";
9
+ readonly muted: "#8a8a9a";
10
+ readonly faint: "#5a5a6a";
11
+ readonly text: "#e8e8f2";
12
+ readonly surface: "#1c1b19";
13
+ readonly stage: "#2a2a29";
14
+ readonly selection: "#2e3140";
15
+ };
16
+ export declare function relativeTime(fromMs: number, nowMs?: number): string;
17
+ export declare function clockTime(ms: number): string;
18
+ export declare function isoDateTime(ms: number): string;
19
+ /** `Aug 11, 2026 at 2:54 PM` style, close to Foundation's abbreviated/short formats. */
20
+ export declare function abbreviatedDateTime(input: number | string): string;
21
+ export declare function truncate(text: string, width: number): string;
@@ -0,0 +1,63 @@
1
+ /** Color tokens mirroring the macOS app's palette, expressed for chalk. */
2
+ export const theme = {
3
+ brand: "#54e0a0",
4
+ amber: "#f0b86e",
5
+ blue: "#7aa2f7",
6
+ green: "#54e0a0",
7
+ purple: "#b9a8ff",
8
+ red: "#f0727a",
9
+ muted: "#8a8a9a",
10
+ faint: "#5a5a6a",
11
+ text: "#e8e8f2",
12
+ surface: "#1c1b19",
13
+ stage: "#2a2a29",
14
+ selection: "#2e3140",
15
+ };
16
+ export function relativeTime(fromMs, nowMs = Date.now()) {
17
+ const seconds = Math.max(0, Math.round((nowMs - fromMs) / 1000));
18
+ if (seconds < 45)
19
+ return "just now";
20
+ const minutes = Math.round(seconds / 60);
21
+ if (minutes < 60)
22
+ return `${minutes} min ago`;
23
+ const hours = Math.round(minutes / 60);
24
+ if (hours < 24)
25
+ return `${hours} hr ago`;
26
+ const days = Math.round(hours / 24);
27
+ if (days < 30)
28
+ return `${days} day${days === 1 ? "" : "s"} ago`;
29
+ const months = Math.round(days / 30);
30
+ if (months < 12)
31
+ return `${months} mo ago`;
32
+ return `${Math.round(months / 12)} yr ago`;
33
+ }
34
+ export function clockTime(ms) {
35
+ const date = new Date(ms);
36
+ return [date.getHours(), date.getMinutes(), date.getSeconds()]
37
+ .map((value) => String(value).padStart(2, "0"))
38
+ .join(":");
39
+ }
40
+ export function isoDateTime(ms) {
41
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
42
+ }
43
+ /** `Aug 11, 2026 at 2:54 PM` style, close to Foundation's abbreviated/short formats. */
44
+ export function abbreviatedDateTime(input) {
45
+ const date = typeof input === "number" ? new Date(input) : new Date(input);
46
+ if (Number.isNaN(date.getTime()))
47
+ return typeof input === "string" ? input : "";
48
+ return date.toLocaleString("en-US", {
49
+ month: "short",
50
+ day: "numeric",
51
+ year: "numeric",
52
+ hour: "numeric",
53
+ minute: "2-digit",
54
+ });
55
+ }
56
+ export function truncate(text, width) {
57
+ if (width <= 0)
58
+ return "";
59
+ const single = text.replace(/\s+/g, " ").trim();
60
+ if (single.length <= width)
61
+ return single;
62
+ return width <= 1 ? "…" : `${single.slice(0, width - 1)}…`;
63
+ }
@@ -0,0 +1,54 @@
1
+ import { type ImageSize } from "../images/png.js";
2
+ export interface FfmpegInfo {
3
+ ffmpeg: string | null;
4
+ ffprobe: string | null;
5
+ version: string | null;
6
+ }
7
+ export declare function detectFfmpeg(env?: NodeJS.ProcessEnv): FfmpegInfo;
8
+ export declare function resetFfmpegCache(): void;
9
+ export interface VideoInfo {
10
+ width: number;
11
+ height: number;
12
+ durationMs: number | null;
13
+ }
14
+ export declare function probeVideo(videoPath: string, info?: FfmpegInfo): Promise<VideoInfo | null>;
15
+ export interface VideoFrame {
16
+ png: Buffer;
17
+ width: number;
18
+ height: number;
19
+ /** Presentation time in milliseconds. */
20
+ tMs: number;
21
+ index: number;
22
+ }
23
+ export interface FramePlayerOptions {
24
+ videoPath: string;
25
+ /** Pixel box the frames must fit inside. */
26
+ bounds: ImageSize;
27
+ sourceSize: ImageSize;
28
+ fps?: number;
29
+ startMs?: number;
30
+ onFrame: (frame: VideoFrame) => void;
31
+ onEnd: () => void;
32
+ onError: (error: Error) => void;
33
+ ffmpegPath?: string;
34
+ }
35
+ /** Splits a concatenated PNG byte stream into whole files. */
36
+ export declare function splitPngStream(buffer: Buffer): {
37
+ frames: Buffer[];
38
+ rest: Buffer;
39
+ };
40
+ export declare class FramePlayer {
41
+ private readonly options;
42
+ private child;
43
+ private pending;
44
+ private index;
45
+ private readonly fps;
46
+ private readonly startMs;
47
+ private stopped;
48
+ readonly frameSize: ImageSize;
49
+ constructor(options: FramePlayerOptions);
50
+ start(): void;
51
+ /** Time of the last delivered frame, in milliseconds. */
52
+ get positionMs(): number;
53
+ stop(): void;
54
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Movie playback through ffmpeg: decode the file at a modest frame rate,
3
+ * scaled to the stage, as a stream of PNG frames the terminal can draw with
4
+ * the graphics protocol. Seeking restarts the decoder at the new offset.
5
+ */
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { fitInside, readPngSize } from "../images/png.js";
10
+ const EXTRA_BIN_DIRS = ["/opt/homebrew/bin", "/usr/local/bin"];
11
+ const PNG_END = Buffer.from([0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82]);
12
+ let cachedInfo = null;
13
+ function findBinary(name, env) {
14
+ const dirs = [...(env.PATH ?? "").split(path.delimiter), ...EXTRA_BIN_DIRS].filter(Boolean);
15
+ for (const dir of dirs) {
16
+ const candidate = path.join(dir, name);
17
+ try {
18
+ fs.accessSync(candidate, fs.constants.X_OK);
19
+ return candidate;
20
+ }
21
+ catch {
22
+ continue;
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+ export function detectFfmpeg(env = process.env) {
28
+ if (cachedInfo)
29
+ return cachedInfo;
30
+ const ffmpeg = env.ASTROSHOT_REVIEW_FFMPEG ?? findBinary("ffmpeg", env);
31
+ const ffprobe = findBinary("ffprobe", env);
32
+ let version = null;
33
+ if (ffmpeg) {
34
+ const result = spawnSync(ffmpeg, ["-version"], { encoding: "utf8" });
35
+ version = /ffmpeg version (\S+)/.exec(result.stdout ?? "")?.[1] ?? null;
36
+ }
37
+ cachedInfo = { ffmpeg, ffprobe, version };
38
+ return cachedInfo;
39
+ }
40
+ export function resetFfmpegCache() {
41
+ cachedInfo = null;
42
+ }
43
+ export async function probeVideo(videoPath, info = detectFfmpeg()) {
44
+ if (!info.ffprobe)
45
+ return null;
46
+ return new Promise((resolve) => {
47
+ const child = spawn(info.ffprobe, [
48
+ "-v",
49
+ "error",
50
+ "-select_streams",
51
+ "v:0",
52
+ "-show_entries",
53
+ "stream=width,height:format=duration",
54
+ "-of",
55
+ "json",
56
+ videoPath,
57
+ ]);
58
+ let output = "";
59
+ child.stdout.on("data", (chunk) => {
60
+ output += chunk.toString();
61
+ });
62
+ child.on("error", () => resolve(null));
63
+ child.on("close", () => {
64
+ try {
65
+ const parsed = JSON.parse(output);
66
+ const stream = parsed.streams?.[0];
67
+ if (!stream?.width || !stream.height)
68
+ return resolve(null);
69
+ const duration = Number(parsed.format?.duration);
70
+ resolve({
71
+ width: stream.width,
72
+ height: stream.height,
73
+ durationMs: Number.isFinite(duration) && duration > 0 ? Math.round(duration * 1000) : null,
74
+ });
75
+ }
76
+ catch {
77
+ resolve(null);
78
+ }
79
+ });
80
+ });
81
+ }
82
+ /** Splits a concatenated PNG byte stream into whole files. */
83
+ export function splitPngStream(buffer) {
84
+ const frames = [];
85
+ let cursor = 0;
86
+ while (cursor < buffer.length) {
87
+ const end = buffer.indexOf(PNG_END, cursor);
88
+ if (end === -1)
89
+ break;
90
+ frames.push(buffer.subarray(cursor, end + PNG_END.length));
91
+ cursor = end + PNG_END.length;
92
+ }
93
+ return { frames, rest: buffer.subarray(cursor) };
94
+ }
95
+ export class FramePlayer {
96
+ options;
97
+ child = null;
98
+ pending = Buffer.alloc(0);
99
+ index = 0;
100
+ fps;
101
+ startMs;
102
+ stopped = false;
103
+ frameSize;
104
+ constructor(options) {
105
+ this.options = options;
106
+ this.fps = options.fps ?? 12;
107
+ this.startMs = options.startMs ?? 0;
108
+ this.frameSize = fitInside(options.sourceSize, options.bounds);
109
+ // Even dimensions keep every encoder happy.
110
+ this.frameSize = {
111
+ width: Math.max(2, this.frameSize.width - (this.frameSize.width % 2)),
112
+ height: Math.max(2, this.frameSize.height - (this.frameSize.height % 2)),
113
+ };
114
+ }
115
+ start() {
116
+ const ffmpeg = this.options.ffmpegPath ?? detectFfmpeg().ffmpeg;
117
+ if (!ffmpeg) {
118
+ this.options.onError(new Error("ffmpeg is not installed"));
119
+ return;
120
+ }
121
+ const args = [
122
+ "-hide_banner",
123
+ "-loglevel",
124
+ "error",
125
+ "-nostdin",
126
+ "-re",
127
+ "-ss",
128
+ (this.startMs / 1000).toFixed(3),
129
+ "-i",
130
+ this.options.videoPath,
131
+ "-an",
132
+ "-vf",
133
+ `fps=${this.fps},scale=${this.frameSize.width}:${this.frameSize.height}:flags=fast_bilinear`,
134
+ "-f",
135
+ "image2pipe",
136
+ "-vcodec",
137
+ "png",
138
+ "-compression_level",
139
+ "3",
140
+ "-",
141
+ ];
142
+ const child = spawn(ffmpeg, args, { stdio: ["ignore", "pipe", "pipe"] });
143
+ this.child = child;
144
+ let stderr = "";
145
+ child.stderr?.on("data", (chunk) => {
146
+ stderr += chunk.toString();
147
+ });
148
+ child.stdout?.on("data", (chunk) => {
149
+ if (this.stopped)
150
+ return;
151
+ this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;
152
+ const { frames, rest } = splitPngStream(this.pending);
153
+ this.pending = rest;
154
+ for (const png of frames) {
155
+ const size = readPngSize(png);
156
+ if (!size)
157
+ continue;
158
+ const frame = {
159
+ png,
160
+ width: size.width,
161
+ height: size.height,
162
+ tMs: this.startMs + Math.round((this.index * 1000) / this.fps),
163
+ index: this.index,
164
+ };
165
+ this.index += 1;
166
+ this.options.onFrame(frame);
167
+ }
168
+ });
169
+ child.on("error", (error) => {
170
+ if (this.stopped)
171
+ return;
172
+ this.stopped = true;
173
+ this.options.onError(error);
174
+ });
175
+ child.on("close", (code) => {
176
+ if (this.stopped)
177
+ return;
178
+ this.stopped = true;
179
+ if (code && code !== 0) {
180
+ this.options.onError(new Error(`ffmpeg exited with ${code}: ${stderr.trim().slice(0, 300)}`));
181
+ }
182
+ else {
183
+ this.options.onEnd();
184
+ }
185
+ });
186
+ }
187
+ /** Time of the last delivered frame, in milliseconds. */
188
+ get positionMs() {
189
+ return this.startMs + Math.round((Math.max(0, this.index - 1) * 1000) / this.fps);
190
+ }
191
+ stop() {
192
+ if (this.stopped)
193
+ return;
194
+ this.stopped = true;
195
+ const child = this.child;
196
+ this.child = null;
197
+ if (child && child.exitCode === null) {
198
+ try {
199
+ child.kill("SIGKILL");
200
+ }
201
+ catch {
202
+ // Already gone.
203
+ }
204
+ }
205
+ }
206
+ }
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@archastro/astroshot-review",
3
+ "version": "0.2.2",
4
+ "description": "Terminal review tray for Astroshots: stream, review, and play .astroshot captures with Kitty graphics",
5
+ "keywords": [
6
+ "astroshots",
7
+ "ink",
8
+ "kitty",
9
+ "graphics",
10
+ "terminal",
11
+ "tui",
12
+ "screenshot",
13
+ "review"
14
+ ],
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/ArchAstro/astroshots.git",
19
+ "directory": "packages/astroshot-review"
20
+ },
21
+ "homepage": "https://github.com/ArchAstro/astroshots#readme",
22
+ "bugs": "https://github.com/ArchAstro/astroshots/issues",
23
+ "type": "module",
24
+ "main": "./dist/index.js",
25
+ "bin": {
26
+ "astroshot-review": "bin/astroshot-review.mjs"
27
+ },
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "bin",
37
+ "dist",
38
+ "README.md"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.build.json",
42
+ "clean": "node scripts/clean.mjs",
43
+ "test": "npm run test:unit && npm run test:e2e",
44
+ "test:unit": "vitest run",
45
+ "test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts",
46
+ "typecheck": "tsc --noEmit",
47
+ "prepack": "npm run clean && npm run build",
48
+ "pretest": "npm run build --workspace @archastro/tui-shot"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.14.0"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public",
55
+ "provenance": true,
56
+ "registry": "https://registry.npmjs.org/"
57
+ },
58
+ "dependencies": {
59
+ "ink": "^7.1.0",
60
+ "pngjs": "^7.0.0",
61
+ "react": "^19.0.0"
62
+ },
63
+ "devDependencies": {
64
+ "@archastro/tui-shot": "0.2.2",
65
+ "@types/node": "^22.0.0",
66
+ "@types/pngjs": "^6.0.5",
67
+ "@types/react": "^19.0.0",
68
+ "typescript": "^5.9.0",
69
+ "vitest": "^4.1.0"
70
+ }
71
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archastro/movie-harness",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Universal movie capture harness for browser, PTY, desktop, and raw frames into .astroshot/",
5
5
  "keywords": [
6
6
  "astroshots",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archastro/react-shot",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Capture deterministic PNG screenshots from React and TSX fixtures",
5
5
  "keywords": [
6
6
  "react",
@@ -52,9 +52,9 @@
52
52
  "scripts": {
53
53
  "build": "tsc -p tsconfig.build.json",
54
54
  "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
55
- "test": "npm run build && vitest run",
56
- "test:unit": "vitest run src/meta.test.ts",
57
- "test:e2e": "npm run build && vitest run src/cli.e2e.test.ts",
55
+ "test": "npm run test:unit && npm run test:e2e",
56
+ "test:unit": "vitest run",
57
+ "test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts",
58
58
  "typecheck": "tsc -p tsconfig.json --noEmit",
59
59
  "prepack": "npm run clean && npm run build"
60
60
  },
@@ -143,3 +143,12 @@ await closeSharedBrowser();
143
143
  ```
144
144
 
145
145
  Ink fixtures support Ink 7 and React 19 as peer dependencies.
146
+
147
+ ## Graphics-aware PTY captures
148
+
149
+ Add `graphics: kitty` to a PTY fixture to emulate a terminal that supports the
150
+ Kitty graphics protocol. The harness answers the capability query and cell-size
151
+ reports, records transmitted images and their placements at the cursor, strips
152
+ the escape sequences from the text, and paints the pictures into the PNG in
153
+ cell units. `KittyGraphicsTracker` is exported for harnesses that drive a PTY
154
+ themselves.
@@ -1,3 +1,5 @@
1
1
  export { closeSharedBrowser, takeTuiShot } from "./shot.js";
2
2
  export { takePtyShot } from "./pty-shot.js";
3
+ export { KittyGraphicsTracker, encodePng, overlaysToHtml } from "./kitty-graphics.js";
4
+ export type { GraphicsOverlay } from "./kitty-graphics.js";
3
5
  export type { BatchEntry, BatchManifest, PtyAction, PtyKey, PtyShotFixture, PtyShotRequest, TuiShotFixture, TuiShotRequest, } from "./types.js";
@@ -1,2 +1,3 @@
1
1
  export { closeSharedBrowser, takeTuiShot } from "./shot.js";
2
2
  export { takePtyShot } from "./pty-shot.js";
3
+ export { KittyGraphicsTracker, encodePng, overlaysToHtml } from "./kitty-graphics.js";
@@ -0,0 +1,45 @@
1
+ import type { HeadlessTerminal } from "./terminal-html.js";
2
+ export interface GraphicsOverlay {
3
+ col: number;
4
+ row: number;
5
+ cols: number;
6
+ rows: number;
7
+ z: number;
8
+ /** PNG bytes as a data URL. */
9
+ dataUrl: string;
10
+ }
11
+ export interface KittyTrackerOptions {
12
+ terminal: HeadlessTerminal;
13
+ cols: number;
14
+ rows: number;
15
+ cellWidth: number;
16
+ cellHeight: number;
17
+ /** Where terminal replies (query answers, device attributes) are sent. */
18
+ reply: (data: string) => void;
19
+ }
20
+ /** Encode raw RGB/RGBA pixels as a PNG (filter type 0 on every scanline). */
21
+ export declare function encodePng(pixels: Buffer, width: number, height: number, channels: 3 | 4): Buffer;
22
+ export declare class KittyGraphicsTracker {
23
+ private readonly options;
24
+ private readonly images;
25
+ private readonly placements;
26
+ private pending;
27
+ private carry;
28
+ private queryLog;
29
+ constructor(options: KittyTrackerOptions);
30
+ /** Queries the child sent, for assertions. */
31
+ get queries(): readonly string[];
32
+ get imageCount(): number;
33
+ /** Feed child output: text goes to the terminal, graphics are recorded. */
34
+ write(data: string): Promise<void>;
35
+ private writeText;
36
+ private handleCommand;
37
+ private storeImage;
38
+ private place;
39
+ private delete;
40
+ private pngFor;
41
+ /** Pictures currently visible, ordered for painting. */
42
+ overlays(): GraphicsOverlay[];
43
+ }
44
+ /** HTML for overlays, positioned in cell units so they track the font metrics. */
45
+ export declare function overlaysToHtml(overlays: GraphicsOverlay[], lineHeight: number): string;