@hraness/slopcamera 3.6.0 → 3.8.0

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 (57) hide show
  1. package/README.md +49 -33
  2. package/apps/desktop/README.md +3 -10
  3. package/apps/desktop/application/html-overlay-renderer.ts +6 -0
  4. package/apps/desktop/application/operation.ts +1 -1
  5. package/apps/desktop/application/spatial-assets.ts +1 -1
  6. package/apps/desktop/cli/{menubar-status.ts → activity-status.ts} +24 -21
  7. package/apps/desktop/cli/args.ts +62 -10
  8. package/apps/desktop/cli/command-host-resources.ts +5 -1
  9. package/apps/desktop/cli/commands.ts +26 -9
  10. package/apps/desktop/cli/desktop-status.ts +271 -0
  11. package/apps/desktop/cli/help.ts +36 -28
  12. package/apps/desktop/cli/html-film-init.ts +158 -0
  13. package/apps/desktop/cli/html-film-names.ts +17 -0
  14. package/apps/desktop/cli/html-film.ts +471 -0
  15. package/apps/desktop/cli/html-overlay-renderer.ts +29 -2
  16. package/apps/desktop/cli/html-scene.ts +3 -3
  17. package/apps/desktop/cli/legacy-login.ts +184 -0
  18. package/apps/desktop/cli/main.ts +9 -0
  19. package/apps/desktop/cli/outputs.ts +19 -0
  20. package/apps/desktop/cli/portable-surface.ts +55 -1
  21. package/apps/desktop/cli/registry.ts +314 -0
  22. package/apps/desktop/cli/verbs.ts +193 -0
  23. package/apps/desktop/dist/cli/main.js +338 -316
  24. package/apps/desktop/html-overlay/film.ts +418 -0
  25. package/dist/cli.js +13 -2
  26. package/dist/generate.js +1 -1
  27. package/dist/{index-4z9y2wa2.js → index-842t02pp.js} +1 -1
  28. package/dist/{index-qxfexvj3.js → index-k9bjw80z.js} +20 -4
  29. package/dist/index-p74xy76d.js +3 -0
  30. package/dist/{index-fevf4ppk.js → index-y8spxs77.js} +10 -7
  31. package/dist/index.js +1 -1
  32. package/dist/operations.js +1 -1
  33. package/dist/workflow.js +1 -1
  34. package/docs/README.md +6 -6
  35. package/examples/html/launch-film/README.md +51 -0
  36. package/examples/html/launch-film/build.ts +158 -0
  37. package/examples/html/launch-film/film.css +593 -0
  38. package/examples/html/launch-film/film.html +85 -0
  39. package/examples/html/launch-film/film.js +410 -0
  40. package/examples/html/launch-film/film.json +63 -0
  41. package/examples/html/launch-film/mockups.tsx +74 -0
  42. package/examples/html/launch-film/timeline.ts +57 -0
  43. package/package.json +24 -11
  44. package/skills/slopcamera/SKILL.md +4 -3
  45. package/skills/slopcamera/references/install.md +3 -3
  46. package/skills/slopcamera/references/launch-films.md +158 -0
  47. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  48. package/skills/slopcamera/references/visual-communication.md +1 -1
  49. package/src/cli.ts +125 -9
  50. package/src/generation-pricing.ts +21 -1
  51. package/src/icon-set.ts +1086 -0
  52. package/src/icon.ts +396 -59
  53. package/src/index.ts +1 -0
  54. package/src/operations.ts +52 -0
  55. package/src/version.ts +1 -1
  56. package/apps/desktop/cli/menubar.ts +0 -272
  57. package/dist/index-nxtmy18t.js +0 -3
package/src/operations.ts CHANGED
@@ -16,8 +16,10 @@ import {
16
16
  } from "./generate.js"
17
17
  import {
18
18
  generateSlopcameraIcon,
19
+ slopcameraIconContexts,
19
20
  slopcameraIconMaximumRounds,
20
21
  slopcameraIconSubjectMaximumBytes,
22
+ type SlopcameraIconContext,
21
23
  type SlopcameraIconReceipt,
22
24
  } from "./icon.js"
23
25
  import {
@@ -255,6 +257,15 @@ export const slopcameraOperationRegistry: readonly SlopcameraOperationDescriptor
255
257
  pattern: "^#[a-fA-F0-9]{3}(?:[a-fA-F0-9]{3})?$",
256
258
  },
257
259
  purpose: { type: "string", enum: ["mark", "illustration"] },
260
+ context: {
261
+ type: "string",
262
+ enum: [...slopcameraIconContexts],
263
+ },
264
+ candidatePool: {
265
+ type: "integer",
266
+ minimum: 1,
267
+ maximum: slopcameraIconMaximumRounds,
268
+ },
258
269
  rounds: {
259
270
  type: "integer",
260
271
  minimum: 1,
@@ -411,6 +422,8 @@ export interface IconSlopcameraOperationInput {
411
422
  readonly critiqueModel?: string
412
423
  readonly ink?: string
413
424
  readonly purpose?: "illustration" | "mark"
425
+ readonly context?: SlopcameraIconContext
426
+ readonly candidatePool?: number
414
427
  readonly rounds?: number
415
428
  readonly keepRaster?: boolean
416
429
  }
@@ -606,6 +619,9 @@ function parseIcon(value: unknown): IconSlopcameraOperationInput {
606
619
  "model",
607
620
  "critiqueModel",
608
621
  "ink",
622
+ "purpose",
623
+ "context",
624
+ "candidatePool",
609
625
  "rounds",
610
626
  "keepRaster",
611
627
  ])
