@hraness/slopcamera 3.4.0 → 3.6.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.
- package/README.md +32 -19
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/args.ts +3 -1
- package/apps/desktop/cli/commands.ts +22 -1
- package/apps/desktop/cli/help.ts +37 -13
- package/apps/desktop/cli/menubar-status.ts +155 -0
- package/apps/desktop/cli/menubar.ts +129 -101
- package/apps/desktop/dist/cli/main.js +334 -312
- package/apps/desktop/html-overlay/catalog.ts +1 -0
- package/apps/desktop/html-overlay/spatial.ts +10 -6
- package/apps/desktop/workflows/index.ts +1 -0
- package/dist/cli.js +7 -8
- package/dist/generate.js +1 -1
- package/dist/{index-qtn3s421.js → index-4z9y2wa2.js} +1 -1
- package/dist/{index-sy1n4zfy.js → index-fevf4ppk.js} +1 -1
- package/dist/{index-qry58nj2.js → index-mnp0bftq.js} +2 -2
- package/dist/index-nxtmy18t.js +3 -0
- package/dist/{index-abnk4h9c.js → index-qxfexvj3.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/operations.js +1 -1
- package/dist/vectorize/worker.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/README.md +3 -3
- package/examples/studio/vgpu/README.md +1 -1
- package/package.json +5 -6
- package/skills/slopcamera/SKILL.md +4 -3
- package/skills/slopcamera/references/brand-illustrations.md +237 -0
- package/skills/slopcamera/references/gateway-media.md +1 -1
- package/skills/slopcamera/references/install.md +10 -1
- package/skills/slopcamera/references/patent-drawings.md +4 -4
- package/skills/slopcamera/references/support.md +1 -1
- package/skills/slopcamera/references/vectorization.md +3 -1
- package/src/cli.ts +13 -10
- package/src/code/public-operations.ts +9 -0
- package/src/generate.ts +44 -1
- package/src/generation-pricing.ts +217 -0
- package/src/mcp/tools.ts +4 -0
- package/src/operations.ts +4 -0
- package/src/support.ts +7 -1
- package/src/vectorize/command.ts +6 -1
- package/src/vectorize/supervisor.ts +23 -3
- package/src/vectorize/tool.ts +8 -4
- package/src/version.ts +1 -1
- package/src/visual-style.ts +4 -1
- package/dist/index-77fjfg9f.js +0 -3
package/README.md
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
# Slopcamera
|
|
2
2
|
|
|
3
|
-
[](https://slopcamera.com)
|
|
4
4
|
|
|
5
|
-
**Slopcamera
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
**Slopcamera is a media studio for coding agents. Codex, Claude Code, and other
|
|
6
|
+
agents make images, diagrams, animation, 3D scenes, and edited video from source
|
|
7
|
+
files they can keep revising.**
|
|
8
8
|
|
|
9
|
-
Describe
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
Describe the result you want to Codex, Claude Code, or another coding agent.
|
|
10
|
+
Slopcamera gives the agent a Bun CLI, a TypeScript SDK, and an Agent Skill matched
|
|
11
|
+
to the CLI version, so it can inspect sources, set up cameras, edit a composition,
|
|
12
|
+
and render. `slopcamera mcp` runs an MCP server with a smaller, fixed set of diagram,
|
|
13
|
+
image, and scene tools. Projects stay in local files, and there is no Slopcamera
|
|
14
|
+
account. Generation uses your own Vercel AI Gateway account or, for prompt-only
|
|
15
|
+
images, prepaid Hraness Credits.
|
|
14
16
|
|
|
15
17
|
[Install](#install-slopcamera) · [Make a first diagram](#make-your-first-diagram) · [Capabilities](#what-slopcamera-does) · [Documentation](https://slopcamera.com/docs) · [slopcamera.com](https://slopcamera.com)
|
|
16
18
|
|
|
@@ -38,20 +40,21 @@ the rendered output with source, runtime requirements and reproduction steps.
|
|
|
38
40
|
timing, framing, captions, and effects as project decisions. Preview and final
|
|
39
41
|
renders use the same timeline and composition.
|
|
40
42
|
- **Choose where computation happens.** Local rendering and editing use your
|
|
41
|
-
machine. Optional model-backed work uses your Vercel AI Gateway access
|
|
42
|
-
|
|
43
|
+
machine. Optional model-backed work uses your Vercel AI Gateway access and
|
|
44
|
+
uploads named local media only after you acknowledge it. Prompt-only images can
|
|
45
|
+
instead run on the hosted API with prepaid Hraness Credits. Native Python
|
|
43
46
|
authoring requires separate trust because it runs as your current user.
|
|
44
47
|
|
|
45
48
|
## Install Slopcamera
|
|
46
49
|
|
|
47
|
-
Slopcamera v3.
|
|
50
|
+
Slopcamera v3.5.0 includes 17 reusable film and animation direction profiles, deterministic exposure helpers, and a sample renderer that defaults to native 4K. Use the [style direction guide](docs/how-to/direct-visual-styles.md) to author cel animation, pixel art, mathematical explanations, and historical film treatments from retained sources. On macOS, `slopcamera menubar` puts Slopcamera in the menu bar and explains each macOS notice before it appears.
|
|
48
51
|
|
|
49
52
|
Install [Bun 1.3.14 or newer](https://bun.sh), then install the verified
|
|
50
|
-
[Slopcamera v3.
|
|
53
|
+
[Slopcamera v3.5.0 release](https://github.com/hraness/slopcamera/releases/tag/v3.5.0)
|
|
51
54
|
from its canonical archive:
|
|
52
55
|
|
|
53
56
|
```sh
|
|
54
|
-
bun add --global https://github.com/hraness/slopcamera/releases/download/v3.
|
|
57
|
+
bun add --global https://github.com/hraness/slopcamera/releases/download/v3.5.0/hraness-slopcamera-3.5.0.tgz
|
|
55
58
|
slopcamera skill install --target agents
|
|
56
59
|
```
|
|
57
60
|
|
|
@@ -87,8 +90,9 @@ Native engines install separately. The [source-install guide](docs/how-to/use-cu
|
|
|
87
90
|
explains workspace placement and durable-run identity; the
|
|
88
91
|
[capability reference](docs/reference/capabilities.md) names runtime requirements.
|
|
89
92
|
|
|
90
|
-
Slopcamera is a CLI. On macOS, an optional unbundled menu-bar companion
|
|
91
|
-
|
|
93
|
+
Slopcamera is a CLI. On macOS, an optional unbundled menu-bar companion shows
|
|
94
|
+
what Slopcamera is rendering, how the last job ended, your last known credits
|
|
95
|
+
balance and the newest files in the agent outputs directory; `slopcamera menubar` runs a prebuilt
|
|
92
96
|
companion directly and does not install or launch an application bundle. The
|
|
93
97
|
companion is a thin product binary over the shared
|
|
94
98
|
[desktop-foundation](https://github.com/hraness/desktop-foundation) crate, pinned
|
|
@@ -134,7 +138,8 @@ architecture from dimensions, [build a parametric design](https://slopcamera.com
|
|
|
134
138
|
|
|
135
139
|
## What Slopcamera does
|
|
136
140
|
|
|
137
|
-
Slopcamera
|
|
141
|
+
Slopcamera lets your coding agent make images, diagrams, animation, 3D scenes,
|
|
142
|
+
and video from source files it can keep revising.
|
|
138
143
|
|
|
139
144
|
### Author scenes and direct cameras
|
|
140
145
|
|
|
@@ -153,7 +158,7 @@ rigged/morph profile in v3.3.1 admits bounded skins and morph targets. Saved spl
|
|
|
153
158
|
capture appearance; they do not establish collision geometry or editable native
|
|
154
159
|
meshes. See [Directed scenes](docs/spatial-scenes.md).
|
|
155
160
|
|
|
156
|
-
Slopcamera v3.3.1
|
|
161
|
+
Slopcamera v3.3.1 introduced the cinematic planning loop: a `slopcamera.spatial-direction`
|
|
157
162
|
document describes beats, actions, camera coverage, and look intents, compiles
|
|
158
163
|
into proposed performance/camera/cinema/material-lighting/shot documents, and
|
|
159
164
|
fans out into bounded galleries per axis. Declared effects bind into renders,
|
|
@@ -308,7 +313,7 @@ declarative graphs, approvals, and resuming work.
|
|
|
308
313
|
workflows run with the current user's access. Hashes and receipts identify
|
|
309
314
|
observed inputs and outputs; they do not make arbitrary code hermetic.
|
|
310
315
|
- **MCP is a subset.** Its 17 fixed tools check and render diagrams, plan and audit
|
|
311
|
-
scenes, and run six bounded
|
|
316
|
+
scenes, and run six bounded operation codes. It does not
|
|
312
317
|
expose every local CLI operation and never mutates project state.
|
|
313
318
|
|
|
314
319
|
## Design and trust
|
|
@@ -333,6 +338,14 @@ source identity alone does not promise identical pixels on another machine.
|
|
|
333
338
|
See [Architecture](docs/architecture.md), [`SECURITY.md`](SECURITY.md),
|
|
334
339
|
[`PRIVACY.md`](PRIVACY.md), and [`NOTICE.md`](NOTICE.md) for the detailed boundaries.
|
|
335
340
|
|
|
341
|
+
Your agent renders images, diagrams, scenes, animation, and video from source
|
|
342
|
+
files you keep, and important operations record their inputs and outputs in
|
|
343
|
+
receipts, so a rendered result arrives with the record of how it was made: the
|
|
344
|
+
design every Hraness project shares. [The thread through
|
|
345
|
+
hraness](https://hraness.com/writing/the-thread-through-hraness) follows that
|
|
346
|
+
design across the projects, and the [ALGAL
|
|
347
|
+
vision](https://algal.computer/docs/vision/) states the bet behind it.
|
|
348
|
+
|
|
336
349
|
## Documentation
|
|
337
350
|
|
|
338
351
|
- **Learn:** [Your first diagram](docs/tutorials/first-diagram.md) · [Your first native film](docs/tutorials/first-native-film.md).
|
|
@@ -4,7 +4,7 @@ import type { z } from "zod";
|
|
|
4
4
|
|
|
5
5
|
import type { ApplicationContext } from "./context";
|
|
6
6
|
|
|
7
|
-
export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.
|
|
7
|
+
export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.6.0" as const;
|
|
8
8
|
|
|
9
9
|
export const OPERATION_KINDS = [
|
|
10
10
|
"scene.audit",
|
package/apps/desktop/cli/args.ts
CHANGED
|
@@ -3659,8 +3659,10 @@ export function parseCliArgs(argv: readonly string[]): CliCommand {
|
|
|
3659
3659
|
case "menubar": {
|
|
3660
3660
|
const parsed = parseOptions(argv.slice(1), { ...JSON_SPEC, "--foreground": "flag", "--background": "flag" });
|
|
3661
3661
|
const positionals = parsed.positionals;
|
|
3662
|
-
if (positionals.length > 1 || (positionals[0] !== undefined && !["install", "uninstall", "status"].includes(positionals[0]))) throw new CliError("usage", "Use slopcamera menubar [--foreground|--background]
|
|
3662
|
+
if (positionals.length > 1 || (positionals[0] !== undefined && !["install", "uninstall", "status", "start"].includes(positionals[0]))) throw new CliError("usage", "Use slopcamera menubar [--foreground|--background] or slopcamera menubar install|uninstall|status|start. See slopcamera help menubar.");
|
|
3663
3663
|
if (positionals[0] !== undefined && (optionFlag(parsed, "--foreground") || optionFlag(parsed, "--background"))) throw new CliError("usage", "Choose a menu-bar action or a run mode, not both.");
|
|
3664
|
+
// `start` opens it now and returns, like --background; the login item's hints use it.
|
|
3665
|
+
if (positionals[0] === "start") return { kind: "menubar", action: "run", mode: "background", json: optionFlag(parsed, "--json") };
|
|
3664
3666
|
return { kind: "menubar", action: (positionals[0] as "install" | "uninstall" | "status" | undefined) ?? "run", mode: optionFlag(parsed, "--background") ? "background" : "foreground", json: optionFlag(parsed, "--json") };
|
|
3665
3667
|
}
|
|
3666
3668
|
case "outputs": {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { activityLabel, recordActivity, recordCredits } from "./menubar-status";
|
|
1
2
|
import { reportUsefulResult, type UsefulResultObserver } from "../../../src/support-completion";
|
|
2
3
|
import { SlopcameraCloudError } from "../../../src/cloud-errors";
|
|
3
4
|
import {
|
|
@@ -362,7 +363,7 @@ import {
|
|
|
362
363
|
workflowRunStore,
|
|
363
364
|
} from "./workflow-runs";
|
|
364
365
|
|
|
365
|
-
export const SLOPCAMERA_VERSION = "3.
|
|
366
|
+
export const SLOPCAMERA_VERSION = "3.6.0";
|
|
366
367
|
|
|
367
368
|
// Legacy direct renders predate per-target output contracts. Keep them
|
|
368
369
|
// bounded generously enough for long-form production while preventing one
|
|
@@ -739,6 +740,13 @@ function humanTime(microseconds: number): string {
|
|
|
739
740
|
|
|
740
741
|
function requireCapability(capabilities: readonly Capability[], name: Capability["name"]): string {
|
|
741
742
|
const capability = capabilityByName(capabilities, name);
|
|
743
|
+
if ((!capability.available || capability.command === undefined) && name === "face-analyzer") {
|
|
744
|
+
throw new CliError(
|
|
745
|
+
"unavailable",
|
|
746
|
+
"Face analysis needs a small helper that isn't built on this Mac yet. In a Slopcamera checkout, run bun run build:desktop:analysis:macos; it uses Apple's command line tools and asks before installing them.",
|
|
747
|
+
{ capability: name, ...(capability.reason === undefined ? {} : { reason: capability.reason }) },
|
|
748
|
+
);
|
|
749
|
+
}
|
|
742
750
|
if (!capability.available || capability.command === undefined) {
|
|
743
751
|
throw new CliError(
|
|
744
752
|
"unavailable",
|
|
@@ -5541,6 +5549,7 @@ async function handleCreditsStatus(
|
|
|
5541
5549
|
} catch (error) {
|
|
5542
5550
|
throw creditsErrorToCli(error);
|
|
5543
5551
|
}
|
|
5552
|
+
recordCredits(context.stateRoot, { usd: balance.balance.usd, low: balance.lowBalance, checkedAt: context.clock() });
|
|
5544
5553
|
const view = {
|
|
5545
5554
|
configured: true,
|
|
5546
5555
|
tokenSource: loaded.source,
|
|
@@ -8412,6 +8421,7 @@ async function resolveMutationTarget(
|
|
|
8412
8421
|
export async function runCli(argv: readonly string[], dependencies: CliDependencies = {}): Promise<number> {
|
|
8413
8422
|
const io = dependencies.io ?? processIo;
|
|
8414
8423
|
let jsonRequested = argv.includes("--json") || argv.includes("--jsonl");
|
|
8424
|
+
let activity: { readonly stateRoot: string; readonly label: string; readonly startedAt: number } | undefined;
|
|
8415
8425
|
try {
|
|
8416
8426
|
const command = parseCliArgs(argv);
|
|
8417
8427
|
jsonRequested = "json" in command ? command.json : command.kind === "events" && command.format !== "human";
|
|
@@ -8506,6 +8516,11 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
|
|
|
8506
8516
|
? {}
|
|
8507
8517
|
: { renderGalleryScene: dependencies.renderGalleryScene }),
|
|
8508
8518
|
};
|
|
8519
|
+
const label = activityLabel(command);
|
|
8520
|
+
if (label !== undefined) {
|
|
8521
|
+
activity = { stateRoot, label, startedAt: context.clock() };
|
|
8522
|
+
recordActivity(stateRoot, { state: "running", label, startedAt: activity.startedAt });
|
|
8523
|
+
}
|
|
8509
8524
|
const mutationTarget = await resolveMutationTarget(paths, stateRoot, command);
|
|
8510
8525
|
await withCommandHostResources(context, command, async admittedContext => {
|
|
8511
8526
|
if (mutationTarget === undefined) {
|
|
@@ -8528,9 +8543,15 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
|
|
|
8528
8543
|
|| command.kind === "project-render" && command.action === "run" && !command.dryRun) {
|
|
8529
8544
|
reportUsefulResult(dependencies.onUsefulResult);
|
|
8530
8545
|
}
|
|
8546
|
+
if (activity !== undefined) {
|
|
8547
|
+
recordActivity(activity.stateRoot, { state: "done", label: activity.label, startedAt: activity.startedAt, finishedAt: io.now().getTime() });
|
|
8548
|
+
}
|
|
8531
8549
|
return 0;
|
|
8532
8550
|
} catch (error) {
|
|
8533
8551
|
const failure = asCliError(error);
|
|
8552
|
+
if (activity !== undefined) {
|
|
8553
|
+
recordActivity(activity.stateRoot, { state: "failed", label: activity.label, startedAt: activity.startedAt, finishedAt: io.now().getTime(), error: failure.code });
|
|
8554
|
+
}
|
|
8534
8555
|
const payload = {
|
|
8535
8556
|
error: {
|
|
8536
8557
|
code: failure.code,
|
package/apps/desktop/cli/help.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
+
import { slopcameraSupportAdvancedHelp, slopcameraSupportHelpLine } from "../../../src/support";
|
|
2
|
+
|
|
1
3
|
import { HTML_OVERLAY_SCAFFOLD_KINDS } from "../html-overlay";
|
|
2
4
|
|
|
3
5
|
const HTML_OVERLAY_SCAFFOLD_KIND_HELP =
|
|
4
6
|
HTML_OVERLAY_SCAFFOLD_KINDS.join("|");
|
|
5
7
|
|
|
6
|
-
const
|
|
8
|
+
const globalHelp = () => `slopcamera: a media studio for coding agents
|
|
7
9
|
|
|
8
10
|
Usage: slopcamera <command> [options]
|
|
9
11
|
|
|
10
12
|
Commands:
|
|
11
|
-
capabilities
|
|
12
|
-
operations list|show
|
|
13
|
+
capabilities List what this build can do and how well each part is tested
|
|
14
|
+
operations list|show List the operations agents can run and their limits
|
|
13
15
|
diagram init|check|render Create, validate, or render portable diagram sources
|
|
14
16
|
diagram sheets init|check|render
|
|
15
17
|
Create and render black-and-white drawing sheets
|
|
@@ -35,10 +37,6 @@ Commands:
|
|
|
35
37
|
credits status|topup|wait|forget
|
|
36
38
|
Buy and inspect hosted generation credits
|
|
37
39
|
media audio|color Apply local non-destructive audio and video effects
|
|
38
|
-
menubar [--background] Run the prebuilt menu-bar companion
|
|
39
|
-
menubar install|uninstall|status Manage its per-user LaunchAgent
|
|
40
|
-
support [--json|protocol --json|offer --json|shown <id>|release <id>|dismiss|snooze|enable|status --json]
|
|
41
|
-
Optional support; no feature requires payment
|
|
42
40
|
outputs Print the agent outputs directory
|
|
43
41
|
recordings list List recording bundles
|
|
44
42
|
projects list|create List projects or create one from a recording
|
|
@@ -55,13 +53,39 @@ Commands:
|
|
|
55
53
|
render plan|run <recording> Resolve or execute a render plan
|
|
56
54
|
assets emoji search|resolve Find checked local emoji overlays
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
Examples:
|
|
57
|
+
slopcamera doctor Check what this Mac can render
|
|
58
|
+
slopcamera html scaffold plain --output intro.html
|
|
59
|
+
Start an HTML scene
|
|
60
|
+
slopcamera render run REC --output out.mp4 Render a recording
|
|
61
61
|
|
|
62
|
-
Run slopcamera help <command> for command-specific help
|
|
62
|
+
Run slopcamera help <command> for command-specific help.
|
|
63
|
+
${slopcameraSupportHelpLine()}`;
|
|
63
64
|
|
|
64
65
|
const HELP: Readonly<Record<string, string>> = {
|
|
66
|
+
advanced: `${slopcameraSupportAdvancedHelp()}`,
|
|
67
|
+
menubar: `Usage:
|
|
68
|
+
slopcamera menubar [--foreground|--background] [--json]
|
|
69
|
+
slopcamera menubar install|uninstall|status|start [--json]
|
|
70
|
+
|
|
71
|
+
Shows what Slopcamera is doing in the menu bar: a render in progress, how the
|
|
72
|
+
last job ended and your last known credits balance, plus your newest outputs.
|
|
73
|
+
It only reads that status and the outputs folder (slopcamera outputs).
|
|
74
|
+
|
|
75
|
+
menubar Open it now; it closes when this command ends
|
|
76
|
+
menubar start Open it now and return (same as --background)
|
|
77
|
+
menubar install Open it every time you log in
|
|
78
|
+
menubar uninstall Stop opening it at login and remove the installed copy
|
|
79
|
+
menubar status Check whether it opens at login and is running
|
|
80
|
+
|
|
81
|
+
If it's already in your menu bar, starting it again says so and succeeds.
|
|
82
|
+
macOS shows a notice that slopcamera-menubar can open at login. Turn it off any
|
|
83
|
+
time in System Settings › General › Login Items & Extensions.
|
|
84
|
+
|
|
85
|
+
The menu bar isn't in released packages yet, so it isn't listed in
|
|
86
|
+
slopcamera help. In a Slopcamera checkout, build it with
|
|
87
|
+
cargo build --release --manifest-path desktop/Cargo.toml and slopcamera finds
|
|
88
|
+
it; elsewhere, set SLOPCAMERA_MENUBAR to the built file.`,
|
|
65
89
|
capabilities: `Usage: slopcamera capabilities [--json]
|
|
66
90
|
|
|
67
91
|
Print the exact statically assembled capability manifest for this CLI build. Each module owns named operation, workflow, MCP-tool, command, and runtime-profile identities. The manifest includes trust classes, resource/effect policies, runtime requirements, qualification status, and a canonical SHA-256. It reads no workspace source, loads no plugin, probes no runtime, and grants no authority. Use doctor separately to inspect this machine's currently available executables.`,
|
|
@@ -677,8 +701,8 @@ Long inactivity is analyzed and removed by default; use --keep-inactivity to opt
|
|
|
677
701
|
};
|
|
678
702
|
|
|
679
703
|
export function commandHelp(topic: readonly string[]): string {
|
|
680
|
-
if (topic.length === 0) return
|
|
681
|
-
return HELP[topic[0]!] ??
|
|
704
|
+
if (topic.length === 0) return globalHelp();
|
|
705
|
+
return HELP[topic[0]!] ?? globalHelp();
|
|
682
706
|
}
|
|
683
707
|
|
|
684
708
|
export function completions(words: readonly string[]): readonly string[] {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, openSync, closeSync, readFileSync, renameSync, rmSync, writeSync, constants } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { CliCommand } from "./args";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The small status file the menu bar reads: what Slopcamera is doing now,
|
|
9
|
+
* how the last render ended, and the last credits balance a command saw.
|
|
10
|
+
* It holds plain labels, times and fixed error codes only — never paths,
|
|
11
|
+
* prompts, tokens or account identifiers. Writes are best effort: a status
|
|
12
|
+
* that can't be saved never fails the command.
|
|
13
|
+
*/
|
|
14
|
+
export const MENUBAR_STATUS_FILE = "menubar-status.json";
|
|
15
|
+
const MAX_STATUS_BYTES = 16 * 1024;
|
|
16
|
+
|
|
17
|
+
export type ActivityState = "running" | "done" | "failed";
|
|
18
|
+
|
|
19
|
+
export interface MenubarActivity {
|
|
20
|
+
readonly state: ActivityState;
|
|
21
|
+
readonly label: string;
|
|
22
|
+
readonly startedAt: number;
|
|
23
|
+
readonly finishedAt?: number;
|
|
24
|
+
readonly error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MenubarCredits {
|
|
28
|
+
readonly usd: string;
|
|
29
|
+
readonly low: boolean;
|
|
30
|
+
readonly checkedAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MenubarStatus {
|
|
34
|
+
readonly schemaVersion: 1;
|
|
35
|
+
readonly activity?: MenubarActivity;
|
|
36
|
+
readonly credits?: MenubarCredits;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Plain labels for the commands that make media. Everything else leaves the status alone. */
|
|
40
|
+
export function activityLabel(command: CliCommand): string | undefined {
|
|
41
|
+
switch (command.kind) {
|
|
42
|
+
case "render-run": return command.dryRun ? undefined : "Rendering a recording";
|
|
43
|
+
case "project-render": return command.action === "run" && !command.dryRun ? "Rendering a project" : undefined;
|
|
44
|
+
case "project-cinema": return "Rendering a project";
|
|
45
|
+
case "html-render": return "Rendering an HTML scene";
|
|
46
|
+
case "diagram-render": return "Rendering a diagram";
|
|
47
|
+
case "image-vectorize": return "Vectorizing an image";
|
|
48
|
+
case "ai-image-generate": return "Generating an image";
|
|
49
|
+
case "ai-video-generate": return "Generating a video";
|
|
50
|
+
case "ai-speech-generate": return "Generating speech";
|
|
51
|
+
case "directing": return "Directing a clip";
|
|
52
|
+
case "studio": return "Running a studio job";
|
|
53
|
+
case "spatial-scene": return command.action === "render" ? "Rendering a 3D scene" : undefined;
|
|
54
|
+
case "workflows-run": return "Running a workflow";
|
|
55
|
+
case "code-run": return "Running a workflow";
|
|
56
|
+
default: return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parse(text: string): MenubarStatus {
|
|
61
|
+
try {
|
|
62
|
+
const value: unknown = JSON.parse(text);
|
|
63
|
+
if (typeof value !== "object" || value === null || (value as { schemaVersion?: unknown }).schemaVersion !== 1) return { schemaVersion: 1 };
|
|
64
|
+
const record = value as Record<string, unknown>;
|
|
65
|
+
const activity = record.activity as MenubarActivity | undefined;
|
|
66
|
+
const credits = record.credits as MenubarCredits | undefined;
|
|
67
|
+
return {
|
|
68
|
+
schemaVersion: 1,
|
|
69
|
+
...(activity !== undefined && typeof activity === "object" && typeof activity.label === "string" ? { activity } : {}),
|
|
70
|
+
...(credits !== undefined && typeof credits === "object" && typeof credits.usd === "string" ? { credits } : {}),
|
|
71
|
+
};
|
|
72
|
+
} catch {
|
|
73
|
+
return { schemaVersion: 1 };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function readMenubarStatus(stateRoot: string): MenubarStatus {
|
|
78
|
+
const path = join(stateRoot, MENUBAR_STATUS_FILE);
|
|
79
|
+
try {
|
|
80
|
+
const info = lstatSync(path);
|
|
81
|
+
if (!info.isFile() || info.size > MAX_STATUS_BYTES) return { schemaVersion: 1 };
|
|
82
|
+
return parse(readFileSync(path, "utf8"));
|
|
83
|
+
} catch {
|
|
84
|
+
return { schemaVersion: 1 };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const LOCK_FILE = `.${MENUBAR_STATUS_FILE}.lock`;
|
|
89
|
+
/** A held lock blocks a write for this long before it is skipped. */
|
|
90
|
+
const LOCK_BUDGET_MS = 250;
|
|
91
|
+
/** A lock older than this is a crashed writer's and is reclaimed. */
|
|
92
|
+
const LOCK_STALE_MS = 10_000;
|
|
93
|
+
|
|
94
|
+
function sleepSync(ms: number): void {
|
|
95
|
+
Bun.sleepSync(ms);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Runs `update` holding a small exclusive lock file, so a render finishing
|
|
100
|
+
* while `credits status` runs cannot overwrite the other slot. Returns
|
|
101
|
+
* undefined when the lock never comes free; the write is then skipped
|
|
102
|
+
* rather than lost-halfway.
|
|
103
|
+
*/
|
|
104
|
+
function withStatusLock<T>(stateRoot: string, update: () => T): T | undefined {
|
|
105
|
+
const lock = join(stateRoot, LOCK_FILE);
|
|
106
|
+
const deadline = Date.now() + LOCK_BUDGET_MS;
|
|
107
|
+
for (;;) {
|
|
108
|
+
let fd: number;
|
|
109
|
+
try {
|
|
110
|
+
fd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return undefined;
|
|
113
|
+
try {
|
|
114
|
+
if (Date.now() - lstatSync(lock).mtimeMs > LOCK_STALE_MS) rmSync(lock, { force: true });
|
|
115
|
+
} catch { /* races with the lock holder */ }
|
|
116
|
+
if (Date.now() >= deadline) return undefined;
|
|
117
|
+
sleepSync(5);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
try {
|
|
122
|
+
return update();
|
|
123
|
+
} finally {
|
|
124
|
+
try { rmSync(lock, { force: true }); } catch { /* best effort */ }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Replaces the status file atomically with an owner-only file. Never throws. */
|
|
130
|
+
export function writeMenubarStatus(stateRoot: string, update: (current: MenubarStatus) => MenubarStatus): void {
|
|
131
|
+
withStatusLock(stateRoot, () => {
|
|
132
|
+
const path = join(stateRoot, MENUBAR_STATUS_FILE);
|
|
133
|
+
const staged = join(stateRoot, `.${MENUBAR_STATUS_FILE}.${randomUUID()}.tmp`);
|
|
134
|
+
try {
|
|
135
|
+
const root = lstatSync(stateRoot);
|
|
136
|
+
if (!root.isDirectory()) return;
|
|
137
|
+
const text = `${JSON.stringify(update(readMenubarStatus(stateRoot)))}\n`;
|
|
138
|
+
if (Buffer.byteLength(text) > MAX_STATUS_BYTES) return;
|
|
139
|
+
const fd = openSync(staged, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
140
|
+
try { writeSync(fd, text); } finally { closeSync(fd); }
|
|
141
|
+
chmodSync(staged, 0o600);
|
|
142
|
+
renameSync(staged, path);
|
|
143
|
+
} catch {
|
|
144
|
+
try { rmSync(staged, { force: true }); } catch { /* best effort */ }
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function recordActivity(stateRoot: string, activity: MenubarActivity): void {
|
|
150
|
+
writeMenubarStatus(stateRoot, (current) => ({ ...current, activity }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function recordCredits(stateRoot: string, credits: MenubarCredits): void {
|
|
154
|
+
writeMenubarStatus(stateRoot, (current) => ({ ...current, credits }));
|
|
155
|
+
}
|