@hraness/slopcamera 3.9.2 → 3.10.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/PRIVACY.md CHANGED
@@ -53,3 +53,11 @@ from vulnerability reports.
53
53
 
54
54
  Report suspected vulnerabilities through GitHub private vulnerability
55
55
  reporting at https://github.com/hraness/slopcamera/security/advisories/new.
56
+
57
+ The public slopcamera.com website sends cookieless page views, page exits,
58
+ performance measurements, explicit link and install-copy actions, missing-page
59
+ reports, and rate-limited redacted client errors to PostHog. It keeps no person
60
+ profiles, analytics cookies, or session recordings and respects Do Not Track.
61
+ Public campaign parameters are retained; other query values and credentials
62
+ are removed. Sensitive paths collapse to `/private` and their campaign
63
+ attribution is removed. Preview builds and the inert preview page send nothing.
package/README.md CHANGED
@@ -34,11 +34,11 @@ for prompt-only hosted images; model usage is billed separately.
34
34
  ## Install SlopCamera
35
35
 
36
36
  Install [Bun 1.3.14 or newer](https://bun.sh), then install the
37
- [SlopCamera v3.9.0 release](https://github.com/hraness/slopcamera/releases/tag/v3.9.0)
37
+ [SlopCamera v3.9.2 release](https://github.com/hraness/slopcamera/releases/tag/v3.9.2)
38
38
  and its matching Agent Skill:
39
39
 
40
40
  ```sh
41
- bun add --global https://github.com/hraness/slopcamera/releases/download/v3.9.0/hraness-slopcamera-3.9.0.tgz
41
+ bun add --global https://github.com/hraness/slopcamera/releases/download/v3.9.2/hraness-slopcamera-3.9.2.tgz
42
42
  slopcamera skill install --target agents
43
43
  ```
44
44
 
@@ -100,6 +100,10 @@ placement and resuming long runs.
100
100
 
101
101
  </details>
102
102
 
103
+ CLI updates in SlopCamera 3.10.0 and newer are enabled by default for supported
104
+ Bun and npm global installations. See [update controls](docs/reference/capabilities.md#cli-updates)
105
+ to keep a version or check for a release.
106
+
103
107
  ## Make a film. Then direct it again.
104
108
 
105
109
  [Last tram to the moon](https://slopcamera.com/docs/tutorials/first-animation)
@@ -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.9.2" as const;
7
+ export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.10.1" as const;
8
8
 
9
9
  export const OPERATION_KINDS = [
10
10
  "scene.audit",
@@ -365,7 +365,7 @@ import {
365
365
  workflowRunStore,
366
366
  } from "./workflow-runs";
367
367
 
368
- export const SLOPCAMERA_VERSION = "3.9.2";
368
+ export const SLOPCAMERA_VERSION = "3.10.1";
369
369
 
370
370
  // Legacy direct renders predate per-target output contracts. Keep them
371
371
  // bounded generously enough for long-form production while preventing one
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bun
2
+ import { fileURLToPath } from "node:url";
3
+ import type { CliUpdateOptions, StartupResult } from "@hraness/cli-update";
4
+ import { SLOPCAMERA_VERSION } from "../../../src/version";
5
+ import { slopcameraUpdatePolicy } from "./update-policy";
6
+
7
+ type EntryPorts = {
8
+ update(options: CliUpdateOptions): Promise<StartupResult>;
9
+ main(): Promise<void>;
10
+ };
11
+
12
+ export async function runSlopcameraEntrypoint(argv = process.argv.slice(2), ports: Partial<EntryPorts> = {}): Promise<void> {
13
+ const main = ports.main ?? (async () => (await import("./main")).runMainEntrypoint({}, true));
14
+ // Compiled private binaries and their embedded workers keep the native installer.
15
+ if (import.meta.path.includes("$bunfs")) { await main(); return; }
16
+ // The product renders its own command help; the updater sees an inert help request.
17
+ const gateArgv = argv[0] === "update" && argv.slice(1).some(arg => arg === "--help" || arg === "-h")
18
+ ? ["help", "update"] : argv;
19
+ const update = await (ports.update ?? (async options => (await import("@hraness/cli-update")).runCliUpdate(options)))({
20
+ packageName: "@hraness/slopcamera", version: SLOPCAMERA_VERSION, binName: "slopcamera",
21
+ entrypoint: fileURLToPath(import.meta.url), argv: gateArgv,
22
+ provider: { kind: "github", repository: "hraness/slopcamera", assetName: "hraness-slopcamera-{version}.tgz" },
23
+ ...slopcameraUpdatePolicy(gateArgv),
24
+ verifyArtifact: async artifact => (await import("./update-verification")).verifySlopcameraUpdate(artifact),
25
+ });
26
+ if (update.handled) { process.exitCode = update.exitCode; return; }
27
+ try { await main(); }
28
+ finally { await update.release(); }
29
+ }
30
+
31
+ if (import.meta.main) await runSlopcameraEntrypoint();
@@ -33,6 +33,7 @@ Commands:
33
33
  runs list|show|resume|approve|cancel
34
34
  Inspect and control durable workflow runs
35
35
  doctor Check local render and asset capabilities
36
+ update Update the CLI or manage automatic updates
36
37
  ai models|image|video|speech|transcribe
37
38
  Discover and run Vercel AI Gateway media models
38
39
  credits status|topup|wait|forget
@@ -719,6 +720,13 @@ compiles a bounded set of single-axis candidate plans under cinema/galleries/, e
719
720
  re-hashed through the canonical composition digest; it never selects a candidate.
720
721
 
721
722
  Imported media starts unverified. Align its audio before relying on synchronization. Structural edits are project-time operations and affect every placement. Camera moves address any placed video stream; metadata-driven screen zooms still require a recording-backed source placement. Face analysis and geometry tracks remain local. --select largest follows the largest currently visible prepared-layer face per frame. --require-all-selected makes a missing explicit/all-selected face invoke the chosen gap policy.`,
723
+ update: `Usage: slopcamera update [check|status|enable|disable] [--json]
724
+
725
+ Supported Bun and npm global installations on macOS and Linux check the immutable
726
+ GitHub release before work starts, at most once a day. Verification needs GitHub CLI.
727
+ Use slopcamera update disable to keep this version, or HRANESS_NO_UPDATE=1 for one
728
+ invocation. Exact Bun version pins require slopcamera update enable. Local image
729
+ vectorization, SDK imports, and the private compiled binary do not update automatically.`,
722
730
  render: `Usage: slopcamera render <plan|run> <recording> [--display <id|primary>] [--output <path>] [--dry-run] [--keep-inactivity] [--json]
723
731
 
724
732
  Long inactivity is analyzed and removed by default; use --keep-inactivity to opt out.`,
@@ -731,10 +739,11 @@ export function commandHelp(topic: readonly string[]): string {
731
739
 
732
740
  export function completions(words: readonly string[]): readonly string[] {
733
741
  const topLevel = [
734
- "capabilities", "operations", "diagram", "direct", "studio", "image", "html", "style", "workflows", "code", "runs", "doctor", "ai", "credits", "media", "support", "outputs", "status", "tui", "commands", "legacy", "recordings", "projects", "project", "scene", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
742
+ "update", "capabilities", "operations", "diagram", "direct", "studio", "image", "html", "style", "workflows", "code", "runs", "doctor", "ai", "credits", "media", "support", "outputs", "status", "tui", "commands", "legacy", "recordings", "projects", "project", "scene", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
735
743
  ];
736
744
  if (words.length <= 1) return topLevel;
737
745
  const command = words[0];
746
+ if (command === "update") return ["check", "status", "enable", "disable"];
738
747
  if (command === "support") return ["protocol", "offer", "shown", "release", "dismiss", "snooze", "enable", "status"];
739
748
  if (command === "html") return ["catalog", "scaffold", "init", "render", "still", "preview", "deliver"];
740
749
  if (command === "style") return ["list", "show"];
@@ -23,6 +23,8 @@ import { basename, dirname, join } from "node:path";
23
23
 
24
24
  import { z } from "zod";
25
25
 
26
+ import { verifyStableBrowserWatcherBaseline } from "./html-overlay-watcher-baseline";
27
+
26
28
  import {
27
29
  chromium,
28
30
  type Browser,
@@ -2020,6 +2022,8 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2020
2022
  signal,
2021
2023
  );
2022
2024
  browserRuntimeSnapshot = preparedBrowserRuntime;
2025
+ let watcherVersion = 0;
2026
+ let watcherFailure: Error | undefined;
2023
2027
  const runtimeName = basename(preparedBrowserRuntime.runtimeRoot);
2024
2028
  const snapshotName = basename(preparedBrowserRuntime.directory);
2025
2029
  const anchorWatcher = watch(
@@ -2028,11 +2032,13 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2028
2032
  (eventType, filename) => {
2029
2033
  const path = filename?.toString() ?? "<snapshot-anchor>";
2030
2034
  if (path === snapshotName) {
2035
+ watcherVersion += 1;
2031
2036
  browserRuntimeMutations.add(`anchor-${eventType}:${path}`);
2032
2037
  }
2033
2038
  },
2034
2039
  );
2035
2040
  anchorWatcher.on("error", error => {
2041
+ watcherFailure = error;
2036
2042
  browserRuntimeMutations.add(`anchor-watch-error:${error.message}`);
2037
2043
  });
2038
2044
  browserRuntimeWatchers.push(anchorWatcher);
@@ -2065,6 +2071,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2065
2071
  // its exact path identity still detects fixture substitution.
2066
2072
  return;
2067
2073
  }
2074
+ watcherVersion += 1;
2068
2075
  if (path === runtimeName || path.startsWith(`${runtimeName}/`)) {
2069
2076
  browserRuntimeMutations.add(`${eventType}:${path}`);
2070
2077
  return;
@@ -2073,41 +2080,48 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2073
2080
  },
2074
2081
  );
2075
2082
  containerWatcher.on("error", error => {
2083
+ watcherFailure = error;
2076
2084
  browserRuntimeMutations.add(`watch-error:${error.message}`);
2077
2085
  });
2078
2086
  browserRuntimeWatchers.push(containerWatcher);
2079
- await new Promise<void>(resolve => setTimeout(resolve, 25));
2080
- if (signal.aborted) throw cancellationReason(signal);
2081
- // FSEvents can deliver the just-created direct-child notification after
2082
- // both watches attach. Clear that bounded baseline before re-verifying
2083
- // every manifest and path identity. A swap during this window changes
2084
- // the already-captured container ctime and therefore fails below.
2085
- browserRuntimeMutations.clear();
2086
2087
  let browser: Browser | undefined;
2087
2088
  let browserFailure: Readonly<{ error: unknown }> | undefined;
2088
2089
  try {
2089
- await verifyBrowserRuntimeSnapshot(
2090
- preparedBrowserRuntime,
2091
- request.browserRuntime,
2092
- "immediately before launch",
2093
- signal,
2094
- );
2095
- await assertBrowserRuntimeSnapshotIdentity(
2096
- preparedBrowserRuntime,
2097
- request.browserRuntime.manifest.entries,
2098
- "immediately before launch",
2099
- signal,
2100
- );
2101
- await assertBrowserRuntimeSnapshotContainerIdentity(
2102
- preparedBrowserRuntime,
2103
- "immediately before launch",
2090
+ // Delayed preparation notifications can arrive during full verification.
2091
+ // Recheck the SAME pre-wait identities until one pass is stable. Nothing
2092
+ // after the synchronous baseline commit is discarded.
2093
+ await verifyStableBrowserWatcherBaseline({
2104
2094
  signal,
2105
- );
2106
- await assertBrowserRuntimeSnapshotAnchorIdentity(
2107
- preparedBrowserRuntime,
2108
- "immediately before launch",
2109
- signal,
2110
- );
2095
+ version: () => watcherVersion,
2096
+ failure: () => watcherFailure,
2097
+ quietMs: process.platform === "darwin" ? 2_000 : 25,
2098
+ deadlineMs: 10_000,
2099
+ verify: async () => {
2100
+ await verifyBrowserRuntimeSnapshot(
2101
+ preparedBrowserRuntime,
2102
+ request.browserRuntime,
2103
+ "immediately before launch",
2104
+ signal,
2105
+ );
2106
+ await assertBrowserRuntimeSnapshotIdentity(
2107
+ preparedBrowserRuntime,
2108
+ request.browserRuntime.manifest.entries,
2109
+ "immediately before launch",
2110
+ signal,
2111
+ );
2112
+ await assertBrowserRuntimeSnapshotContainerIdentity(
2113
+ preparedBrowserRuntime,
2114
+ "immediately before launch",
2115
+ signal,
2116
+ );
2117
+ await assertBrowserRuntimeSnapshotAnchorIdentity(
2118
+ preparedBrowserRuntime,
2119
+ "immediately before launch",
2120
+ signal,
2121
+ );
2122
+ },
2123
+ commit: () => { browserRuntimeMutations.clear(); },
2124
+ });
2111
2125
  discardProvenLaunchManagedRootMetadataEvent(
2112
2126
  browserRuntimeMutations,
2113
2127
  runtimeName,
@@ -0,0 +1,65 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ interface BaselineClock {
4
+ readonly now: () => number;
5
+ readonly wait: (milliseconds: number, signal: AbortSignal) => Promise<void>;
6
+ }
7
+
8
+ const clock: BaselineClock = {
9
+ now: () => performance.now(),
10
+ wait: async (milliseconds, signal) => { await delay(milliseconds, undefined, { signal }); },
11
+ };
12
+
13
+ /** Drain asynchronous preparation notifications before revalidating the saved identities. */
14
+ interface BrowserWatcherBaselineOptions {
15
+ readonly signal: AbortSignal;
16
+ readonly version: () => number;
17
+ readonly failure: () => Error | undefined;
18
+ readonly quietMs: number;
19
+ readonly deadlineMs: number;
20
+ readonly clock?: BaselineClock;
21
+ }
22
+
23
+ export async function settleBrowserWatcherBaseline(options: BrowserWatcherBaselineOptions): Promise<void> {
24
+ const timing = options.clock ?? clock;
25
+ const started = timing.now();
26
+ let lastActivity = started;
27
+ let observedVersion = options.version();
28
+ for (;;) {
29
+ options.signal.throwIfAborted();
30
+ const failure = options.failure();
31
+ if (failure !== undefined) throw failure;
32
+ const now = timing.now();
33
+ const version = options.version();
34
+ if (version !== observedVersion) {
35
+ observedVersion = version;
36
+ lastActivity = now;
37
+ }
38
+ if (now - started >= options.deadlineMs) {
39
+ throw new Error("Browser runtime filesystem notifications did not settle before launch.");
40
+ }
41
+ if (now - lastActivity >= options.quietMs) return;
42
+ await timing.wait(Math.min(50, options.quietMs - (now - lastActivity), options.deadlineMs - (now - started)), options.signal);
43
+ }
44
+ }
45
+
46
+ /** Every discarded notification batch must precede a stable, complete identity check. */
47
+ export async function verifyStableBrowserWatcherBaseline(options: BrowserWatcherBaselineOptions & {
48
+ readonly verify: () => Promise<void>;
49
+ readonly commit: () => void;
50
+ }): Promise<void> {
51
+ for (let pass = 0; pass < 3; pass += 1) {
52
+ await settleBrowserWatcherBaseline(options);
53
+ const version = options.version();
54
+ await options.verify();
55
+ options.signal.throwIfAborted();
56
+ const failure = options.failure();
57
+ if (failure !== undefined) throw failure;
58
+ if (options.version() === version) {
59
+ // No await may separate the stable-generation check from arming the guard.
60
+ options.commit();
61
+ return;
62
+ }
63
+ }
64
+ throw new Error("Browser runtime filesystem notifications changed during all three verification passes.");
65
+ }
@@ -0,0 +1,18 @@
1
+ export function slopcameraUpdatePolicy(argv: readonly string[]): { effectFree: boolean; offline: boolean } {
2
+ const [command, action] = argv;
3
+ const portable = ["diagram", "mcp", "canvas", "skill"].includes(command ?? "")
4
+ || (command === "style" && !argv.includes("--help") && !argv.includes("-h"))
5
+ || (command === "code" && ["search", "execute"].includes(action ?? ""))
6
+ || (command === "media" && action === "soundtrack")
7
+ || (command === "image" && ["vectorize", "icon", "gallery"].includes(action ?? ""))
8
+ || (command === "image" && action === "generate" && argv.includes("--output"));
9
+ const registry = ["status", "tui", "commands", "outputs", "legacy", "support"].includes(command ?? "");
10
+ return {
11
+ effectFree: command === undefined || ["help", "--help", "-h", "version", "--version", "__complete", "capabilities"].includes(command)
12
+ || (command === "diagram" && ["help", "--help", "-h"].includes(action ?? ""))
13
+ || (!portable && !registry && (argv.includes("--help") || argv.includes("-h"))),
14
+ offline: command === "diagram"
15
+ || (command === "image" && ["vectorize", "icon"].includes(action ?? ""))
16
+ || (command === "media" && action === "soundtrack"),
17
+ };
18
+ }
@@ -0,0 +1,76 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const repository = "hraness/slopcamera";
8
+ const maximumMetadataBytes = 2 * 1024 * 1024;
9
+ type Gh = (args: readonly string[]) => Promise<Buffer>;
10
+
11
+ const runGh: Gh = args => new Promise((resolve, reject) => {
12
+ execFile("gh", [...args], {
13
+ encoding: "buffer", timeout: 30_000, maxBuffer: maximumMetadataBytes,
14
+ env: { ...process.env, GH_HOST: "github.com", GH_PROMPT_DISABLED: "1", GH_PAGER: "cat" },
15
+ }, (error, stdout) => {
16
+ // gh's error can include authenticated response bodies. Keep diagnostics fixed.
17
+ if (error !== null) reject(new Error("SlopCamera release verification failed. Check GitHub CLI access and retry slopcamera update."));
18
+ else resolve(stdout);
19
+ });
20
+ });
21
+
22
+ function record(value: unknown): Record<string, unknown> {
23
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid SlopCamera release metadata.");
24
+ return value as Record<string, unknown>;
25
+ }
26
+
27
+ /** Keep the install guide's immutable-release and source-bound attestation checks. */
28
+ export async function verifySlopcameraUpdate(
29
+ artifact: { path: string; version: string; sha256: string },
30
+ gh: Gh = runGh,
31
+ ): Promise<void> {
32
+ if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u.test(artifact.version)
33
+ || artifact.version.length > 50 || !/^[a-f0-9]{64}$/u.test(artifact.sha256)) throw new Error("Invalid SlopCamera update identity.");
34
+ const tag = `v${artifact.version}`;
35
+ const json = async (path: string) => {
36
+ const bytes = await gh(["api", path]);
37
+ if (bytes.length > maximumMetadataBytes) throw new Error("SlopCamera release metadata is too large.");
38
+ return record(JSON.parse(bytes.toString("utf8")) as unknown);
39
+ };
40
+ await gh(["release", "verify", tag, "--repo", repository]);
41
+ await gh(["release", "verify-asset", tag, artifact.path, "--repo", repository]);
42
+ const reference = record((await json(`repos/${repository}/git/ref/tags/${tag}`)).object);
43
+ if (reference.type !== "tag" || typeof reference.sha !== "string" || !/^[a-f0-9]{40}$/u.test(reference.sha)) {
44
+ throw new Error("SlopCamera updates require an annotated release tag.");
45
+ }
46
+ const annotated = await json(`repos/${repository}/git/tags/${reference.sha}`);
47
+ const source = record(annotated.object);
48
+ if (annotated.tag !== tag || source.type !== "commit" || typeof source.sha !== "string" || !/^[a-f0-9]{40}$/u.test(source.sha)) {
49
+ throw new Error("SlopCamera release tag does not identify its source commit.");
50
+ }
51
+ const release = await json(`repos/${repository}/releases/tags/${tag}`);
52
+ if (release.tag_name !== tag || release.immutable !== true || !Array.isArray(release.assets)) {
53
+ throw new Error("SlopCamera release is not immutable.");
54
+ }
55
+ const bundles = release.assets.map(record).filter(asset => asset.name === "provenance.jsonl");
56
+ const bundle = bundles[0];
57
+ if (bundles.length !== 1 || bundle === undefined || bundle.state !== "uploaded"
58
+ || typeof bundle.id !== "number" || !Number.isSafeInteger(bundle.id) || bundle.id <= 0
59
+ || typeof bundle.size !== "number" || !Number.isSafeInteger(bundle.size) || bundle.size < 1 || bundle.size > maximumMetadataBytes
60
+ || typeof bundle.digest !== "string" || !/^sha256:[a-f0-9]{64}$/u.test(bundle.digest)) {
61
+ throw new Error("SlopCamera release lacks a checked provenance bundle.");
62
+ }
63
+ const bytes = await gh(["api", `repos/${repository}/releases/assets/${bundle.id}`, "--header", "Accept: application/octet-stream"]);
64
+ if (bytes.length !== bundle.size || `sha256:${createHash("sha256").update(bytes).digest("hex")}` !== bundle.digest) {
65
+ throw new Error("SlopCamera provenance bundle differs from the immutable release.");
66
+ }
67
+ const directory = await mkdtemp(join(tmpdir(), "slopcamera-update-verification-"));
68
+ try {
69
+ const path = join(directory, "provenance.jsonl");
70
+ await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
71
+ await gh(["attestation", "verify", artifact.path, "--repo", repository,
72
+ "--signer-workflow", `${repository}/.github/workflows/release.yml`,
73
+ "--signer-digest", source.sha, "--source-digest", source.sha,
74
+ "--source-ref", `refs/tags/${tag}`, "--deny-self-hosted-runners", "--bundle", path]);
75
+ } finally { await rm(directory, { recursive: true, force: true }); }
76
+ }
@@ -21,6 +21,11 @@ export interface VerbRow {
21
21
  const row = (words: string, opClass: OpClass, summary: string): VerbRow => ({ path: words.split(" "), opClass, summary });
22
22
 
23
23
  export const CLI_VERBS: readonly VerbRow[] = [
24
+ row("update", "operate", "Install a verified newer CLI release"),
25
+ row("update check", "operate", "Check release availability and save the check time"),
26
+ row("update status", "read", "Show installation support and update preferences"),
27
+ row("update enable", "operate", "Enable automatic CLI updates"),
28
+ row("update disable", "operate", "Keep the installed CLI version"),
24
29
  row("capabilities", "read", "List what this build can do and how well each part is tested"),
25
30
  row("doctor", "read", "Check local render tools, asset caches and old login items"),
26
31
  row("operations list", "read", "List the operations agents can run and their limits"),