@@ -641,6 +657,33 @@ function parseIcon(value: unknown): IconSlopcameraOperationInput {
641
657
  ) {
642
658
  operationFailure("ink must be a #rgb or #rrggbb color.")
643
659
  }
660
+ if (
661
+ input.purpose !== undefined &&
662
+ input.purpose !== "illustration" &&
663
+ input.purpose !== "mark"
664
+ ) {
665
+ operationFailure("purpose must be illustration or mark.")
666
+ }
667
+ if (
668
+ input.context !== undefined &&
669
+ (typeof input.context !== "string" ||
670
+ !slopcameraIconContexts.includes(input.context as SlopcameraIconContext))
671
+ ) {
672
+ operationFailure(
673
+ `context must be one of: ${slopcameraIconContexts.join(", ")}.`,
674
+ )
675
+ }
676
+ const candidatePool = input.candidatePool
677
+ if (
678
+ candidatePool !== undefined &&
679
+ (!Number.isInteger(candidatePool) ||
680
+ (candidatePool as number) < 1 ||
681
+ (candidatePool as number) > slopcameraIconMaximumRounds)
682
+ ) {
683
+ operationFailure(
684
+ `candidatePool must be an integer from 1 through ${slopcameraIconMaximumRounds}.`,
685
+ )
686
+ }
644
687
  const rounds = input.rounds
