astroshot 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/README.md +87 -0
- package/bin/astroshot.mjs +71 -0
- package/node_modules/@archastro/astroshot/LICENSE +21 -0
- package/node_modules/@archastro/astroshot/README.md +96 -0
- package/node_modules/@archastro/astroshot/bin/astroshot.mjs +276 -0
- package/node_modules/@archastro/astroshot/bin/demo.mjs +275 -0
- package/node_modules/@archastro/astroshot/bin/doctor.mjs +492 -0
- package/node_modules/@archastro/astroshot/bin/mac-preferences.mjs +346 -0
- package/node_modules/@archastro/astroshot/bin/templates.mjs +186 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/fixtures.json +42 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/journey.png +0 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/journey.webm +0 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/next-steps.png +0 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/welcome.png +0 -0
- package/node_modules/@archastro/astroshot/ink.d.ts +5 -0
- package/node_modules/@archastro/astroshot/ink.js +1 -0
- package/node_modules/@archastro/astroshot/package.json +79 -0
- package/node_modules/@archastro/astroshot/pty.d.ts +8 -0
- package/node_modules/@archastro/astroshot/pty.js +1 -0
- package/node_modules/@archastro/astroshot/react.d.ts +1 -0
- package/node_modules/@archastro/astroshot/react.js +1 -0
- package/node_modules/@archastro/astroshot/tui.d.ts +1 -0
- package/node_modules/@archastro/astroshot/tui.js +1 -0
- package/node_modules/@archastro/movie-harness/README.md +138 -0
- package/node_modules/@archastro/movie-harness/bin/astroshot-movie.mjs +5 -0
- package/node_modules/@archastro/movie-harness/dist/cli.d.ts +1 -0
- package/node_modules/@archastro/movie-harness/dist/cli.js +389 -0
- package/node_modules/@archastro/movie-harness/dist/encode.d.ts +14 -0
- package/node_modules/@archastro/movie-harness/dist/encode.js +184 -0
- package/node_modules/@archastro/movie-harness/dist/index.d.ts +14 -0
- package/node_modules/@archastro/movie-harness/dist/index.js +11 -0
- package/node_modules/@archastro/movie-harness/dist/paths.d.ts +9 -0
- package/node_modules/@archastro/movie-harness/dist/paths.js +57 -0
- package/node_modules/@archastro/movie-harness/dist/png.d.ts +8 -0
- package/node_modules/@archastro/movie-harness/dist/png.js +79 -0
- package/node_modules/@archastro/movie-harness/dist/session.d.ts +45 -0
- package/node_modules/@archastro/movie-harness/dist/session.js +170 -0
- package/node_modules/@archastro/movie-harness/dist/sink.d.ts +7 -0
- package/node_modules/@archastro/movie-harness/dist/sink.js +113 -0
- package/node_modules/@archastro/movie-harness/dist/source-help.d.ts +29 -0
- package/node_modules/@archastro/movie-harness/dist/source-help.js +222 -0
- package/node_modules/@archastro/movie-harness/dist/sources/browser.d.ts +7 -0
- package/node_modules/@archastro/movie-harness/dist/sources/browser.js +87 -0
- package/node_modules/@archastro/movie-harness/dist/sources/desktop-macos.d.ts +110 -0
- package/node_modules/@archastro/movie-harness/dist/sources/desktop-macos.js +505 -0
- package/node_modules/@archastro/movie-harness/dist/sources/frames-store.d.ts +19 -0
- package/node_modules/@archastro/movie-harness/dist/sources/frames-store.js +165 -0
- package/node_modules/@archastro/movie-harness/dist/sources/pty.d.ts +15 -0
- package/node_modules/@archastro/movie-harness/dist/sources/pty.js +330 -0
- package/node_modules/@archastro/movie-harness/dist/terminal-paint.d.ts +27 -0
- package/node_modules/@archastro/movie-harness/dist/terminal-paint.js +190 -0
- package/node_modules/@archastro/movie-harness/dist/types.d.ts +140 -0
- package/node_modules/@archastro/movie-harness/dist/types.js +1 -0
- package/node_modules/@archastro/movie-harness/native/macos/WindowTools.swift +164 -0
- package/node_modules/@archastro/movie-harness/package.json +67 -0
- package/node_modules/@archastro/react-shot/LICENSE +21 -0
- package/node_modules/@archastro/react-shot/README.md +181 -0
- package/node_modules/@archastro/react-shot/bin/react-shot.mjs +2 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.d.ts +4 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.js +34 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/cli.d.ts +3 -0
- package/node_modules/@archastro/react-shot/dist/cli.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/cli.js +217 -0
- package/node_modules/@archastro/react-shot/dist/cli.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/config.d.ts +5 -0
- package/node_modules/@archastro/react-shot/dist/config.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/config.js +75 -0
- package/node_modules/@archastro/react-shot/dist/config.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/create-server.d.ts +13 -0
- package/node_modules/@archastro/react-shot/dist/create-server.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/create-server.js +254 -0
- package/node_modules/@archastro/react-shot/dist/create-server.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/index.d.ts +4 -0
- package/node_modules/@archastro/react-shot/dist/index.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/index.js +3 -0
- package/node_modules/@archastro/react-shot/dist/index.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/meta.d.ts +9 -0
- package/node_modules/@archastro/react-shot/dist/meta.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/meta.js +23 -0
- package/node_modules/@archastro/react-shot/dist/meta.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/shot.d.ts +4 -0
- package/node_modules/@archastro/react-shot/dist/shot.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/shot.js +259 -0
- package/node_modules/@archastro/react-shot/dist/shot.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/stubs.d.ts +6 -0
- package/node_modules/@archastro/react-shot/dist/stubs.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/stubs.js +49 -0
- package/node_modules/@archastro/react-shot/dist/stubs.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/types.d.ts +79 -0
- package/node_modules/@archastro/react-shot/dist/types.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/types.js +2 -0
- package/node_modules/@archastro/react-shot/dist/types.js.map +1 -0
- package/node_modules/@archastro/react-shot/host/index.html +22 -0
- package/node_modules/@archastro/react-shot/package.json +81 -0
- package/node_modules/@archastro/tui-shot/LICENSE +21 -0
- package/node_modules/@archastro/tui-shot/README.md +145 -0
- package/node_modules/@archastro/tui-shot/bin/tui-shot.mjs +23 -0
- package/node_modules/@archastro/tui-shot/dist/batch-paths.d.ts +3 -0
- package/node_modules/@archastro/tui-shot/dist/batch-paths.js +33 -0
- package/node_modules/@archastro/tui-shot/dist/cli.d.ts +2 -0
- package/node_modules/@archastro/tui-shot/dist/cli.js +224 -0
- package/node_modules/@archastro/tui-shot/dist/index.d.ts +3 -0
- package/node_modules/@archastro/tui-shot/dist/index.js +2 -0
- package/node_modules/@archastro/tui-shot/dist/pty-exit-wrapper.d.ts +1 -0
- package/node_modules/@archastro/tui-shot/dist/pty-exit-wrapper.js +50 -0
- package/node_modules/@archastro/tui-shot/dist/pty-shot.d.ts +2 -0
- package/node_modules/@archastro/tui-shot/dist/pty-shot.js +483 -0
- package/node_modules/@archastro/tui-shot/dist/render-ink.d.ts +10 -0
- package/node_modules/@archastro/tui-shot/dist/render-ink.js +87 -0
- package/node_modules/@archastro/tui-shot/dist/shot.d.ts +24 -0
- package/node_modules/@archastro/tui-shot/dist/shot.js +250 -0
- package/node_modules/@archastro/tui-shot/dist/terminal-html.d.ts +14 -0
- package/node_modules/@archastro/tui-shot/dist/terminal-html.js +144 -0
- package/node_modules/@archastro/tui-shot/dist/types.d.ts +81 -0
- package/node_modules/@archastro/tui-shot/dist/types.js +1 -0
- package/node_modules/@archastro/tui-shot/package.json +90 -0
- package/package.json +68 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { chromium } from "playwright";
|
|
6
|
+
import { ensureDir } from "./paths.js";
|
|
7
|
+
function hasFfmpeg() {
|
|
8
|
+
const result = spawnSync("ffmpeg", ["-version"], { encoding: "utf8" });
|
|
9
|
+
return result.status === 0;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Encode a PNG sequence to WebM/MP4.
|
|
13
|
+
*
|
|
14
|
+
* Prefer ffmpeg when present (fast, high quality). Otherwise replay frames in
|
|
15
|
+
* headless Chromium with Playwright recordVideo — no native deps beyond the
|
|
16
|
+
* Chromium we already ship for stills.
|
|
17
|
+
*/
|
|
18
|
+
export async function encodeFrames(request) {
|
|
19
|
+
if (request.framePaths.length === 0) {
|
|
20
|
+
throw new Error("encodeFrames requires at least one frame");
|
|
21
|
+
}
|
|
22
|
+
for (const frame of request.framePaths) {
|
|
23
|
+
if (!fs.existsSync(frame)) {
|
|
24
|
+
throw new Error(`frame not found: ${frame}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const outPath = path.resolve(request.outPath);
|
|
28
|
+
ensureDir(path.dirname(outPath));
|
|
29
|
+
const ext = path.extname(outPath).toLowerCase();
|
|
30
|
+
const durationMs = request.durationMs ??
|
|
31
|
+
Math.max(1, Math.round((request.framePaths.length / request.fps) * 1000));
|
|
32
|
+
if (hasFfmpeg()) {
|
|
33
|
+
await encodeWithFfmpeg(request, outPath);
|
|
34
|
+
return { videoPath: outPath, durationMs };
|
|
35
|
+
}
|
|
36
|
+
if (ext === ".mp4") {
|
|
37
|
+
// Playwright emits WebM; without ffmpeg we cannot remux to MP4.
|
|
38
|
+
const webmPath = outPath.replace(/\.mp4$/i, ".webm");
|
|
39
|
+
await encodeWithPlaywright(request, webmPath);
|
|
40
|
+
return { videoPath: webmPath, durationMs };
|
|
41
|
+
}
|
|
42
|
+
await encodeWithPlaywright(request, outPath);
|
|
43
|
+
return { videoPath: outPath, durationMs };
|
|
44
|
+
}
|
|
45
|
+
async function encodeWithFfmpeg(request, outPath) {
|
|
46
|
+
const listDir = fs.mkdtempSync(path.join(os.tmpdir(), "astroshot-movie-ff-"));
|
|
47
|
+
try {
|
|
48
|
+
const listPath = path.join(listDir, "frames.txt");
|
|
49
|
+
const frameDuration = 1 / request.fps;
|
|
50
|
+
const lines = [];
|
|
51
|
+
for (const frame of request.framePaths) {
|
|
52
|
+
lines.push(`file '${frame.replaceAll("'", "'\\''")}'`);
|
|
53
|
+
lines.push(`duration ${frameDuration.toFixed(6)}`);
|
|
54
|
+
}
|
|
55
|
+
// Last frame needs a trailing file entry for concat demuxer.
|
|
56
|
+
const last = request.framePaths.at(-1);
|
|
57
|
+
lines.push(`file '${last.replaceAll("'", "'\\''")}'`);
|
|
58
|
+
fs.writeFileSync(listPath, `${lines.join("\n")}\n`);
|
|
59
|
+
const ext = path.extname(outPath).toLowerCase();
|
|
60
|
+
const args = ext === ".mp4"
|
|
61
|
+
? [
|
|
62
|
+
"-y",
|
|
63
|
+
"-f",
|
|
64
|
+
"concat",
|
|
65
|
+
"-safe",
|
|
66
|
+
"0",
|
|
67
|
+
"-i",
|
|
68
|
+
listPath,
|
|
69
|
+
"-vf",
|
|
70
|
+
`scale=${request.size.width}:${request.size.height}:flags=neighbor`,
|
|
71
|
+
"-c:v",
|
|
72
|
+
"libx264",
|
|
73
|
+
"-pix_fmt",
|
|
74
|
+
"yuv420p",
|
|
75
|
+
"-movflags",
|
|
76
|
+
"+faststart",
|
|
77
|
+
outPath,
|
|
78
|
+
]
|
|
79
|
+
: [
|
|
80
|
+
"-y",
|
|
81
|
+
"-f",
|
|
82
|
+
"concat",
|
|
83
|
+
"-safe",
|
|
84
|
+
"0",
|
|
85
|
+
"-i",
|
|
86
|
+
listPath,
|
|
87
|
+
"-vf",
|
|
88
|
+
`scale=${request.size.width}:${request.size.height}:flags=neighbor`,
|
|
89
|
+
"-c:v",
|
|
90
|
+
"libvpx-vp9",
|
|
91
|
+
"-b:v",
|
|
92
|
+
"2M",
|
|
93
|
+
"-pix_fmt",
|
|
94
|
+
"yuv420p",
|
|
95
|
+
outPath,
|
|
96
|
+
];
|
|
97
|
+
const result = spawnSync("ffmpeg", args, { encoding: "utf8" });
|
|
98
|
+
if (result.status !== 0) {
|
|
99
|
+
throw new Error(`ffmpeg failed (${result.status}): ${result.stderr?.slice(0, 800) ?? ""}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
fs.rmSync(listDir, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function encodeWithPlaywright(request, outPath) {
|
|
107
|
+
const videoDir = fs.mkdtempSync(path.join(os.tmpdir(), "astroshot-movie-pw-"));
|
|
108
|
+
const size = request.size;
|
|
109
|
+
const frameMs = Math.max(1, Math.round(1000 / request.fps));
|
|
110
|
+
let browser;
|
|
111
|
+
try {
|
|
112
|
+
browser = await chromium.launch({ headless: true });
|
|
113
|
+
const context = await browser.newContext({
|
|
114
|
+
viewport: size,
|
|
115
|
+
deviceScaleFactor: 1,
|
|
116
|
+
recordVideo: {
|
|
117
|
+
dir: videoDir,
|
|
118
|
+
size,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const page = await context.newPage();
|
|
122
|
+
for (const framePath of request.framePaths) {
|
|
123
|
+
const bytes = fs.readFileSync(framePath);
|
|
124
|
+
const b64 = bytes.toString("base64");
|
|
125
|
+
const mime = path.extname(framePath).toLowerCase() === ".jpg" ||
|
|
126
|
+
path.extname(framePath).toLowerCase() === ".jpeg"
|
|
127
|
+
? "image/jpeg"
|
|
128
|
+
: "image/png";
|
|
129
|
+
await page.setContent(`<!doctype html>
|
|
130
|
+
<html>
|
|
131
|
+
<head>
|
|
132
|
+
<meta charset="utf-8" />
|
|
133
|
+
<style>
|
|
134
|
+
html, body {
|
|
135
|
+
margin: 0;
|
|
136
|
+
width: ${size.width}px;
|
|
137
|
+
height: ${size.height}px;
|
|
138
|
+
overflow: hidden;
|
|
139
|
+
background: #000;
|
|
140
|
+
}
|
|
141
|
+
img {
|
|
142
|
+
display: block;
|
|
143
|
+
width: ${size.width}px;
|
|
144
|
+
height: ${size.height}px;
|
|
145
|
+
object-fit: fill;
|
|
146
|
+
image-rendering: pixelated;
|
|
147
|
+
}
|
|
148
|
+
</style>
|
|
149
|
+
</head>
|
|
150
|
+
<body>
|
|
151
|
+
<img src="data:${mime};base64,${b64}" width="${size.width}" height="${size.height}" />
|
|
152
|
+
</body>
|
|
153
|
+
</html>`, { waitUntil: "load" });
|
|
154
|
+
await page.waitForTimeout(frameMs);
|
|
155
|
+
}
|
|
156
|
+
// Hold the last frame a beat so the encoder emits it.
|
|
157
|
+
await page.waitForTimeout(Math.max(frameMs, 100));
|
|
158
|
+
await context.close();
|
|
159
|
+
const videos = fs
|
|
160
|
+
.readdirSync(videoDir)
|
|
161
|
+
.filter((name) => name.endsWith(".webm"))
|
|
162
|
+
.map((name) => path.join(videoDir, name));
|
|
163
|
+
if (videos.length === 0) {
|
|
164
|
+
throw new Error("Playwright did not produce a WebM video");
|
|
165
|
+
}
|
|
166
|
+
// Prefer the newest file if multiple.
|
|
167
|
+
videos.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
168
|
+
fs.copyFileSync(videos[0], outPath);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
await browser?.close().catch(() => undefined);
|
|
172
|
+
fs.rmSync(videoDir, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Copy the last frame as the poster PNG. */
|
|
176
|
+
export function posterFromFrames(framePaths, outPath) {
|
|
177
|
+
if (framePaths.length === 0) {
|
|
178
|
+
throw new Error("posterFromFrames requires at least one frame");
|
|
179
|
+
}
|
|
180
|
+
const last = framePaths.at(-1);
|
|
181
|
+
ensureDir(path.dirname(outPath));
|
|
182
|
+
fs.copyFileSync(last, outPath);
|
|
183
|
+
return outPath;
|
|
184
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { encodeFrames, posterFromFrames } from "./encode.js";
|
|
2
|
+
export { encodeSolidPng, encodeRgbPng } from "./png.js";
|
|
3
|
+
export { assertKebabCase, assertSlug, defaultRunId, featureDir, humanize, resolveRoot, } from "./paths.js";
|
|
4
|
+
export { MovieSession } from "./session.js";
|
|
5
|
+
export { finalizeManifest, sinkMovie } from "./sink.js";
|
|
6
|
+
export { formatSourceCatalog, recommendSource, SOURCE_CATALOG, SOURCE_DECISION_TABLE, sourceHintForError, } from "./source-help.js";
|
|
7
|
+
export type { SourceAdvice, SourceKindHelp } from "./source-help.js";
|
|
8
|
+
export { recordBrowserMovie } from "./sources/browser.js";
|
|
9
|
+
export { assertDesktopToolchain, checkScreenRecordingAccess, describeDesktopWindow, desktopMatchFromFlags, ensureScreenRecordingAccess, formatScreenRecordingDeniedHelp, isNearlyBlankPng, listDesktopWindows, matchDesktopWindow, openScreenRecordingSettings, recordDesktopWindowMovie, } from "./sources/desktop-macos.js";
|
|
10
|
+
export type { DesktopWindowInfo, DesktopWindowMatch, DesktopWindowMovieOptions, ScreenAccessReport, } from "./sources/desktop-macos.js";
|
|
11
|
+
export { loadFrameSession, markFrameSession, pushFrameToSession, startFrameSession, stopFrameSession, } from "./sources/frames-store.js";
|
|
12
|
+
export { loadPtyMovieFixture, recordPtyMovie, recordTruecolorDemoMovie, } from "./sources/pty.js";
|
|
13
|
+
export { createHeadlessTerminal, terminalPlainText, terminalToHtml, writeTerminal, } from "./terminal-paint.js";
|
|
14
|
+
export type { BrowserMovieOptions, EncodeFramesRequest, ManifestStatus, MovieArtifact, MovieChapter, MovieFormat, MovieSessionOptions, MovieSourceKind, PersistedFrameSession, PtyAction, PtyKey, PtyMovieFixture, SinkMovieRequest, SinkMovieResult, Size, } from "./types.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { encodeFrames, posterFromFrames } from "./encode.js";
|
|
2
|
+
export { encodeSolidPng, encodeRgbPng } from "./png.js";
|
|
3
|
+
export { assertKebabCase, assertSlug, defaultRunId, featureDir, humanize, resolveRoot, } from "./paths.js";
|
|
4
|
+
export { MovieSession } from "./session.js";
|
|
5
|
+
export { finalizeManifest, sinkMovie } from "./sink.js";
|
|
6
|
+
export { formatSourceCatalog, recommendSource, SOURCE_CATALOG, SOURCE_DECISION_TABLE, sourceHintForError, } from "./source-help.js";
|
|
7
|
+
export { recordBrowserMovie } from "./sources/browser.js";
|
|
8
|
+
export { assertDesktopToolchain, checkScreenRecordingAccess, describeDesktopWindow, desktopMatchFromFlags, ensureScreenRecordingAccess, formatScreenRecordingDeniedHelp, isNearlyBlankPng, listDesktopWindows, matchDesktopWindow, openScreenRecordingSettings, recordDesktopWindowMovie, } from "./sources/desktop-macos.js";
|
|
9
|
+
export { loadFrameSession, markFrameSession, pushFrameToSession, startFrameSession, stopFrameSession, } from "./sources/frames-store.js";
|
|
10
|
+
export { loadPtyMovieFixture, recordPtyMovie, recordTruecolorDemoMovie, } from "./sources/pty.js";
|
|
11
|
+
export { createHeadlessTerminal, terminalPlainText, terminalToHtml, writeTerminal, } from "./terminal-paint.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function resolveRoot(root?: string): string;
|
|
2
|
+
export declare function assertKebabCase(name: string, label: string): void;
|
|
3
|
+
export declare function assertSlug(slug: string): void;
|
|
4
|
+
export declare function featureDir(root: string, feature: string): string;
|
|
5
|
+
export declare function movieStateDir(root: string, feature: string): string;
|
|
6
|
+
export declare function defaultRunId(feature: string): string;
|
|
7
|
+
export declare function humanize(slug: string): string;
|
|
8
|
+
export declare function ensureDir(dir: string): void;
|
|
9
|
+
export declare function nextSequence(featureDirectory: string): string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
export function resolveRoot(root) {
|
|
5
|
+
if (root)
|
|
6
|
+
return path.resolve(root);
|
|
7
|
+
try {
|
|
8
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
9
|
+
encoding: "utf8",
|
|
10
|
+
}).trim();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return process.cwd();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function assertKebabCase(name, label) {
|
|
17
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
18
|
+
throw new Error(`${label} must be kebab-case [a-z0-9-]+, got ${JSON.stringify(name)}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function assertSlug(slug) {
|
|
22
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
|
23
|
+
throw new Error(`slug must be kebab-case [a-z0-9-]+, got ${JSON.stringify(slug)}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function featureDir(root, feature) {
|
|
27
|
+
return path.join(root, ".astroshot", feature);
|
|
28
|
+
}
|
|
29
|
+
export function movieStateDir(root, feature) {
|
|
30
|
+
return path.join(featureDir(root, feature), ".movie");
|
|
31
|
+
}
|
|
32
|
+
export function defaultRunId(feature) {
|
|
33
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
34
|
+
return `${feature}-${stamp}-${process.pid}`;
|
|
35
|
+
}
|
|
36
|
+
export function humanize(slug) {
|
|
37
|
+
return slug
|
|
38
|
+
.split(/[-_]+/)
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
41
|
+
.join(" ");
|
|
42
|
+
}
|
|
43
|
+
export function ensureDir(dir) {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
export function nextSequence(featureDirectory) {
|
|
47
|
+
const entries = fs.existsSync(featureDirectory)
|
|
48
|
+
? fs.readdirSync(featureDirectory)
|
|
49
|
+
: [];
|
|
50
|
+
let max = 0;
|
|
51
|
+
for (const name of entries) {
|
|
52
|
+
const match = /^(\d{4})-/.exec(name);
|
|
53
|
+
if (match)
|
|
54
|
+
max = Math.max(max, Number(match[1]));
|
|
55
|
+
}
|
|
56
|
+
return String(max + 1).padStart(4, "0");
|
|
57
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal truecolor PNG encoder (no deps). Used for synthetic frames in tests
|
|
3
|
+
* and demos when a full browser paint is not needed.
|
|
4
|
+
*/
|
|
5
|
+
export declare function encodeSolidPng(width: number, height: number, rgb: [number, number, number]): Buffer;
|
|
6
|
+
export declare function encodeRgbPng(width: number, height: number,
|
|
7
|
+
/** Row-major RGB triples, length width*height*3 */
|
|
8
|
+
pixels: Buffer | Uint8Array): Buffer;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import zlib from "node:zlib";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal truecolor PNG encoder (no deps). Used for synthetic frames in tests
|
|
4
|
+
* and demos when a full browser paint is not needed.
|
|
5
|
+
*/
|
|
6
|
+
export function encodeSolidPng(width, height, rgb) {
|
|
7
|
+
const [r, g, b] = rgb;
|
|
8
|
+
const stride = 1 + width * 3;
|
|
9
|
+
const raw = Buffer.alloc(stride * height);
|
|
10
|
+
for (let y = 0; y < height; y++) {
|
|
11
|
+
const row = y * stride;
|
|
12
|
+
raw[row] = 0; // filter none
|
|
13
|
+
for (let x = 0; x < width; x++) {
|
|
14
|
+
const i = row + 1 + x * 3;
|
|
15
|
+
raw[i] = r;
|
|
16
|
+
raw[i + 1] = g;
|
|
17
|
+
raw[i + 2] = b;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return wrapPng(width, height, 2, raw); // 2 = truecolor
|
|
21
|
+
}
|
|
22
|
+
export function encodeRgbPng(width, height,
|
|
23
|
+
/** Row-major RGB triples, length width*height*3 */
|
|
24
|
+
pixels) {
|
|
25
|
+
if (pixels.length < width * height * 3) {
|
|
26
|
+
throw new Error("pixel buffer too small");
|
|
27
|
+
}
|
|
28
|
+
const stride = 1 + width * 3;
|
|
29
|
+
const raw = Buffer.alloc(stride * height);
|
|
30
|
+
for (let y = 0; y < height; y++) {
|
|
31
|
+
const row = y * stride;
|
|
32
|
+
raw[row] = 0;
|
|
33
|
+
const src = y * width * 3;
|
|
34
|
+
for (let x = 0; x < width; x++) {
|
|
35
|
+
const di = row + 1 + x * 3;
|
|
36
|
+
const si = src + x * 3;
|
|
37
|
+
raw[di] = pixels[si];
|
|
38
|
+
raw[di + 1] = pixels[si + 1];
|
|
39
|
+
raw[di + 2] = pixels[si + 2];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return wrapPng(width, height, 2, raw);
|
|
43
|
+
}
|
|
44
|
+
function wrapPng(width, height, colorType, raw) {
|
|
45
|
+
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
46
|
+
const ihdr = Buffer.alloc(13);
|
|
47
|
+
ihdr.writeUInt32BE(width, 0);
|
|
48
|
+
ihdr.writeUInt32BE(height, 4);
|
|
49
|
+
ihdr[8] = 8; // bit depth
|
|
50
|
+
ihdr[9] = colorType;
|
|
51
|
+
ihdr[10] = 0;
|
|
52
|
+
ihdr[11] = 0;
|
|
53
|
+
ihdr[12] = 0;
|
|
54
|
+
const compressed = zlib.deflateSync(raw);
|
|
55
|
+
return Buffer.concat([
|
|
56
|
+
signature,
|
|
57
|
+
chunk("IHDR", ihdr),
|
|
58
|
+
chunk("IDAT", compressed),
|
|
59
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
62
|
+
function chunk(type, data) {
|
|
63
|
+
const typeBuf = Buffer.from(type, "ascii");
|
|
64
|
+
const len = Buffer.alloc(4);
|
|
65
|
+
len.writeUInt32BE(data.length, 0);
|
|
66
|
+
const crc = Buffer.alloc(4);
|
|
67
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
|
68
|
+
return Buffer.concat([len, typeBuf, data, crc]);
|
|
69
|
+
}
|
|
70
|
+
function crc32(buf) {
|
|
71
|
+
let c = 0xffff_ffff;
|
|
72
|
+
for (let i = 0; i < buf.length; i++) {
|
|
73
|
+
c ^= buf[i];
|
|
74
|
+
for (let k = 0; k < 8; k++) {
|
|
75
|
+
c = c & 1 ? 0xedb8_8320 ^ (c >>> 1) : c >>> 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return (c ^ 0xffff_ffff) >>> 0;
|
|
79
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ManifestStatus, MovieArtifact, MovieSessionOptions, MovieSourceKind, Size } from "./types.js";
|
|
2
|
+
export declare class MovieSession {
|
|
3
|
+
readonly feature: string;
|
|
4
|
+
readonly slug: string;
|
|
5
|
+
readonly root: string;
|
|
6
|
+
readonly runId: string;
|
|
7
|
+
readonly title?: string;
|
|
8
|
+
readonly description?: string;
|
|
9
|
+
readonly size: Size;
|
|
10
|
+
readonly fps: number;
|
|
11
|
+
readonly format: "webm" | "mp4";
|
|
12
|
+
readonly source: MovieSourceKind;
|
|
13
|
+
private readonly workDir;
|
|
14
|
+
private readonly frameDir;
|
|
15
|
+
private readonly startedAtMs;
|
|
16
|
+
private frameCount;
|
|
17
|
+
private chapters;
|
|
18
|
+
private stopped;
|
|
19
|
+
private status;
|
|
20
|
+
private constructor();
|
|
21
|
+
static create(options: MovieSessionOptions): MovieSession;
|
|
22
|
+
/** Elapsed ms since session start (for chapter timestamps). */
|
|
23
|
+
elapsedMs(): number;
|
|
24
|
+
mark(slug: string, note?: string): void;
|
|
25
|
+
/**
|
|
26
|
+
* Push a PNG (or JPEG) frame into the session. Bytes are written to disk
|
|
27
|
+
* immediately so a crash still leaves a recoverable sequence.
|
|
28
|
+
*/
|
|
29
|
+
pushFrame(image: Buffer, extension?: "png" | "jpg" | "jpeg"): string;
|
|
30
|
+
pushFrameFile(imagePath: string): string;
|
|
31
|
+
listFrames(): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Encode collected frames, write poster + video into .astroshot/, update
|
|
34
|
+
* manifest. Cleans the temp work directory afterward.
|
|
35
|
+
*/
|
|
36
|
+
stop(options?: {
|
|
37
|
+
status?: ManifestStatus;
|
|
38
|
+
/** Skip encode and only publish when frames already form the movie via another path. */
|
|
39
|
+
videoPath?: string;
|
|
40
|
+
posterPath?: string;
|
|
41
|
+
durationMs?: number;
|
|
42
|
+
}): Promise<MovieArtifact>;
|
|
43
|
+
finalize(status: "pass" | "fail" | "idle"): Promise<void>;
|
|
44
|
+
private assertOpen;
|
|
45
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { encodeFrames, posterFromFrames } from "./encode.js";
|
|
5
|
+
import { assertKebabCase, assertSlug, defaultRunId, ensureDir, resolveRoot, } from "./paths.js";
|
|
6
|
+
import { finalizeManifest, sinkMovie } from "./sink.js";
|
|
7
|
+
export class MovieSession {
|
|
8
|
+
feature;
|
|
9
|
+
slug;
|
|
10
|
+
root;
|
|
11
|
+
runId;
|
|
12
|
+
title;
|
|
13
|
+
description;
|
|
14
|
+
size;
|
|
15
|
+
fps;
|
|
16
|
+
format;
|
|
17
|
+
source;
|
|
18
|
+
workDir;
|
|
19
|
+
frameDir;
|
|
20
|
+
startedAtMs;
|
|
21
|
+
frameCount = 0;
|
|
22
|
+
chapters = [];
|
|
23
|
+
stopped = false;
|
|
24
|
+
status;
|
|
25
|
+
constructor(options, workDir) {
|
|
26
|
+
this.feature = options.feature;
|
|
27
|
+
this.slug = options.slug;
|
|
28
|
+
this.root = resolveRoot(options.root);
|
|
29
|
+
this.runId = options.runId ?? defaultRunId(options.feature);
|
|
30
|
+
this.title = options.title;
|
|
31
|
+
this.description = options.description;
|
|
32
|
+
this.size = options.size ?? { width: 1280, height: 720 };
|
|
33
|
+
this.fps = options.fps ?? 15;
|
|
34
|
+
this.format = options.format ?? "webm";
|
|
35
|
+
this.source = options.source;
|
|
36
|
+
this.status = options.status ?? "running";
|
|
37
|
+
this.workDir = workDir;
|
|
38
|
+
this.frameDir = path.join(workDir, "frames");
|
|
39
|
+
this.startedAtMs = Date.now();
|
|
40
|
+
ensureDir(this.frameDir);
|
|
41
|
+
}
|
|
42
|
+
static create(options) {
|
|
43
|
+
assertKebabCase(options.feature, "feature");
|
|
44
|
+
assertSlug(options.slug);
|
|
45
|
+
if (options.fps !== undefined && !(options.fps > 0 && options.fps <= 60)) {
|
|
46
|
+
throw new Error("fps must be in (0, 60]");
|
|
47
|
+
}
|
|
48
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "astroshot-movie-"));
|
|
49
|
+
return new MovieSession(options, workDir);
|
|
50
|
+
}
|
|
51
|
+
/** Elapsed ms since session start (for chapter timestamps). */
|
|
52
|
+
elapsedMs() {
|
|
53
|
+
return Date.now() - this.startedAtMs;
|
|
54
|
+
}
|
|
55
|
+
mark(slug, note) {
|
|
56
|
+
this.assertOpen();
|
|
57
|
+
assertSlug(slug);
|
|
58
|
+
this.chapters.push({
|
|
59
|
+
slug,
|
|
60
|
+
tMs: this.elapsedMs(),
|
|
61
|
+
note,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Push a PNG (or JPEG) frame into the session. Bytes are written to disk
|
|
66
|
+
* immediately so a crash still leaves a recoverable sequence.
|
|
67
|
+
*/
|
|
68
|
+
pushFrame(image, extension = "png") {
|
|
69
|
+
this.assertOpen();
|
|
70
|
+
if (image.length < 8) {
|
|
71
|
+
throw new Error("pushFrame: empty image buffer");
|
|
72
|
+
}
|
|
73
|
+
const index = String(this.frameCount).padStart(6, "0");
|
|
74
|
+
const file = path.join(this.frameDir, `${index}.${extension}`);
|
|
75
|
+
fs.writeFileSync(file, image);
|
|
76
|
+
this.frameCount += 1;
|
|
77
|
+
return file;
|
|
78
|
+
}
|
|
79
|
+
pushFrameFile(imagePath) {
|
|
80
|
+
this.assertOpen();
|
|
81
|
+
const bytes = fs.readFileSync(imagePath);
|
|
82
|
+
const ext = path.extname(imagePath).toLowerCase().replace(".", "");
|
|
83
|
+
if (ext !== "png" && ext !== "jpg" && ext !== "jpeg") {
|
|
84
|
+
throw new Error(`pushFrameFile: unsupported extension .${ext}`);
|
|
85
|
+
}
|
|
86
|
+
return this.pushFrame(bytes, ext);
|
|
87
|
+
}
|
|
88
|
+
listFrames() {
|
|
89
|
+
if (!fs.existsSync(this.frameDir))
|
|
90
|
+
return [];
|
|
91
|
+
return fs
|
|
92
|
+
.readdirSync(this.frameDir)
|
|
93
|
+
.filter((name) => /\.(png|jpe?g)$/i.test(name))
|
|
94
|
+
.sort()
|
|
95
|
+
.map((name) => path.join(this.frameDir, name));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Encode collected frames, write poster + video into .astroshot/, update
|
|
99
|
+
* manifest. Cleans the temp work directory afterward.
|
|
100
|
+
*/
|
|
101
|
+
async stop(options) {
|
|
102
|
+
this.assertOpen();
|
|
103
|
+
this.stopped = true;
|
|
104
|
+
const status = options?.status ?? this.status;
|
|
105
|
+
try {
|
|
106
|
+
let videoPath = options?.videoPath;
|
|
107
|
+
let posterPath = options?.posterPath;
|
|
108
|
+
let durationMs = options?.durationMs;
|
|
109
|
+
const frames = this.listFrames();
|
|
110
|
+
if (!videoPath) {
|
|
111
|
+
if (frames.length === 0) {
|
|
112
|
+
throw new Error("MovieSession.stop: no frames and no videoPath");
|
|
113
|
+
}
|
|
114
|
+
const outExt = this.format === "mp4" ? ".mp4" : ".webm";
|
|
115
|
+
const encoded = await encodeFrames({
|
|
116
|
+
framePaths: frames,
|
|
117
|
+
outPath: path.join(this.workDir, `movie${outExt}`),
|
|
118
|
+
size: this.size,
|
|
119
|
+
fps: this.fps,
|
|
120
|
+
});
|
|
121
|
+
videoPath = encoded.videoPath;
|
|
122
|
+
durationMs = encoded.durationMs;
|
|
123
|
+
}
|
|
124
|
+
if (!posterPath) {
|
|
125
|
+
if (frames.length === 0) {
|
|
126
|
+
throw new Error("MovieSession.stop: no frames for poster");
|
|
127
|
+
}
|
|
128
|
+
posterPath = posterFromFrames(frames, path.join(this.workDir, "poster.png"));
|
|
129
|
+
}
|
|
130
|
+
durationMs ??= this.elapsedMs();
|
|
131
|
+
const published = sinkMovie({
|
|
132
|
+
root: this.root,
|
|
133
|
+
feature: this.feature,
|
|
134
|
+
slug: this.slug,
|
|
135
|
+
runId: this.runId,
|
|
136
|
+
title: this.title,
|
|
137
|
+
description: this.description,
|
|
138
|
+
status,
|
|
139
|
+
source: this.source,
|
|
140
|
+
posterPath,
|
|
141
|
+
videoPath,
|
|
142
|
+
durationMs,
|
|
143
|
+
chapters: this.chapters,
|
|
144
|
+
size: this.size,
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
videoPath: published.videoDest,
|
|
148
|
+
posterPath: published.posterDest,
|
|
149
|
+
durationMs,
|
|
150
|
+
chapters: this.chapters,
|
|
151
|
+
source: this.source,
|
|
152
|
+
feature: this.feature,
|
|
153
|
+
slug: this.slug,
|
|
154
|
+
sequence: published.sequence,
|
|
155
|
+
runId: this.runId,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
fs.rmSync(this.workDir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async finalize(status) {
|
|
163
|
+
finalizeManifest(this.root, this.feature, this.runId, status);
|
|
164
|
+
}
|
|
165
|
+
assertOpen() {
|
|
166
|
+
if (this.stopped) {
|
|
167
|
+
throw new Error("MovieSession is already stopped");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SinkMovieRequest, SinkMovieResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Publish poster + video into .astroshot/<feature>/ and append a movie shot
|
|
4
|
+
* to manifest.json. Poster basename is the review key (matches stills).
|
|
5
|
+
*/
|
|
6
|
+
export declare function sinkMovie(request: SinkMovieRequest): SinkMovieResult;
|
|
7
|
+
export declare function finalizeManifest(root: string, feature: string, runId: string, status: "pass" | "fail" | "idle" | "running"): void;
|