@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.
- package/LICENSE +21 -0
- package/README.md +19 -0
- package/bin/astroshot-review.mjs +17 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +240 -0
- package/dist/data/friction.d.ts +22 -0
- package/dist/data/friction.js +278 -0
- package/dist/data/hash-cache.d.ts +17 -0
- package/dist/data/hash-cache.js +51 -0
- package/dist/data/index-cache.d.ts +22 -0
- package/dist/data/index-cache.js +64 -0
- package/dist/data/manifest.d.ts +41 -0
- package/dist/data/manifest.js +105 -0
- package/dist/data/model.d.ts +96 -0
- package/dist/data/model.js +1 -0
- package/dist/data/paths.d.ts +27 -0
- package/dist/data/paths.js +98 -0
- package/dist/data/review-store.d.ts +67 -0
- package/dist/data/review-store.js +237 -0
- package/dist/data/scan.d.ts +32 -0
- package/dist/data/scan.js +227 -0
- package/dist/data/store.d.ts +92 -0
- package/dist/data/store.js +408 -0
- package/dist/data/watcher.d.ts +37 -0
- package/dist/data/watcher.js +126 -0
- package/dist/images/halfblocks.d.ts +10 -0
- package/dist/images/halfblocks.js +21 -0
- package/dist/images/png.d.ts +14 -0
- package/dist/images/png.js +45 -0
- package/dist/images/scale.d.ts +31 -0
- package/dist/images/scale.js +102 -0
- package/dist/images/service.d.ts +54 -0
- package/dist/images/service.js +163 -0
- package/dist/images/worker.d.ts +21 -0
- package/dist/images/worker.js +30 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/terminal/graphics-stdout.d.ts +9 -0
- package/dist/terminal/graphics-stdout.js +36 -0
- package/dist/terminal/herdr.d.ts +61 -0
- package/dist/terminal/herdr.js +327 -0
- package/dist/terminal/image-layer.d.ts +122 -0
- package/dist/terminal/image-layer.js +471 -0
- package/dist/terminal/kitty.d.ts +74 -0
- package/dist/terminal/kitty.js +112 -0
- package/dist/terminal/probe.d.ts +49 -0
- package/dist/terminal/probe.js +206 -0
- package/dist/ui/app.d.ts +6 -0
- package/dist/ui/app.js +695 -0
- package/dist/ui/chrome.d.ts +53 -0
- package/dist/ui/chrome.js +69 -0
- package/dist/ui/context.d.ts +16 -0
- package/dist/ui/context.js +8 -0
- package/dist/ui/detail.d.ts +33 -0
- package/dist/ui/detail.js +39 -0
- package/dist/ui/friction.d.ts +42 -0
- package/dist/ui/friction.js +84 -0
- package/dist/ui/help.d.ts +4 -0
- package/dist/ui/help.js +61 -0
- package/dist/ui/hooks.d.ts +9 -0
- package/dist/ui/hooks.js +32 -0
- package/dist/ui/movie-player.d.ts +29 -0
- package/dist/ui/movie-player.js +119 -0
- package/dist/ui/picture.d.ts +19 -0
- package/dist/ui/picture.js +100 -0
- package/dist/ui/selectors.d.ts +26 -0
- package/dist/ui/selectors.js +69 -0
- package/dist/ui/settings.d.ts +5 -0
- package/dist/ui/settings.js +17 -0
- package/dist/ui/stream.d.ts +38 -0
- package/dist/ui/stream.js +118 -0
- package/dist/ui/system.d.ts +4 -0
- package/dist/ui/system.js +34 -0
- package/dist/ui/takeover.d.ts +26 -0
- package/dist/ui/takeover.js +29 -0
- package/dist/ui/text-input.d.ts +8 -0
- package/dist/ui/text-input.js +74 -0
- package/dist/ui/theme.d.ts +21 -0
- package/dist/ui/theme.js +63 -0
- package/dist/video/ffmpeg.d.ts +54 -0
- package/dist/video/ffmpeg.js +206 -0
- package/package.json +71 -0
|
@@ -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;
|
package/dist/ui/theme.js
ADDED
|
@@ -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
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@archastro/astroshot-review",
|
|
3
|
+
"version": "0.2.1",
|
|
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.1",
|
|
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
|
+
}
|