645
688
  if (
646
689
  rounds !== undefined &&
@@ -668,6 +711,15 @@ function parseIcon(value: unknown): IconSlopcameraOperationInput {
668
711
  ? {}
669
712
  : { critiqueModel: input.critiqueModel as string }),
670
713
  ...(input.ink === undefined ? {} : { ink: input.ink as string }),
714
+ ...(input.purpose === undefined
715
+ ? {}
716
+ : { purpose: input.purpose as "illustration" | "mark" }),
717
+ ...(input.context === undefined
718
+ ? {}
719
+ : { context: input.context as SlopcameraIconContext }),
720
+ ...(candidatePool === undefined
721
+ ? {}
722
+ : { candidatePool: candidatePool as number }),
671
723
  ...(rounds === undefined ? {} : { rounds: rounds as number }),
672
724
  ...(input.keepRaster === undefined
673
725
  ? {}
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SLOPCAMERA_VERSION = "3.6.0" as const
1
+ export const SLOPCAMERA_VERSION = "3.8.0" as const
@@ -1,272 +0,0 @@
1
- import { closeSync, constants, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import type { Stats } from "node:fs";
3
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { CliError } from "./errors";
5
- import type { CliIo } from "./io";
6
- import { writeJson, writeLine } from "./io";
7
- import { ensurePrivateDirectory } from "./paths";
8
-
9
- const SETTLE_MS = 400;
10
- /** The label earlier releases used; the menu bar now manages its own login item. */
11
- const LABEL = "com.hraness.slopcamera.menubar";
12
-
13
- function xml(value: string): string { return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;"); }
14
- function exists(path: string): boolean { try { const info = lstatSync(path); return isAbsolute(path) && realpathSync(path) === resolve(path) && (info.uid === process.getuid?.() || info.uid === 0) && info.isFile() && (info.mode & 0o111) !== 0 && (info.mode & 0o022) === 0; } catch { return false; } }
15
-
16
- /** The directory agents write user-facing outputs into. */
17
- export function outputsRoot(stateRoot: string): string {
18
- const productRoot = stateRoot.endsWith(`${sep}cli`) ? dirname(stateRoot) : stateRoot;
19
- return join(productRoot, "outputs");
20
- }
21
- export function resolveMenubarBinary(repositoryRoot: string, environment: Readonly<Record<string, string | undefined>> = process.env): string | null {
22
- const explicit = environment.SLOPCAMERA_MENUBAR;
23
- if (explicit !== undefined) return explicit !== "" && exists(explicit) ? explicit : null;
24
- const candidates = [ resolve(dirname(process.execPath), "slopcamera-menubar"), ...(environment.HOME === undefined || environment.HOME === "" ? [] : [installedBinaryPath(environment)]), resolve(repositoryRoot, "desktop", "target", "release", "slopcamera-menubar")];
25
- for (const candidate of candidates) if (candidate !== undefined && candidate !== "" && exists(candidate)) return candidate;
26
- return null;
27
- }
28
- export function launchAgentPath(environment: Readonly<Record<string, string | undefined>> = process.env): string {
29
- const home = environment.HOME; if (home === undefined || home === "" || !isAbsolute(home)) throw new CliError("unavailable", "HOME is required for a per-user LaunchAgent.");
30
- return join(home, "Library", "LaunchAgents", `${LABEL}.plist`);
31
- }
32
- export function installedBinaryPath(environment: Readonly<Record<string, string | undefined>> = process.env): string {
33
- const home = environment.HOME; if (home === undefined || home === "" || !isAbsolute(home)) throw new CliError("unavailable", "HOME is required for a per-user menu-bar install.");
34
- return join(home, "Library", "Application Support", "Slopcamera", "menubar", "slopcamera-menubar");
35
- }
36
- export function launchAgentPlist(binary: string): string {
37
- return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${xml(LABEL)}</string><key>ProgramArguments</key><array><string>${xml(binary)}</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><false/><key>ProcessType</key><string>Interactive</string><key>LimitLoadToSessionType</key><string>Aqua</string></dict></plist>\n`;
38
- }
39
- type Environment = Readonly<Record<string, string | undefined>>;
40
- const MAX_BINARY_BYTES = 128 * 1024 * 1024;
41
-
42
- function infoAt(path: string): Stats | null {
43
- try { return lstatSync(path); } catch (error) {
44
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
45
- throw error;
46
- }
47
- }
48
-
49
- /** Existing shared directories retain their permissions; links are never followed. */
50
- function checkParents(path: string, environment: Environment, create = false): void {
51
- const home = environment.HOME;
52
- if (!home || !isAbsolute(home) || realpathSync(home) !== resolve(home)) {
53
- throw new CliError("unsafe-path", "HOME must be an absolute physical directory.");
54
- }
55
- const suffix = relative(home, dirname(path));
56
- if (suffix.startsWith(`..${sep}`) || suffix === ".." || isAbsolute(suffix)) {
57
- throw new CliError("unsafe-path", "The menu-bar install must remain under HOME.");
58
- }
59
- let cursor = home;
60
- for (const part of ["", ...suffix.split(sep)]) {
61
- if (part) cursor = join(cursor, part);
62
- let info = infoAt(cursor);
63
- if (info === null) {
64
- if (!create) return;
65
- mkdirSync(cursor, { mode: 0o700 });
66
- info = lstatSync(cursor);
67
- }
68
- if (!info.isDirectory() || info.uid !== process.getuid?.() || (info.mode & 0o022) !== 0) {
69
- throw new CliError("unsafe-path", "Menu-bar install directories must be owned physical directories without shared write access.");
70
- }
71
- }
72
- }
73
-
74
- function assertOwnedFile(path: string): void {
75
- const info = infoAt(path);
76
- if (info !== null && (!info.isFile() || info.nlink !== 1 || info.uid !== process.getuid?.() || (info.mode & 0o022) !== 0)) {
77
- throw new CliError("unsafe-path", "The menu-bar install contains an unsafe file.");
78
- }
79
- }
80
-
81
- function readRegular(path: string, maximum: number, executable = false): Buffer {
82
- if (!isAbsolute(path) || realpathSync(path) !== resolve(path) || Buffer.byteLength(path) > 4096 || /[\u0000-\u001f\u007f{}"\\]/u.test(path)) {
83
- throw new CliError("unsafe-path", "The menu-bar file must have a bounded physical absolute path.");
84
- }
85
- const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
86
- try {
87
- const info = fstatSync(fd);
88
- if (!info.isFile() || (info.uid !== process.getuid?.() && info.uid !== 0) || info.size > maximum || info.size === 0 || (info.mode & 0o022) !== 0 || (executable && (info.mode & 0o111) === 0)) {
89
- throw new CliError("unsafe-path", "The menu-bar file must be bounded, regular, and protected from shared writes.");
90
- }
91
- const bytes = Buffer.alloc(info.size + 1);
92
- let count = 0;
93
- while (count < bytes.length) {
94
- const read = readSync(fd, bytes, count, bytes.length - count, count);
95
- if (read === 0) break;
96
- count += read;
97
- }
98
- const after = fstatSync(fd);
99
- if (count !== info.size || after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs) {
100
- throw new CliError("conflict", "The menu-bar file changed while it was being read.");
101
- }
102
- return bytes.subarray(0, count);
103
- } finally { closeSync(fd); }
104
- }
105
-
106
- function readAgent(path: string, environment: Environment): string | null {
107
- checkParents(path, environment);
108
- if (infoAt(path) === null) return null;
109
- assertOwnedFile(path);
110
- return readRegular(path, 16 * 1024).toString("utf8");
111
- }
112
-
113
- /**
114
- * Copies the built menu bar into its installed location, replacing an
115
- * earlier copy. Existing shared directory permissions are preserved;
116
- * symbolic links and files other users can write are refused.
117
- */
118
- export function installBinary(binary: string, environment: Environment = process.env): string {
119
- if (!isAbsolute(binary)) throw new CliError("unsafe-path", "The menu-bar build must be an absolute path.");
120
- const stable = installedBinaryPath(environment);
121
- checkParents(stable, environment);
122
- assertOwnedFile(stable);
123
- const bytes = readRegular(binary, MAX_BINARY_BYTES, true);
124
- checkParents(stable, environment, true);
125
- const stage = mkdtempSync(join(dirname(stable), ".install-"));
126
- try {
127
- const staged = join(stage, "binary");
128
- writeFileSync(staged, bytes, { mode: 0o700, flag: "wx" });
129
- assertOwnedFile(stable);
130
- renameSync(staged, stable);
131
- return stable;
132
- } finally {
133
- rmSync(stage, { recursive: true });
134
- }
135
- }
136
-
137
- /**
138
- * Removes the login item earlier releases wrote under the old label, but
139
- * only when it is exactly the file they wrote. It never calls launchctl: a
140
- * copy that is already running keeps running until you quit it or log out.
141
- */
142
- export function removeLegacyAgent(environment: Environment = process.env): "absent" | "removed" | "foreign" {
143
- const path = launchAgentPath(environment);
144
- const current = readAgent(path, environment);
145
- if (current === null) return "absent";
146
- if (current !== launchAgentPlist(installedBinaryPath(environment))) return "foreign";
147
- rmSync(path);
148
- return "removed";
149
- }
150
-
151
- function assertMac(platform: NodeJS.Platform): void {
152
- if (platform !== "darwin") throw new CliError("unsupported-plan", "The Slopcamera menu bar runs only on macOS.");
153
- }
154
-
155
- export interface HelperResult { readonly code: number; readonly stdout: string; readonly stderr: string }
156
- /** Runs the installed menu bar's own install, uninstall or status command. */
157
- export type HelperRunner = (binary: string, args: readonly string[], env: Environment) => Promise<HelperResult>;
158
-
159
- async function runHelper(binary: string, args: readonly string[], env: Environment): Promise<HelperResult> {
160
- const child = Bun.spawn([binary, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", env: { ...env } as Record<string, string> });
161
- const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
162
- return { code, stdout: stdout.slice(0, 64 * 1024), stderr: stderr.slice(0, 64 * 1024) };
163
- }
164
-
165
- const AGENT_MARKERS = ["AI_AGENT", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CURSOR_AGENT", "GEMINI_CLI"];
166
-
167
- /**
168
- * The helper decides between human and agent output from its environment.
169
- * Its output is captured here, so a person's terminal is passed on as
170
- * `HRANESS_AUDIENCE=human`; agents and explicit settings pass through.
171
- */
172
- export function helperEnvironment(env: Environment): Environment {
173
- const agent = AGENT_MARKERS.some((name) => (env[name] ?? "") !== "");
174
- return env.HRANESS_AUDIENCE !== undefined || agent ? env : { ...env, HRANESS_AUDIENCE: "human" };
175
- }
176
-
177
- function helperFailure(result: HelperResult): CliError {
178
- // The helper prints the login-item notice on stderr before the error;
179
- // the failure is the last ✗ line plus the → hint that follows it.
180
- const lines = result.stderr.split("\n");
181
- let start = -1;
182
- for (const [index, line] of lines.entries()) {
183
- const trimmed = line.trimStart();
184
- if (trimmed.startsWith("✗") || trimmed.startsWith("FAIL")) start = index;
185
- }
186
- const text = (start === -1 ? result.stderr : lines.slice(start).join("\n")).trim().replace(/^(✗|FAIL)\s+/u, "");
187
- return new CliError(result.code === 2 ? "usage" : "unavailable", text === "" ? "The Slopcamera menu bar couldn't change its login item." : text);
188
- }
189
-
190
- /** `slopcamera-menubar` exits with this code when another copy already holds the menu bar. */
191
- export const ALREADY_RUNNING_EXIT = 3;
192
- const NOT_BUILT = "The Slopcamera menu bar isn't in this release yet. Build it with cargo build --release --manifest-path desktop/Cargo.toml in a Slopcamera checkout; slopcamera finds it there, or set SLOPCAMERA_MENUBAR to the built file.";
193
-
194
- export type Launched = "running" | "already-running" | "exited";
195
- export type BinaryRunner = (binary: string, foreground: boolean) => Promise<number | null>;
196
-
197
- /** Resolves the exit code, or null when a background copy is still running after startup. */
198
- async function spawnBinary(binary: string, foreground: boolean): Promise<number | null> {
199
- let child: Bun.Subprocess;
200
- try { child = Bun.spawn([binary], foreground ? { stdin: "inherit", stdout: "inherit", stderr: "inherit" } : { stdin: "ignore", stdout: "ignore", stderr: "ignore" }); } catch { throw new CliError("unavailable", "The Slopcamera menu bar couldn't start."); }
201
- if (!foreground) { child.unref(); return await Promise.race([child.exited.then((code) => code as number | null), Bun.sleep(SETTLE_MS).then(() => null)]); }
202
- return await child.exited;
203
- }
204
-
205
- /** Interprets the companion's exit: another running copy is success, not a failure. */
206
- export function launchOutcome(code: number | null, foreground: boolean): Launched {
207
- if (code === ALREADY_RUNNING_EXIT) return "already-running";
208
- if (code === null && !foreground) return "running";
209
- if (code === 0 && foreground) return "exited";
210
- throw new CliError("unavailable", "The Slopcamera menu bar stopped while starting. Run it in the foreground to see why: slopcamera menubar --foreground");
211
- }
212
-
213
- interface Marks { readonly ok: string; readonly warn: string; readonly note: string; readonly next: string }
214
- /** ✓ ⚠ 🔐 → with ASCII fallbacks for dumb terminals and non-UTF-8 locales. */
215
- export function marks(env: Readonly<Record<string, string | undefined>>): Marks {
216
- const utf8 = [env.LC_ALL, env.LC_CTYPE, env.LANG].some((value) => value !== undefined && /utf-?8/i.test(value));
217
- const ascii = env.TERM === "dumb" || !utf8 || env.HRANESS_ASCII === "1";
218
- return ascii ? { ok: "OK", warn: "WARN", note: "NOTE", next: "->" } : { ok: "✓", warn: "⚠", note: "🔐", next: "→" };
219
- }
220
-
221
- export function launchMessage(outcome: Launched, env: Readonly<Record<string, string | undefined>>): string {
222
- const mark = marks(env);
223
- if (outcome === "already-running") return `${mark.ok} Slopcamera is already in your menu bar. Look for 📷.`;
224
- if (outcome === "running") return `${mark.ok} Slopcamera is in your menu bar. Look for 📷.`;
225
- return "The Slopcamera menu bar closed.";
226
- }
227
-
228
- export async function launchMenubar(io: CliIo, repositoryRoot: string, asJson: boolean, mode: "foreground" | "background" = "foreground", runBinary: BinaryRunner = spawnBinary): Promise<void> {
229
- const binary = resolveMenubarBinary(repositoryRoot, io.env);
230
- if (binary === null) throw new CliError("unavailable", NOT_BUILT);
231
- const outcome = launchOutcome(await runBinary(binary, mode === "foreground"), mode === "foreground");
232
- if (asJson) writeJson(io, { running: outcome !== "exited", foreground: mode === "foreground", alreadyRunning: outcome === "already-running" });
233
- else writeLine(io, launchMessage(outcome, io.env));
234
- }
235
- export async function manageMenubar(io: CliIo, repositoryRoot: string, action: "install" | "uninstall" | "status", asJson: boolean, run: HelperRunner = runHelper): Promise<void> {
236
- assertMac(io.platform);
237
- const stable = installedBinaryPath(io.env);
238
- const args = asJson ? [action, "--json"] : [action];
239
- const env = helperEnvironment(io.env);
240
- const forward = (result: HelperResult): void => {
241
- if (result.stdout !== "") io.stdout(result.stdout);
242
- if (result.code !== 0) throw helperFailure(result);
243
- if (result.stderr !== "") io.stderr(result.stderr);
244
- };
245
- if (action === "install") {
246
- const binary = resolveMenubarBinary(repositoryRoot, io.env);
247
- if (binary === null) throw new CliError("unavailable", NOT_BUILT);
248
- const installed = binary === stable ? stable : installBinary(binary, io.env);
249
- forward(await run(installed, args, env));
250
- // The old login item goes only after the new one is in place, so a
251
- // failed install never leaves nothing to start at login.
252
- removeLegacyAgent(io.env);
253
- return;
254
- }
255
- if (action === "uninstall") removeLegacyAgent(io.env);
256
- if (!exists(stable)) {
257
- if (asJson) writeJson(io, { login: "off", running: false, installed: false });
258
- else if (action === "uninstall") writeLine(io, `${marks(io.env).ok} Slopcamera won't open at login.`);
259
- else writeLine(io, `Slopcamera's menu bar isn't installed.\n${marks(io.env).next} slopcamera menubar install`);
260
- return;
261
- }
262
- const result = await run(stable, args, env);
263
- forward(result);
264
- if (action === "uninstall") rmSync(stable);
265
- }
266
-
267
- export async function reportOutputsRoot(io: CliIo, stateRoot: string, asJson: boolean): Promise<void> {
268
- const directory = outputsRoot(stateRoot);
269
- await ensurePrivateDirectory(directory);
270
- if (asJson) writeJson(io, { outputs: directory });
271
- else writeLine(io, directory);
272
- }
@@ -1,3 +0,0 @@
1
- // @bun
2
- import{__require}from"./index-80a36bc0.js";import{createHash,randomUUID}from"crypto";import{link,rm,writeFile}from"fs/promises";import{dirname,extname,resolve}from"path";class SlopcameraCloudError extends Error{code;constructor(code,message,options){super(`[${code}] ${message}`,options);this.name="SlopcameraCloudError";this.code=code}}var slopcameraImageModelPrices=Object.freeze({"openai/gpt-image-1":Object.freeze({kind:"tokens",textInputPerMillion:5000000,outputPerMillion:40000000,worstImage:250000,source:"https://developers.openai.com/api/docs/models/gpt-image-1"}),"openai/gpt-image-1-mini":Object.freeze({kind:"tokens",textInputPerMillion:2000000,outputPerMillion:8000000,worstImage:52000,source:"https://developers.openai.com/api/docs/models/gpt-image-1-mini"}),"openai/gpt-image-1.5":Object.freeze({kind:"tokens",textInputPerMillion:5000000,outputPerMillion:32000000,worstImage:200000,source:"https://developers.openai.com/api/docs/models/gpt-image-1.5"}),"recraft/recraft-v4.1-utility":Object.freeze({kind:"image",perImage:35000,source:"https://vercel.com/ai-gateway/models/recraft-v4.1-utility"})});var slopcameraPromptTokenAllowance=64;var slopcameraCostProvider="vercel-ai-gateway";function slopcameraImageModelPrice(model){return Object.hasOwn(slopcameraImageModelPrices,model)?slopcameraImageModelPrices[model]:undefined}function tokenCost(tokens,perMillion){return Math.ceil(tokens*perMillion/1e6)}function promptBytes(prompt){return Buffer.byteLength(prompt,"utf8")}function validTokenCount(value){return typeof value==="number"&&Number.isSafeInteger(value)&&value>=0}function estimatedLines(model,prompt,price){if(price.kind==="image"){return[{provider:slopcameraCostProvider,operation:`${model} image`,microUsd:price.perImage,basis:"estimated"}]}return[{provider:slopcameraCostProvider,operation:`${model} image output`,microUsd:price.worstImage,basis:"estimated"},{provider:slopcameraCostProvider,operation:`${model} text input`,microUsd:tokenCost(promptBytes(prompt)+slopcameraPromptTokenAllowance,price.textInputPerMillion),basis:"estimated"}]}function imageCallCosts(model,prompt,usage){const price=slopcameraImageModelPrice(model);if(price===undefined){return[{provider:slopcameraCostProvider,operation:`${model} image`,microUsd:0,basis:"unknown"}]}if(price.kind==="tokens"&&usage!==undefined&&validTokenCount(usage.inputTokens)&&validTokenCount(usage.outputTokens)&&usage.outputTokens>0){return[{provider:slopcameraCostProvider,operation:`${model} image output`,microUsd:tokenCost(usage.outputTokens,price.outputPerMillion),basis:"reported"},{provider:slopcameraCostProvider,operation:`${model} text input`,microUsd:tokenCost(usage.inputTokens,price.textInputPerMillion),basis:"reported"}]}return estimatedLines(model,prompt,price)}var slopcameraGatewayApiBaseUrl="https://ai-gateway.vercel.sh/v4/ai";var slopcameraImageModels=Object.freeze(["openai/gpt-image-1.5","recraft/recraft-v4.1-utility"]);var slopcameraResponseMediaTypes=Object.freeze(["image/png","image/jpeg","image/webp"]);var slopcameraMaximumPromptBytes=32*1024;var slopcameraMaximumRawImageBytes=64*1024*1024;var defaultGenerationTimeoutMs=5*60000;var maximumGenerationTimeoutMs=30*60000;var defaultMaximumGatewayResponseBytes=96*1024*1024;function invalidArgument(message){throw new SlopcameraCloudError("INVALID_ARGUMENT",message)}function credentialValue(value){if(value.length<16||value.length>16*1024||value.trim()!==value||/[^\x21-\x7e]/u.test(value)){throw new SlopcameraCloudError("AUTHENTICATION_REQUIRED","The selected Vercel AI Gateway credential is invalid.")}return value}function environment(injected){return injected??process.env}function resolveSlopcameraGatewayCredential(injected){const values=environment(injected);if(values.AI_GATEWAY_API_KEY!==undefined){return{source:"AI_GATEWAY_API_KEY",token:credentialValue(values.AI_GATEWAY_API_KEY)}}if(values.VERCEL_OIDC_TOKEN!==undefined){return{source:"VERCEL_OIDC_TOKEN",token:credentialValue(values.VERCEL_OIDC_TOKEN)}}throw new SlopcameraCloudError("AUTHENTICATION_REQUIRED","Set AI_GATEWAY_API_KEY or run Slopcamera through `vercel env run -- \u2026` with VERCEL_OIDC_TOKEN available.")}function slopcameraGatewayCredentialStatus(injected){const values=environment(injected);if(values.AI_GATEWAY_API_KEY!==undefined){credentialValue(values.AI_GATEWAY_API_KEY);return{available:true,source:"AI_GATEWAY_API_KEY"}}if(values.VERCEL_OIDC_TOKEN!==undefined){credentialValue(values.VERCEL_OIDC_TOKEN);return{available:true,source:"VERCEL_OIDC_TOKEN"}}return{available:false,source:null}}function validateModel(value){if(typeof value!=="string"||value.length<3||value.length>256||!/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._:-]*$/iu.test(value)){invalidArgument("Model must be a bounded Vercel AI Gateway provider/model id.")}return value}function validatePrompt(value){if(typeof value!=="string"||value.trim().length===0||/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)||Buffer.byteLength(value,"utf8")>slopcameraMaximumPromptBytes){invalidArgument(`Prompt must be non-empty and no more than ${slopcameraMaximumPromptBytes} UTF-8 bytes.`)}return value}function validateTimeout(value){const timeout=value??defaultGenerationTimeoutMs;if(!Number.isSafeInteger(timeout)||timeout<1000||timeout>maximumGenerationTimeoutMs){invalidArgument(`timeoutMs must be an integer from 1000 through ${maximumGenerationTimeoutMs}.`)}return timeout}function isObject(value){return typeof value==="object"&&value!==null||typeof value==="function"}function parseFunction(value){if(typeof value!=="function"){throw new SlopcameraCloudError("GENERATION_FAILED","The Vercel AI Gateway runtime is unavailable.")}return value}function disableAiSdkWarningLogging(){globalThis.AI_SDK_LOG_WARNINGS=false}function assertGenerationActive(signal){if(signal.aborted){throw new SlopcameraCloudError("GENERATION_FAILED","Vercel AI Gateway image generation was cancelled or exceeded its deadline.")}}async function loadDefaultGatewayRuntime(){let aiModule;let gatewayModule;try{[aiModule,gatewayModule]=await Promise.all([import("ai-v7"),import("@ai-sdk/gateway-v4")])}catch{throw new SlopcameraCloudError("GENERATION_FAILED","The Vercel AI Gateway runtime is unavailable.")}if(!isObject(aiModule)||!isObject(gatewayModule)){throw new SlopcameraCloudError("GENERATION_FAILED","The Vercel AI Gateway runtime is unavailable.")}const generateImage=parseFunction(aiModule.generateImage);if(typeof gatewayModule.createGateway!=="function"){throw new SlopcameraCloudError("GENERATION_FAILED","The Vercel AI Gateway runtime is unavailable.")}const createGateway=gatewayModule.createGateway;return{createGateway:(settings)=>{const provider=createGateway(settings);if(!isObject(provider)||typeof provider.imageModel!=="function"){throw new SlopcameraCloudError("GENERATION_FAILED","The Vercel AI Gateway runtime is unavailable.")}const imageModel=provider.imageModel;return{imageModel:(modelId)=>imageModel.call(provider,modelId)}},generateImage}}function gatewayUrl(input){try{if(input instanceof Request)return new URL(input.url);if(input instanceof URL)return new URL(input.href);return new URL(input,slopcameraGatewayApiBaseUrl)}catch{throw new SlopcameraCloudError("GENERATION_FAILED","The Vercel AI Gateway request was rejected.")}}function canonicalGatewayInput(input,url){if(!(input instanceof Request))return url;const snapshot=new Request(input);const body=snapshot.method==="GET"||snapshot.method==="HEAD"?undefined:snapshot.body??undefined;const requestInit={headers:snapshot.headers,method:snapshot.method,signal:snapshot.signal,...body===undefined?{}:{body,duplex:"half"}};return new Request(url.href,requestInit)}function boundedResponse(response,maximumBytes){const declared=response.headers.get("content-length");if(declared!==null){const value=Number(declared);if(!Number.isSafeInteger(value)||value<0||value>maximumBytes){response.body?.cancel().catch(()=>{return});throw new SlopcameraCloudError("GENERATION_INVALID_RESPONSE","Vercel AI Gateway returned an invalid bounded response.")}}if(response.body===null)return response;const reader=response.body.getReader();let bytes=0;const body=new ReadableStream({cancel:async(reason)=>await reader.cancel(reason),pull:async(controller)=>{try{const next=await reader.read();if(next.done){reader.releaseLock();controller.close();return}bytes+=next.value.byteLength;if(bytes>maximumBytes){await reader.cancel().catch(()=>{return});controller.error(new SlopcameraCloudError("GENERATION_INVALID_RESPONSE","Vercel AI Gateway returned an invalid bounded response."));return}controller.enqueue(next.value)}catch{controller.error(new SlopcameraCloudError("GENERATION_INVALID_RESPONSE","Vercel AI Gateway returned an invalid bounded response."))}}});return new Response(body,{headers:response.headers,status:response.status,statusText:response.statusText})}function createFixedGatewayFetch(options={}){const fetchImplementation=options.fetch??globalThis.fetch;const maximumResponseBytes=options.maximumResponseBytes??defaultMaximumGatewayResponseBytes;if(!Number.isSafeInteger(maximumResponseBytes)||maximumResponseBytes<1||maximumResponseBytes>1024*1024*1024){invalidArgument("maximumResponseBytes is outside the supported range.")}const fixed=new URL(slopcameraGatewayApiBaseUrl);return async(input,init)=>{const url=gatewayUrl(input);if(url.origin!==fixed.origin||url.pathname!==fixed.pathname&&!url.pathname.startsWith(`${fixed.pathname}/`)||url.username!==""||url.password!==""){throw new SlopcameraCloudError("GENERATION_FAILED","The Vercel AI Gateway request was rejected.")}const canonicalInput=canonicalGatewayInput(input,url);let response;try{response=await fetchImplementation(canonicalInput,{...init,redirect:"error"})}catch(error){if(error instanceof SlopcameraCloudError)throw error;throw new SlopcameraCloudError("GENERATION_FAILED","Vercel AI Gateway image generation failed; the request was not retried.")}if(response.redirected||response.status>=300&&response.status<400){await response.body?.cancel().catch(()=>{return});throw new SlopcameraCloudError("GENERATION_FAILED","The Vercel AI Gateway request was rejected.")}return boundedResponse(response,maximumResponseBytes)}}function combineSignals(caller,timeoutMs){const controller=new AbortController;const abort=()=>controller.abort(caller?.reason);let rejectInterruption;const interruption=new Promise((_resolve,reject)=>{rejectInterruption=reject});const rejectOnAbort=()=>{rejectInterruption?.(new SlopcameraCloudError("GENERATION_FAILED","Vercel AI Gateway image generation was cancelled or exceeded its deadline."))};controller.signal.addEventListener("abort",rejectOnAbort,{once:true});if(caller?.aborted===true)abort();caller?.addEventListener("abort",abort,{once:true});const timer=setTimeout(()=>controller.abort(),timeoutMs);return{dispose:()=>{clearTimeout(timer);caller?.removeEventListener("abort",abort);controller.signal.removeEventListener("abort",rejectOnAbort);rejectInterruption=undefined},interruption,signal:controller.signal}}function mediaType(value){if(typeof value!=="string"||!slopcameraResponseMediaTypes.includes(value)){throw new SlopcameraCloudError("GENERATION_INVALID_RESPONSE","Vercel AI Gateway returned an unsupported image type.")}return value}function validImageBytes(bytes,type){if(bytes.byteLength<12||bytes.byteLength>slopcameraMaximumRawImageBytes){return false}if(type==="image/png"){return Buffer.from(bytes.subarray(0,8)).equals(Buffer.from([137,80,78,71,13,10,26,10]))}if(type==="image/jpeg"){return bytes[0]===255&&bytes[1]===216&&bytes.at(-2)===255&&bytes.at(-1)===217}return bytes.byteLength>=16&&Buffer.from(bytes.subarray(0,4)).toString("ascii")==="RIFF"&&Buffer.from(bytes).readUInt32LE(4)===bytes.byteLength-8&&Buffer.from(bytes.subarray(8,12)).toString("ascii")==="WEBP"&&["VP8 ","VP8L","VP8X"].includes(Buffer.from(bytes.subarray(12,16)).toString("ascii"))}function warningReceipt(value){let type="provider-warning";let detail="Provider warning";if(typeof value==="string"){detail=value}else if(isObject(value)){if(typeof value.type==="string"&&value.type.length<=64){type=value.type.replace(/[^a-z0-9._-]/giu,"-")||type}if(typeof value.message==="string")detail=value.message;else if(typeof value.details==="string")detail=value.details}return`${type} sha256:${createHash("sha256").update(detail).digest("hex")}`}function returnedUsage(value){if(!isObject(value)||!isObject(value.usage))return;const{inputTokens,outputTokens}=value.usage;return{inputTokens:typeof inputTokens==="number"?inputTokens:undefined,outputTokens:typeof outputTokens==="number"?outputTokens:undefined}}function parseResult(value,model){if(!isObject(value)||!Array.isArray(value.images)||value.images.length!==1){throw new SlopcameraCloudError("GENERATION_INVALID_RESPONSE","Vercel AI Gateway did not return exactly one image.")}const image=value.images[0];if(!isObject(image)||!(image.uint8Array instanceof Uint8Array)){throw new SlopcameraCloudError("GENERATION_INVALID_RESPONSE","Vercel AI Gateway returned an invalid bounded image.")}const type=mediaType(image.mediaType);if(!validImageBytes(image.uint8Array,type)){throw new SlopcameraCloudError("GENERATION_INVALID_RESPONSE","Vercel AI Gateway returned an invalid bounded image.")}const gateway=isObject(value.providerMetadata)&&isObject(value.providerMetadata.gateway)?value.providerMetadata.gateway:undefined;const foreignGenerationId=gateway!==undefined&&typeof gateway.generationId==="string"&&gateway.generationId.length>0&&gateway.generationId.length<=256&&!/[\u0000-\u001f\u007f]/u.test(gateway.generationId)?gateway.generationId:randomUUID();const requestId=`sha256:${createHash("sha256").update("slopcamera.gateway-generation-id/v1\x00").update(foreignGenerationId).digest("hex")}`;const warnings=Array.isArray(value.warnings)?value.warnings.slice(0,100).map(warningReceipt):[];return{bytes:image.uint8Array,response:{image:{base64:Buffer.from(image.uint8Array).toString("base64"),mediaType:type},model,provider:"vercel-ai-gateway",requestId,warnings}}}async function performGeneration(input,dependencies){const model=validateModel(input.model);const prompt=validatePrompt(input.prompt);const credential=resolveSlopcameraGatewayCredential(dependencies.environment);const timeout=combineSignals(input.signal,validateTimeout(input.timeoutMs));let dispatched=false;let rejected=false;let reported=false;const report=(usage)=>{if(reported||!dispatched||rejected)return;reported=true;dependencies.onProviderCost?.(imageCallCosts(model,prompt,usage))};const observedFetch=async(request,init)=>{const response=await(dependencies.fetch??globalThis.fetch)(request,init);if(response.status>=400&&response.status<500)rejected=true;return response};try{const generation=(async()=>{assertGenerationActive(timeout.signal);disableAiSdkWarningLogging();const runtime=await(dependencies.loadRuntime??loadDefaultGatewayRuntime)();assertGenerationActive(timeout.signal);const provider=runtime.createGateway({apiKey:credential.token,baseURL:slopcameraGatewayApiBaseUrl,fetch:createFixedGatewayFetch({fetch:observedFetch,...dependencies.maximumResponseBytes===undefined?{}:{maximumResponseBytes:dependencies.maximumResponseBytes}})});assertGenerationActive(timeout.signal);dispatched=true;const generated=await runtime.generateImage({abortSignal:timeout.signal,maxRetries:0,model:provider.imageModel(model),n:1,prompt});report(returnedUsage(generated));return parseResult(generated,model)})();return await Promise.race([generation,timeout.interruption])}catch(error){report(undefined);if(error instanceof SlopcameraCloudError)throw error;throw new SlopcameraCloudError("GENERATION_FAILED","Vercel AI Gateway image generation failed; the request was not retried.")}finally{timeout.dispose()}}async function generateSlopcameraImage(input,dependencies={}){return(await performGeneration(input,dependencies)).response}function expectedMediaType(outputPath){const extension=extname(outputPath).toLocaleLowerCase("en-US");if(extension===".png")return"image/png";if(extension===".jpg"||extension===".jpeg")return"image/jpeg";if(extension===".webp")return"image/webp";invalidArgument("Output path must end in .png, .jpg, .jpeg, or .webp.")}async function atomicImageWrite(outputPath,bytes){const absolutePath=resolve(outputPath);const temporaryPath=resolve(dirname(absolutePath),`.${randomUUID()}.slopcamera-generate.tmp`);try{await writeFile(temporaryPath,bytes,{flag:"wx"});await link(temporaryPath,absolutePath);return absolutePath}catch{throw new SlopcameraCloudError("OUTPUT_WRITE_FAILED","Slopcamera could not atomically write the generated image.")}finally{await rm(temporaryPath,{force:true}).catch(()=>{return})}}async function generateSlopcameraImageFile(input,dependencies={}){if(typeof input.outputPath!=="string"||input.outputPath.length<1||input.outputPath.length>4096||input.outputPath.includes("\x00")){invalidArgument("Output path must be a non-empty local path.")}const expected=expectedMediaType(input.outputPath);const generated=await performGeneration(input,dependencies);if(generated.response.image.mediaType!==expected){throw new SlopcameraCloudError("GENERATION_INVALID_RESPONSE",`Generated ${generated.response.image.mediaType} does not match the requested ${expected} output path.`)}const outputPath=await atomicImageWrite(input.outputPath,generated.bytes);return{bytes:generated.bytes.byteLength,mediaType:generated.response.image.mediaType,model:generated.response.model,outputPath,provider:generated.response.provider,requestId:generated.response.requestId,sha256:createHash("sha256").update(generated.bytes).digest("hex"),warnings:generated.response.warnings}}
3
- export{SlopcameraCloudError,slopcameraGatewayApiBaseUrl,slopcameraImageModels,slopcameraResponseMediaTypes,slopcameraMaximumPromptBytes,slopcameraMaximumRawImageBytes,resolveSlopcameraGatewayCredential,slopcameraGatewayCredentialStatus,createFixedGatewayFetch,generateSlopcameraImage,generateSlopcameraImageFile};