@hraness/slopcamera 3.2.8 → 3.3.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.
Files changed (150) hide show
  1. package/README.md +29 -9
  2. package/apps/desktop/application/context.ts +6 -0
  3. package/apps/desktop/application/default-registry.ts +26 -0
  4. package/apps/desktop/application/operation.ts +14 -1
  5. package/apps/desktop/application/operations/index.ts +4 -0
  6. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  7. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  8. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  9. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  10. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  11. package/apps/desktop/application/registry.ts +2 -2
  12. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  13. package/apps/desktop/application/spatial-assets.ts +160 -31
  14. package/apps/desktop/application/spatial-render.ts +217 -20
  15. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  16. package/apps/desktop/application/spatial-review.ts +361 -0
  17. package/apps/desktop/application/spatial-spz.ts +48 -3
  18. package/apps/desktop/application/spatial-world-import.ts +26 -5
  19. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  20. package/apps/desktop/cli/args.ts +514 -11
  21. package/apps/desktop/cli/capability-manifest.ts +374 -0
  22. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  23. package/apps/desktop/cli/command-host-resources.ts +29 -1
  24. package/apps/desktop/cli/commands.ts +1188 -4
  25. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  26. package/apps/desktop/cli/help.ts +172 -6
  27. package/apps/desktop/cli/main.ts +3 -3
  28. package/apps/desktop/cli/portable-surface.ts +6 -1
  29. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  30. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  31. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  32. package/apps/desktop/cli/spatial-scene-service.ts +268 -4
  33. package/apps/desktop/cli/workflow-code.ts +1 -1
  34. package/apps/desktop/code/semantic-builder.ts +24 -0
  35. package/apps/desktop/contracts/cinema.ts +736 -0
  36. package/apps/desktop/contracts/index.ts +1 -0
  37. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  38. package/apps/desktop/contracts/spatial-world.ts +26 -3
  39. package/apps/desktop/core/cinema-plan.ts +1320 -0
  40. package/apps/desktop/core/index.ts +1 -0
  41. package/apps/desktop/core/storage.ts +75 -0
  42. package/apps/desktop/dist/cli/main.js +725 -273
  43. package/apps/desktop/html-overlay/spatial.ts +619 -57
  44. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  45. package/apps/desktop/workflows/index.ts +6 -0
  46. package/dist/cli.js +25 -429
  47. package/dist/code/advanced.js +1 -269
  48. package/dist/code/index.js +16 -2239
  49. package/dist/generate.js +1 -23
  50. package/dist/host-resources.js +1 -25
  51. package/dist/index-0bbesn4m.js +35 -0
  52. package/dist/index-296qhpf2.js +3 -0
  53. package/dist/index-3cxf2c58.js +3 -0
  54. package/dist/index-77fjfg9f.js +3 -0
  55. package/dist/index-80a36bc0.js +3 -0
  56. package/dist/index-a3vvs3rf.js +3 -0
  57. package/dist/index-b3geqczn.js +5 -0
  58. package/dist/index-cgpkyjqa.js +3 -0
  59. package/dist/index-f0ptv8dm.js +7 -0
  60. package/dist/index-grk47tgs.js +3 -0
  61. package/dist/index-qry58nj2.js +10 -0
  62. package/dist/index.js +1 -226
  63. package/dist/operations.js +1 -28
  64. package/dist/skill-install-gx7afxsd.js +2 -0
  65. package/dist/vectorize/worker.js +1 -141
  66. package/dist/workflow.js +1 -15
  67. package/docs/README.md +5 -1
  68. package/package.json +7 -5
  69. package/skills/slopcamera/SKILL.md +6 -1
  70. package/skills/slopcamera/references/directed-scenes.md +22 -2
  71. package/skills/slopcamera/references/gateway-media.md +15 -1
  72. package/skills/slopcamera/references/image-galleries.md +166 -0
  73. package/skills/slopcamera/references/install.md +1 -1
  74. package/skills/slopcamera/references/parametric-design.md +14 -0
  75. package/skills/slopcamera/references/scene-building.md +32 -0
  76. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  77. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  78. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  79. package/src/capability-manifest.ts +361 -0
  80. package/src/cli.ts +222 -4
  81. package/src/code/canonical-json.ts +9 -0
  82. package/src/generate.ts +2 -2
  83. package/src/icon.ts +1111 -0
  84. package/src/image-gallery.ts +1049 -0
  85. package/src/index.ts +11 -0
  86. package/src/mcp/boundary.ts +11 -11
  87. package/src/mcp/index.ts +3 -0
  88. package/src/mcp/server.ts +2 -1
  89. package/src/mcp/tools.ts +1642 -130
  90. package/src/mcp/types.ts +19 -0
  91. package/src/operations.ts +412 -0
  92. package/src/portable-capability-manifest.ts +67 -0
  93. package/src/scene-gallery.ts +184 -0
  94. package/src/spatial-scene/asset-admission.ts +183 -0
  95. package/src/spatial-scene/audit-rendered.ts +692 -0
  96. package/src/spatial-scene/audit.ts +617 -0
  97. package/src/spatial-scene/behavior-audit.ts +252 -0
  98. package/src/spatial-scene/behavior-bake.ts +233 -0
  99. package/src/spatial-scene/behavior-fns.ts +585 -0
  100. package/src/spatial-scene/behavior-gallery.ts +109 -0
  101. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  102. package/src/spatial-scene/behavior-trace.ts +325 -0
  103. package/src/spatial-scene/behavior.ts +618 -0
  104. package/src/spatial-scene/build.ts +632 -0
  105. package/src/spatial-scene/camera-rig.ts +125 -0
  106. package/src/spatial-scene/camera-track.ts +4 -2
  107. package/src/spatial-scene/character.ts +124 -0
  108. package/src/spatial-scene/contracts.ts +75 -7
  109. package/src/spatial-scene/design-templates.ts +351 -0
  110. package/src/spatial-scene/design.ts +327 -0
  111. package/src/spatial-scene/direction-compile.ts +584 -0
  112. package/src/spatial-scene/direction.ts +122 -0
  113. package/src/spatial-scene/effects.ts +149 -0
  114. package/src/spatial-scene/evaluate.ts +109 -29
  115. package/src/spatial-scene/gallery.ts +277 -0
  116. package/src/spatial-scene/generate.ts +239 -0
  117. package/src/spatial-scene/geometry-native.ts +165 -0
  118. package/src/spatial-scene/geometry.ts +1216 -0
  119. package/src/spatial-scene/gltf.ts +600 -77
  120. package/src/spatial-scene/identity.ts +55 -2
  121. package/src/spatial-scene/index.ts +36 -0
  122. package/src/spatial-scene/inspect.ts +35 -6
  123. package/src/spatial-scene/material-lighting.ts +609 -0
  124. package/src/spatial-scene/math.ts +21 -4
  125. package/src/spatial-scene/motion-evidence.ts +88 -0
  126. package/src/spatial-scene/parametric.ts +765 -0
  127. package/src/spatial-scene/particle-preparation.ts +183 -0
  128. package/src/spatial-scene/particle.ts +195 -0
  129. package/src/spatial-scene/patch.ts +109 -15
  130. package/src/spatial-scene/performance.ts +1613 -0
  131. package/src/spatial-scene/probe.ts +137 -0
  132. package/src/spatial-scene/recipe-pack.ts +86 -0
  133. package/src/spatial-scene/render-effects.ts +240 -0
  134. package/src/spatial-scene/review.ts +639 -0
  135. package/src/spatial-scene/simulation-bake.ts +206 -0
  136. package/src/spatial-scene/simulation.ts +187 -0
  137. package/src/spatial-scene/solve.ts +366 -0
  138. package/src/spatial-scene/temporal-audit.ts +354 -0
  139. package/src/version.ts +1 -1
  140. package/dist/index-42zsesc1.js +0 -2257
  141. package/dist/index-7131pg1c.js +0 -1820
  142. package/dist/index-7308egqr.js +0 -48
  143. package/dist/index-8txs6fkn.js +0 -712
  144. package/dist/index-fava6pge.js +0 -192
  145. package/dist/index-h1k0fnjq.js +0 -2363
  146. package/dist/index-r7gdhmsp.js +0 -424
  147. package/dist/index-sh6xbav6.js +0 -1219
  148. package/dist/index-z1w83f81.js +0 -4
  149. package/dist/index-zfnddgay.js +0 -2029
  150. package/dist/skill-install-dh0ntqba.js +0 -10
@@ -1,13 +1,49 @@
1
1
  import { reportUsefulResult, type UsefulResultObserver } from "../../../src/support-completion";
2
+ import { SlopcameraCloudError } from "../../../src/cloud-errors";
3
+ import {
4
+ composeSlopcameraImageGallery,
5
+ parseSlopcameraGalleryVary,
6
+ planSlopcameraGallery,
7
+ slopcameraGalleryLimits,
8
+ type SlopcameraGalleryCandidate,
9
+ type SlopcameraGalleryKind,
10
+ type SlopcameraGalleryPlan,
11
+ type SlopcameraGalleryResolvedCandidate,
12
+ } from "../../../src/image-gallery";
13
+ import {
14
+ galleryProbeRenderRequest,
15
+ galleryProbeScene,
16
+ type SlopcameraGalleryProbeMode,
17
+ } from "../../../src/spatial-scene/probe";
18
+ import {
19
+ planSlopcameraSceneGallery,
20
+ slopcameraSceneGalleryLimits,
21
+ type SlopcameraSceneGalleryVariant,
22
+ } from "../../../src/scene-gallery";
23
+ import {
24
+ parseSpatialScene,
25
+ spatialSceneSha256,
26
+ } from "../../../src/spatial-scene/identity";
27
+ import {
28
+ SPATIAL_SCENE_LIMITS,
29
+ type SpatialSceneV1,
30
+ } from "../../../src/spatial-scene/contracts";
2
31
  import { createDirectingBlobSession } from "./directing-blob";
3
32
  import { assembleDirectingClips, extractDirectingEndpoint, importDirectingAnchor } from "./directing-media";
4
33
  import { executeDirectingCommand } from "./directing-service";
5
34
  import { createHash, randomUUID } from "node:crypto";
6
- import { executeSpatialSceneCommand } from "./spatial-scene-service";
35
+ import sharp from "sharp";
36
+ import { executeSpatialAssetCommand } from "./spatial-asset-service";
37
+ import { executeSpatialDesignCommand } from "./spatial-design-service";
38
+ import {
39
+ executeSpatialSceneCommand,
40
+ publishSpatialSource,
41
+ readSpatialJson,
42
+ } from "./spatial-scene-service";
7
43
  import { executeSpatialProjectCommand } from "./spatial-project-service";
8
44
  import { executeSpatialWorldCommand } from "./spatial-world-service";
9
45
  import { constants } from "node:fs";
10
- import { link, lstat, open, realpath, rm } from "node:fs/promises";
46
+ import { link, lstat, mkdir, open, realpath, rm } from "node:fs/promises";
11
47
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
12
48
  import {
13
49
  createDefaultHostResourceCoordinator,
@@ -21,6 +57,9 @@ import {
21
57
  AnalyzerEvidenceV1Schema,
22
58
  AlignmentCandidateIdSchema,
23
59
  AudioAlignmentAnalysisV1Schema,
60
+ CinemaContinuityReportSchema,
61
+ CinemaPlanIdSchema,
62
+ CinemaRenderReceiptV1Schema,
24
63
  EventKindSchema,
25
64
  MusicAnalysisV1Schema,
26
65
  OverlayOperationSchema,
@@ -50,12 +89,18 @@ import {
50
89
  applyProjectInactivityPlan,
51
90
  analyzeAudioAlignment,
52
91
  applyAudioAlignmentCandidate,
92
+ analyzeCinemaPlan,
53
93
  addZoom,
94
+ assertCinemaPlanComposition,
95
+ auditCinemaContinuity,
96
+ auditCinemaTemporal,
54
97
  buildProjectOutputTimeMap,
55
98
  buildSourceTimeMap,
56
99
  canonicalSlopcameraPersistenceDocument,
57
100
  canonicalJson,
101
+ compileCinemaSequence,
58
102
  compileProjectRenderPlan,
103
+ createCinemaPlanScaffold,
59
104
  compileRenderPlan,
60
105
  createNodeBundleFileSystem,
61
106
  createDefaultEditPlan,
@@ -70,11 +115,17 @@ import {
70
115
  normalizeProjectEditPlan,
71
116
  planAutomaticInactivityCuts,
72
117
  planAutomaticZooms,
118
+ planCinemaGallery,
73
119
  projectFillerCut,
74
120
  removeOverlay,
75
121
  removeZoom,
76
122
  saveEditPlan,
77
123
  saveAnalysisArtifact,
124
+ saveImmutableText,
125
+ loadProjectCinemaPlan,
126
+ projectCinemaRenderPlanPath,
127
+ saveProjectCinemaPlan,
128
+ saveProjectCinemaRevision,
78
129
  saveProjectEditPlan,
79
130
  setSpeed,
80
131
  rebaseProjectEditPlan,
@@ -133,6 +184,7 @@ import {
133
184
  import { resolveEmojiAsset, searchEmojiAssets, inspectEmojiAssets } from "./emoji-assets";
134
185
  import { asCliError, CliError, EXIT_CODE } from "./errors";
135
186
  import { commandHelp, completions } from "./help";
187
+ import { createLocalSlopcameraCapabilityManifest } from "./capability-manifest";
136
188
  import { PlaywrightHtmlOverlayRenderer } from "./html-overlay-renderer";
137
189
  import { executeHtmlSceneCommand } from "./html-scene";
138
190
  import { BunProcessRunner, processIo, writeJson, writeLine, type CliIo, type ProcessRunner } from "./io";
@@ -178,6 +230,7 @@ import {
178
230
  } from "./project-service";
179
231
  import { resolveVerifiedProjectMedia } from "./project-media-integrity";
180
232
  import { buildFfmpegInvocation, prepareOverlaySources } from "./renderer";
233
+ import { buildCinemaFfmpegInvocation } from "./cinema-renderer";
181
234
  import { buildProjectFfmpegInvocation } from "./project-renderer";
182
235
  import {
183
236
  DEFAULT_PROJECT_INACTIVITY_CONFIG,
@@ -186,6 +239,7 @@ import {
186
239
  import { resolveAnimatedPlaybackWindow } from "./overlay-playback";
187
240
  import { analyzeProjectScenes } from "./scene-analysis-service";
188
241
  import type { SceneDescriptionProvider } from "@hraness/slopcamera/scene";
242
+ import type { SpatialReviewProvider } from "../../../src/spatial-scene/review";
189
243
  import { parseCliTime } from "./time";
190
244
  import {
191
245
  DEFAULT_FACE_ANALYSIS_CONFIG,
@@ -214,6 +268,10 @@ import {
214
268
  createGatewaySceneProvider,
215
269
  type GatewaySceneProviderOptions,
216
270
  } from "./gateway-scene-provider";
271
+ import {
272
+ createGatewaySpatialReviewProvider,
273
+ type GatewaySpatialReviewProviderOptions,
274
+ } from "./gateway-review-provider";
217
275
  import {
218
276
  GatewayMediaCatalogError,
219
277
  createFileGatewayMediaCatalogSnapshotStore,
@@ -297,7 +355,7 @@ import {
297
355
  workflowRunStore,
298
356
  } from "./workflow-runs";
299
357
 
300
- export const SLOPCAMERA_VERSION = "3.2.8";
358
+ export const SLOPCAMERA_VERSION = "3.3.1";
301
359
 
302
360
  // Legacy direct renders predate per-target output contracts. Keep them
303
361
  // bounded generously enough for long-form production while preventing one
@@ -319,8 +377,36 @@ export interface CliDependencies {
319
377
  readonly paths?: RepositoryPaths;
320
378
  readonly runner?: ProcessRunner;
321
379
  readonly sceneProviderFactory?: (options: GatewaySceneProviderOptions) => SceneDescriptionProvider;
380
+ readonly spatialReviewProviderFactory?: (options: GatewaySpatialReviewProviderOptions) => SpatialReviewProvider;
322
381
  readonly sleep?: (milliseconds: number) => Promise<void>;
323
382
  readonly version?: string;
383
+ /**
384
+ * Test/review seam for gallery scene renders (`ai image gallery
385
+ * --preview probe` and `ai scene gallery`): renders one bounded scene
386
+ * source into a still. The production default runs the real
387
+ * `scene.render` operation.
388
+ */
389
+ readonly renderGalleryScene?: (input: {
390
+ readonly application: ApplicationContext;
391
+ readonly assets?: readonly {
392
+ readonly assetId: string;
393
+ readonly artifact: {
394
+ readonly bytes: number;
395
+ readonly path: string;
396
+ readonly sha256: string;
397
+ };
398
+ }[];
399
+ readonly request?: unknown;
400
+ readonly sceneSha256?: string;
401
+ readonly source: { readonly path: string };
402
+ }) => Promise<{
403
+ readonly artifact: {
404
+ readonly bytes: number;
405
+ readonly path: string;
406
+ readonly sha256: string;
407
+ };
408
+ readonly receipt?: { readonly path: string };
409
+ }>;
324
410
  }
325
411
 
326
412
  interface CommandContext {
@@ -345,8 +431,10 @@ interface CommandContext {
345
431
  readonly paths: RepositoryPaths;
346
432
  readonly runner: ProcessRunner;
347
433
  readonly sceneProviderFactory: (options: GatewaySceneProviderOptions) => SceneDescriptionProvider;
434
+ readonly spatialReviewProviderFactory: (options: GatewaySpatialReviewProviderOptions) => SpatialReviewProvider;
348
435
  readonly sleep: (milliseconds: number) => Promise<void>;
349
436
  readonly version: string;
437
+ readonly renderGalleryScene?: CliDependencies["renderGalleryScene"];
350
438
  }
351
439
 
352
440
  function contextWithHostResourceLease(
@@ -567,6 +655,13 @@ function applicationContext(
567
655
  machineStateRoot: context.stateRoot,
568
656
  paths: context.paths,
569
657
  runner: context.runner,
658
+ // The review provider is inert until scene.review reaches its consent gate;
659
+ // the credential loader runs only inside the single paid dispatch.
660
+ spatialReviewProvider: context.spatialReviewProviderFactory({
661
+ credential: () => loadGatewayCredential(context.io.env),
662
+ fetch: context.fetch,
663
+ transport: context.gatewayCatalogTransport,
664
+ }),
570
665
  };
571
666
  return {
572
667
  ...application,
@@ -671,6 +766,16 @@ function writeValue(io: CliIo, json: boolean, value: unknown, human: () => strin
671
766
  else writeLine(io, human());
672
767
  }
673
768
 
769
+ function writeCapabilityManifest(io: CliIo, json: boolean, toolVersion: string): void {
770
+ const manifest = createLocalSlopcameraCapabilityManifest(
771
+ createApplicationOperationRegistry({ toolVersion }),
772
+ toolVersion,
773
+ );
774
+ writeValue(io, json, manifest, () => manifest.modules.map(module => (
775
+ `${module.moduleId}@${module.version}\t${module.trust}\toperations=${module.operations.length} workflows=${module.workflows.length} tools=${module.tools.length}\t${module.title}`
776
+ )).join("\n"));
777
+ }
778
+
674
779
  function isWithin(root: string, candidate: string): boolean {
675
780
  const pathRelative = relative(root, candidate);
676
781
  return pathRelative === "" || (!pathRelative.startsWith("..") && !isAbsolute(pathRelative));
@@ -2410,6 +2515,227 @@ async function handleProjectRender(
2410
2515
  writeValue(context.io, command.json, completed, () => `rendered ${outputRelative}; plan=${planArtifactPath}`);
2411
2516
  }
2412
2517
 
2518
+ async function handleProjectCinema(
2519
+ context: CommandContext,
2520
+ command: Extract<CliCommand, { readonly kind: "project-cinema" }>,
2521
+ ): Promise<void> {
2522
+ const project = await openProject(context.paths.projectRoot, command.project);
2523
+ const edit = await loadCurrentProjectPlan(project);
2524
+ const timestamp = context.io.now().toISOString();
2525
+
2526
+ if (command.action === "init") {
2527
+ const existing = await loadProjectCinemaPlan(project.fileSystem);
2528
+ if (existing !== null && !command.force) {
2529
+ throw new CliError(
2530
+ "conflict",
2531
+ "Project already has a cinema sidecar. Pass --force to replace it.",
2532
+ );
2533
+ }
2534
+ let plan;
2535
+ try {
2536
+ plan = createCinemaPlanScaffold(
2537
+ project.project,
2538
+ edit,
2539
+ CinemaPlanIdSchema.parse(`cinema_${randomUUID().replaceAll("-", "").slice(0, 16)}`),
2540
+ timestamp,
2541
+ );
2542
+ } catch (error) {
2543
+ if (error instanceof TypeError) throw new CliError("conflict", error.message);
2544
+ throw error;
2545
+ }
2546
+ const contents = `${canonicalJson(plan)}\n`;
2547
+ await saveProjectCinemaPlan(project.fileSystem, plan);
2548
+ const revisionPath = await saveProjectCinemaRevision(
2549
+ project.fileSystem,
2550
+ contents,
2551
+ sha256Hex(contents),
2552
+ );
2553
+ writeValue(context.io, command.json, {
2554
+ cinemaPlanSha256: plan.cinemaPlanSha256,
2555
+ plan,
2556
+ planPath: "cinema/current.json",
2557
+ revisionPath,
2558
+ shots: plan.shots.length,
2559
+ }, () => `cinema sidecar ${plan.cinemaPlanSha256} with ${plan.shots.length} shots; revision=${revisionPath}`);
2560
+ return;
2561
+ }
2562
+
2563
+ const cinema = await loadProjectCinemaPlan(project.fileSystem);
2564
+ if (cinema === null) {
2565
+ throw new CliError(
2566
+ "not-found",
2567
+ "Project has no cinema sidecar. Run slopcamera project cinema init first.",
2568
+ );
2569
+ }
2570
+ try {
2571
+ assertCinemaPlanComposition(cinema);
2572
+ } catch (error) {
2573
+ if (error instanceof TypeError) throw new CliError("conflict", error.message);
2574
+ throw error;
2575
+ }
2576
+
2577
+ if (command.action === "check") {
2578
+ const analysis = analyzeCinemaPlan(cinema, project.project, edit);
2579
+ const audit = auditCinemaContinuity(cinema);
2580
+ const findings = [...analysis.findings, ...audit.findings].sort((left, right) => (
2581
+ left.timeUs - right.timeUs
2582
+ || left.code.localeCompare(right.code)
2583
+ || left.shotIds.join("").localeCompare(right.shotIds.join(""))
2584
+ ));
2585
+ const report = CinemaContinuityReportSchema.parse({
2586
+ cinemaPlanSha256: cinema.cinemaPlanSha256,
2587
+ findings,
2588
+ kind: "slopcamera.cinema-continuity-report",
2589
+ schemaVersion: 1,
2590
+ });
2591
+ const contents = `${canonicalJson(report)}\n`;
2592
+ const auditPath = `cinema/audits/${sha256Hex(contents)}.json`;
2593
+ await saveImmutableText(project.fileSystem, auditPath, contents, sha256Hex(contents));
2594
+ const errors = findings.filter(finding => finding.severity === "error").length;
2595
+ writeValue(context.io, command.json, {
2596
+ auditPath,
2597
+ findings,
2598
+ ok: errors === 0,
2599
+ stale: findings.some(finding => finding.code === "stale-sidecar"),
2600
+ }, () => `${findings.length} findings (${errors} errors); audit=${auditPath}`);
2601
+ if (errors > 0) {
2602
+ throw new CliError(
2603
+ "conflict",
2604
+ `Cinema sidecar has ${errors} blocking findings; see ${auditPath}.`,
2605
+ );
2606
+ }
2607
+ return;
2608
+ }
2609
+
2610
+ if (command.action === "audit") {
2611
+ const report = auditCinemaTemporal(cinema, project.project, edit);
2612
+ const contents = `${canonicalJson(report)}\n`;
2613
+ const auditPath = `cinema/audits/${sha256Hex(contents)}.json`;
2614
+ await saveImmutableText(project.fileSystem, auditPath, contents, sha256Hex(contents));
2615
+ const warnings = report.findings.filter(finding => finding.severity !== "advisory").length;
2616
+ writeValue(context.io, command.json, {
2617
+ auditPath,
2618
+ findings: report.findings,
2619
+ metrics: report.metrics,
2620
+ }, () => `${report.findings.length} temporal findings (${warnings} warnings); audit=${auditPath}`);
2621
+ return;
2622
+ }
2623
+
2624
+ if (command.action === "gallery") {
2625
+ const gallery = planCinemaGallery(cinema, command.axis);
2626
+ const contents = `${canonicalJson(gallery)}\n`;
2627
+ const galleryAbsolute = await resolveSafePath(project.directory.path, command.output ?? `cinema/galleries/${sha256Hex(contents)}.json`);
2628
+ const galleryRelative = relative(project.directory.path, galleryAbsolute);
2629
+ if (!galleryRelative.startsWith("cinema/") || isAbsolute(galleryRelative)) {
2630
+ throw new CliError("unsafe-path", "Cinema gallery output must remain under the project's cinema/ directory.");
2631
+ }
2632
+ await saveImmutableText(project.fileSystem, galleryRelative, contents, sha256Hex(contents));
2633
+ writeValue(context.io, command.json, {
2634
+ axis: gallery.axis,
2635
+ candidates: gallery.candidates.map(candidate => ({
2636
+ candidateId: candidate.candidateId,
2637
+ cinemaPlanSha256: candidate.cinemaPlanSha256,
2638
+ label: candidate.label,
2639
+ })),
2640
+ galleryPath: galleryRelative,
2641
+ selection: gallery.selection,
2642
+ }, () => `${gallery.candidates.length} candidates on ${gallery.axis}; gallery=${galleryRelative}`);
2643
+ return;
2644
+ }
2645
+
2646
+ const placeholders = command.action === "animatic" || command.allowPlaceholders
2647
+ ? "allow" as const
2648
+ : "reject" as const;
2649
+ let renderPlan;
2650
+ try {
2651
+ renderPlan = compileCinemaSequence(cinema, project.project, edit, { placeholders });
2652
+ } catch (error) {
2653
+ if (error instanceof TypeError) throw new CliError("conflict", error.message);
2654
+ throw error;
2655
+ }
2656
+ const planContents = `${canonicalJson(renderPlan)}\n`;
2657
+ const planPath = projectCinemaRenderPlanPath(renderPlan.planSha256);
2658
+ await saveImmutableText(
2659
+ project.fileSystem,
2660
+ planPath,
2661
+ planContents,
2662
+ sha256Hex(planContents),
2663
+ );
2664
+ const planDocumentSha256 = sha256Hex(planContents);
2665
+
2666
+ if (command.action === "plan") {
2667
+ writeValue(context.io, command.json, {
2668
+ advisories: renderPlan.advisories,
2669
+ plan: renderPlan,
2670
+ planPath,
2671
+ }, () => (
2672
+ `cinema render plan ${renderPlan.planSha256}; shots=${renderPlan.shots.length} cues=${renderPlan.audioCues.length} advisories=${renderPlan.advisories.length}`
2673
+ ));
2674
+ return;
2675
+ }
2676
+
2677
+ const tier = command.action === "animatic" ? "preview" as const : "final" as const;
2678
+ const requested = command.output
2679
+ ?? `renders/cinema/${command.action}-${renderPlan.planSha256.slice(0, 16)}.mp4`;
2680
+ const renderLeaf = await resolveProjectRenderLeaf(project.directory.path, requested, { videoOutput: true });
2681
+ const receiptRelative = `${renderLeaf.relative}.cinema-receipt.json`;
2682
+ const receiptLeaf = await resolveProjectRenderLeaf(project.directory.path, receiptRelative);
2683
+ const ffmpeg = await requireRequestedCapability(context, "ffmpeg");
2684
+ const built = await buildCinemaFfmpegInvocation(renderPlan, {
2685
+ dryRun: command.dryRun,
2686
+ ffmpeg,
2687
+ outputPath: renderLeaf.absolute,
2688
+ projectDirectory: project.directory.path,
2689
+ repositoryRoot: context.paths.repositoryRoot,
2690
+ tier,
2691
+ });
2692
+ const output = {
2693
+ invocation: built.invocation,
2694
+ planPath,
2695
+ projectId: project.project.projectId,
2696
+ receiptPath: command.dryRun ? null : receiptRelative,
2697
+ tier,
2698
+ };
2699
+ if (command.dryRun) {
2700
+ writeValue(context.io, command.json, output, () => `dry-run ${built.argv.join(" ")}`);
2701
+ return;
2702
+ }
2703
+ const placeholderShotIds = renderPlan.shots
2704
+ .filter(shot => shot.placeholder !== undefined)
2705
+ .map(shot => shot.shotId);
2706
+ const outputIntegrity = await executeAtomicRender({
2707
+ argv: built.argv,
2708
+ companion: {
2709
+ finalPath: receiptLeaf.absolute,
2710
+ publish: async integrity => await project.fileSystem.writeTextAtomic(
2711
+ receiptRelative,
2712
+ `${canonicalJson(CinemaRenderReceiptV1Schema.parse({
2713
+ cinemaPlanSha256: cinema.cinemaPlanSha256,
2714
+ createdAt: context.io.now().toISOString(),
2715
+ kind: "slopcamera.cinema-render-receipt",
2716
+ output: { ...integrity, path: renderLeaf.relative },
2717
+ placeholders: placeholderShotIds,
2718
+ plan: { path: planPath, sha256: planDocumentSha256 },
2719
+ projectId: project.project.projectId,
2720
+ schemaVersion: 1,
2721
+ tier,
2722
+ }))}\n`,
2723
+ ),
2724
+ },
2725
+ failureLabel: "FFmpeg cinema render failed",
2726
+ finalOutputPath: renderLeaf.absolute,
2727
+ maximumOutputBytes: MAXIMUM_LEGACY_PROJECT_RENDER_OUTPUT_BYTES,
2728
+ runner: context.runner,
2729
+ });
2730
+ const completed = { ...output, output: outputIntegrity };
2731
+ writeValue(
2732
+ context.io,
2733
+ command.json,
2734
+ completed,
2735
+ () => `rendered ${renderLeaf.relative}; plan=${planPath}`,
2736
+ );
2737
+ }
2738
+
2413
2739
  function projectPlacement(
2414
2740
  project: Awaited<ReturnType<typeof openProject>>,
2415
2741
  assetId: string,
@@ -4457,6 +4783,20 @@ function gatewayCliError(error: unknown): CliError {
4457
4783
  if (error instanceof GatewayProviderOptionsError) {
4458
4784
  return new CliError("usage", error.message);
4459
4785
  }
4786
+ if (error instanceof SlopcameraCloudError) {
4787
+ return new CliError(
4788
+ error.code === "INVALID_ARGUMENT"
4789
+ ? "usage"
4790
+ : error.code === "OUTPUT_WRITE_FAILED"
4791
+ ? "conflict"
4792
+ : error.code === "AUTHENTICATION_REQUIRED"
4793
+ ? "authorization-required"
4794
+ : error.code === "GENERATION_INVALID_RESPONSE"
4795
+ ? "invalid-data"
4796
+ : "unavailable",
4797
+ error.message,
4798
+ );
4799
+ }
4460
4800
  return asCliError(error);
4461
4801
  }
4462
4802
 
@@ -5112,6 +5452,819 @@ async function handleAiImageGenerate(
5112
5452
  });
5113
5453
  }
5114
5454
 
5455
+ async function dispatchGalleryCandidate(
5456
+ context: CommandContext,
5457
+ command: Extract<CliCommand, { readonly kind: "ai-image-gallery" }>,
5458
+ plan: SlopcameraGalleryPlan,
5459
+ candidate: SlopcameraGalleryCandidate,
5460
+ timeoutMs: number,
5461
+ ): Promise<SlopcameraGalleryResolvedCandidate> {
5462
+ const tracker = await createGatewayJobTracker(context, {
5463
+ model: command.model,
5464
+ operation: "image.generate",
5465
+ request: {
5466
+ candidate: {
5467
+ id: candidate.id,
5468
+ index: candidate.index,
5469
+ label: candidate.label,
5470
+ },
5471
+ count: 1,
5472
+ galleryKind: plan.kind,
5473
+ promptCharacters: candidate.prompt.length,
5474
+ promptSha256: sha256Hex(candidate.prompt),
5475
+ subjectSha256: sha256Hex(plan.subject),
5476
+ },
5477
+ });
5478
+ const settled = (
5479
+ partial: Omit<SlopcameraGalleryResolvedCandidate, "id" | "index" | "job" | "label" | "prompt">,
5480
+ ): SlopcameraGalleryResolvedCandidate => ({
5481
+ id: candidate.id,
5482
+ index: candidate.index,
5483
+ job: tracker.path,
5484
+ label: candidate.label,
5485
+ prompt: candidate.prompt,
5486
+ ...partial,
5487
+ });
5488
+ try {
5489
+ const bundle = await executeTrackedGatewayOperation(
5490
+ context,
5491
+ tracker,
5492
+ async service => await service.generateImage(
5493
+ {
5494
+ consent: gatewayConsent(context),
5495
+ model: command.model,
5496
+ prompt: candidate.prompt,
5497
+ },
5498
+ {
5499
+ ...(context.abortSignal === undefined
5500
+ ? {}
5501
+ : { signal: context.abortSignal }),
5502
+ timeoutMs,
5503
+ },
5504
+ ),
5505
+ result => result,
5506
+ );
5507
+ const output = bundle.outputs[0];
5508
+ if (output === undefined) {
5509
+ return settled({
5510
+ error: "The Gateway image job completed without an image output.",
5511
+ status: "failed",
5512
+ });
5513
+ }
5514
+ const bytes = await readBoundedPhysicalBytes(
5515
+ output.path,
5516
+ CLI_GATEWAY_IMAGE_INPUT_BYTES,
5517
+ );
5518
+ const generated = settled({
5519
+ bytes,
5520
+ mediaType: output.mediaType,
5521
+ path: output.path,
5522
+ ...(bundle.receipt.routing.generationId === undefined
5523
+ ? {}
5524
+ : { requestId: bundle.receipt.routing.generationId }),
5525
+ sha256: output.sha256,
5526
+ status: "generated",
5527
+ ...(bundle.receipt.warnings.length === 0
5528
+ ? {}
5529
+ : { warnings: bundle.receipt.warnings }),
5530
+ });
5531
+ if (command.preview === "probe") {
5532
+ return await attachGalleryProbePreview(context, plan, generated);
5533
+ }
5534
+ return generated;
5535
+ } catch (error) {
5536
+ // A settled paid job stays a gallery row: the receipt keeps the failure
5537
+ // and the durable job path so review never depends on provider success.
5538
+ return settled({
5539
+ error: gatewayCliError(error).message,
5540
+ status: "failed",
5541
+ });
5542
+ }
5543
+ }
5544
+
5545
+ const GALLERY_PROBE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/svg+xml"]);
5546
+
5547
+ /**
5548
+ * Renders one settled candidate inside the fixed probe scene and returns the
5549
+ * candidate row with a distinct `cellImage` render artifact. A render failure
5550
+ * keeps the flat candidate cell and records the failure as a warning — the
5551
+ * paid candidate bytes stay reviewable and promotable.
5552
+ */
5553
+ async function attachGalleryProbePreview(
5554
+ context: CommandContext,
5555
+ plan: SlopcameraGalleryPlan,
5556
+ resolved: SlopcameraGalleryResolvedCandidate,
5557
+ ): Promise<SlopcameraGalleryResolvedCandidate> {
5558
+ const probeFailed = (message: string): SlopcameraGalleryResolvedCandidate => ({
5559
+ ...resolved,
5560
+ warnings: [...(resolved.warnings ?? []), `Probe preview failed: ${message}`],
5561
+ });
5562
+ try {
5563
+ if (
5564
+ resolved.bytes === undefined
5565
+ || resolved.mediaType === undefined
5566
+ || resolved.path === undefined
5567
+ || resolved.sha256 === undefined
5568
+ ) {
5569
+ return probeFailed("the candidate artifact lacks published bytes.");
5570
+ }
5571
+ if (!GALLERY_PROBE_MIME_TYPES.has(resolved.mediaType)) {
5572
+ return probeFailed(`candidate media type ${resolved.mediaType} cannot be a scene asset.`);
5573
+ }
5574
+ const [repositoryRoot, candidatePath] = await Promise.all([
5575
+ realpath(context.paths.repositoryRoot),
5576
+ realpath(resolved.path),
5577
+ ]);
5578
+ if (!isWithin(repositoryRoot, candidatePath)) {
5579
+ return probeFailed("the candidate artifact escapes the repository root.");
5580
+ }
5581
+ if (
5582
+ createHash("sha256").update(resolved.bytes).digest("hex")
5583
+ !== resolved.sha256
5584
+ ) {
5585
+ return probeFailed("the candidate bytes do not match their published artifact digest.");
5586
+ }
5587
+ const metadata = await sharp(resolved.bytes, {
5588
+ failOn: "warning",
5589
+ limitInputPixels: slopcameraGalleryLimits.candidatePixels,
5590
+ }).metadata();
5591
+ if (metadata.width === undefined || metadata.height === undefined) {
5592
+ return probeFailed("the candidate image did not expose its dimensions.");
5593
+ }
5594
+ const mode = plan.kind as SlopcameraGalleryProbeMode;
5595
+ const scene = galleryProbeScene(mode, {
5596
+ assetId: "asset_candidate",
5597
+ dependencies: [],
5598
+ interpretation: {
5599
+ alpha: metadata.hasAlpha === true ? "straight" : "opaque",
5600
+ colorSpace: "srgb",
5601
+ height: metadata.height,
5602
+ kind: "image",
5603
+ mimeType: resolved.mediaType as "image/png" | "image/jpeg" | "image/svg+xml",
5604
+ width: metadata.width,
5605
+ },
5606
+ payload: {
5607
+ bytes: resolved.bytes.byteLength,
5608
+ path: basename(candidatePath),
5609
+ sha256: resolved.sha256,
5610
+ },
5611
+ provenance: {
5612
+ description: `Gallery candidate ${resolved.id} probe preview.`,
5613
+ source: "generated",
5614
+ },
5615
+ });
5616
+ const scenePath = join(dirname(candidatePath), "probe-scene.json");
5617
+ const handle = await open(
5618
+ scenePath,
5619
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
5620
+ 0o600,
5621
+ );
5622
+ try {
5623
+ await handle.writeFile(`${canonicalJson(scene)}\n`);
5624
+ } finally {
5625
+ await handle.close();
5626
+ }
5627
+ const source = { path: relative(repositoryRoot, scenePath) };
5628
+ // The render phase admits its own local/browser vector per settled
5629
+ // candidate; paid provider waits never hold render capacity.
5630
+ const renderClaims = physicalHostResourceClaims(
5631
+ [
5632
+ { amount: 1, resource: "cpu" },
5633
+ { amount: 1, resource: "local-io" },
5634
+ { amount: 1, resource: "browser" },
5635
+ { amount: 1, resource: "output-publication" },
5636
+ ],
5637
+ context.hostResourceCoordinator,
5638
+ );
5639
+ const rendered = await withHostResourceClaims(
5640
+ context,
5641
+ missingHostResourceClaims(
5642
+ context.hostResourceLease?.claims ?? [],
5643
+ renderClaims,
5644
+ ),
5645
+ async admitted => {
5646
+ const input = {
5647
+ request: galleryProbeRenderRequest(),
5648
+ sceneSha256: spatialSceneSha256(scene),
5649
+ source,
5650
+ };
5651
+ if (context.renderGalleryScene !== undefined) {
5652
+ return await context.renderGalleryScene({
5653
+ ...input,
5654
+ application: applicationContext(admitted),
5655
+ });
5656
+ }
5657
+ const registry = createApplicationOperationRegistry({
5658
+ toolVersion: context.version,
5659
+ });
5660
+ const result = await registry.execute(
5661
+ {
5662
+ abortSignal: context.abortSignal ?? new AbortController().signal,
5663
+ application: applicationContext(admitted),
5664
+ },
5665
+ {
5666
+ input,
5667
+ kind: "scene.render",
5668
+ version: 1,
5669
+ },
5670
+ );
5671
+ const output = result.output as {
5672
+ readonly artifact: {
5673
+ readonly bytes: number;
5674
+ readonly path: string;
5675
+ readonly sha256: string;
5676
+ };
5677
+ };
5678
+ return { artifact: output.artifact };
5679
+ },
5680
+ context.abortSignal,
5681
+ );
5682
+ const stillPath = resolve(context.paths.repositoryRoot, rendered.artifact.path);
5683
+ if (!isWithin(context.paths.repositoryRoot, stillPath)) {
5684
+ return probeFailed("the rendered still escapes the repository root.");
5685
+ }
5686
+ const stillBytes = await readBoundedPhysicalBytes(
5687
+ stillPath,
5688
+ CLI_GATEWAY_IMAGE_INPUT_BYTES,
5689
+ );
5690
+ if (
5691
+ stillBytes.byteLength !== rendered.artifact.bytes
5692
+ || createHash("sha256").update(stillBytes).digest("hex") !== rendered.artifact.sha256
5693
+ ) {
5694
+ return probeFailed("the rendered still does not match its published artifact digest.");
5695
+ }
5696
+ return {
5697
+ ...resolved,
5698
+ cellImage: {
5699
+ bytes: stillBytes,
5700
+ mediaType: "image/png",
5701
+ path: stillPath,
5702
+ sha256: rendered.artifact.sha256,
5703
+ },
5704
+ };
5705
+ } catch (error) {
5706
+ return probeFailed(error instanceof Error ? error.message : String(error));
5707
+ }
5708
+ }
5709
+
5710
+ /**
5711
+ * Renders one derived variant scene into a beauty still through the qualified
5712
+ * `scene.render` operation (or the test seam) and returns the resolved gallery
5713
+ * candidate. A render or write failure stays in the sheet as a failed row
5714
+ * carrying the variant's patch provenance.
5715
+ */
5716
+ async function dispatchSceneVariant(
5717
+ context: CommandContext,
5718
+ baseScene: SpatialSceneV1,
5719
+ variant: SlopcameraSceneGalleryVariant,
5720
+ request: {
5721
+ readonly cameraId: string;
5722
+ readonly mode: { readonly kind: "beauty" };
5723
+ readonly selection: { readonly kind: "frame"; readonly timeUs: number };
5724
+ },
5725
+ derivedPath: string,
5726
+ bases: {
5727
+ readonly repositoryRoot: string;
5728
+ readonly scene: string;
5729
+ readonly variants: string;
5730
+ },
5731
+ ): Promise<SlopcameraGalleryResolvedCandidate> {
5732
+ const base = {
5733
+ id: variant.id,
5734
+ index: variant.index,
5735
+ label: variant.label,
5736
+ prompt: variant.prompt,
5737
+ scene: {
5738
+ diff: variant.diff,
5739
+ patchSha256: variant.patchSha256,
5740
+ sceneSha256: variant.sceneSha256,
5741
+ },
5742
+ };
5743
+ let derivedArtifact: {
5744
+ readonly bytes: Uint8Array;
5745
+ readonly mediaType: "application/json";
5746
+ readonly path: string;
5747
+ readonly sha256: string;
5748
+ } | undefined;
5749
+ let renderReceiptPath: string | undefined;
5750
+ try {
5751
+ const variantDirectory = dirname(derivedPath);
5752
+ await mkdir(variantDirectory, { mode: 0o700, recursive: true });
5753
+ // Stage every declared payload beneath the variant directory so the
5754
+ // derived manifest's contained relative paths resolve for this render
5755
+ // and for a standalone re-render after promotion. Inherited payloads
5756
+ // resolve beside the authored scene; variant-authored payloads resolve
5757
+ // beside the variants file.
5758
+ const baseAssets = new Map(
5759
+ baseScene.assets.map(asset => [asset.assetId, asset]),
5760
+ );
5761
+ const staged = new Map<string, string>();
5762
+ const assets: {
5763
+ readonly assetId: string;
5764
+ readonly artifact: {
5765
+ readonly bytes: number;
5766
+ readonly path: string;
5767
+ readonly sha256: string;
5768
+ };
5769
+ }[] = [];
5770
+ for (const asset of variant.scene.assets) {
5771
+ const inherited = baseAssets.get(asset.assetId);
5772
+ const unchanged = inherited !== undefined
5773
+ && inherited.payload.sha256 === asset.payload.sha256
5774
+ && inherited.payload.bytes === asset.payload.bytes
5775
+ && inherited.payload.path === asset.payload.path;
5776
+ const sourcePath = resolve(
5777
+ unchanged ? bases.scene : bases.variants,
5778
+ asset.payload.path,
5779
+ );
5780
+ const target = join(variantDirectory, asset.payload.path);
5781
+ const binding = [
5782
+ sourcePath,
5783
+ String(asset.payload.bytes),
5784
+ asset.payload.sha256,
5785
+ ].join("\u0000");
5786
+ const assetBinding = {
5787
+ assetId: asset.assetId,
5788
+ artifact: {
5789
+ bytes: asset.payload.bytes,
5790
+ path: relative(context.paths.repositoryRoot, target),
5791
+ sha256: asset.payload.sha256,
5792
+ },
5793
+ };
5794
+ const previous = staged.get(target);
5795
+ if (previous !== undefined) {
5796
+ if (previous !== binding) {
5797
+ throw new CliError(
5798
+ "conflict",
5799
+ `Variant ${variant.id} binds two payloads at ${asset.payload.path}.`,
5800
+ );
5801
+ }
5802
+ assets.push(assetBinding);
5803
+ continue;
5804
+ }
5805
+ const physicalSourcePath = await realpath(sourcePath);
5806
+ if (
5807
+ physicalSourcePath !== sourcePath
5808
+ || !isWithin(bases.repositoryRoot, physicalSourcePath)
5809
+ ) {
5810
+ throw new CliError(
5811
+ "unsafe-path",
5812
+ `Asset payload ${asset.payload.path} must be a physical file inside the repository.`,
5813
+ );
5814
+ }
5815
+ const payload = await readBoundedPhysicalBytes(
5816
+ sourcePath,
5817
+ asset.payload.bytes,
5818
+ );
5819
+ if (
5820
+ payload.byteLength !== asset.payload.bytes
5821
+ || createHash("sha256").update(payload).digest("hex")
5822
+ !== asset.payload.sha256
5823
+ ) {
5824
+ throw new CliError(
5825
+ "invalid-data",
5826
+ `Asset ${asset.assetId} payload does not match its manifest.`,
5827
+ );
5828
+ }
5829
+ await mkdir(dirname(target), { mode: 0o700, recursive: true });
5830
+ const handle = await open(
5831
+ target,
5832
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY
5833
+ | (constants.O_NOFOLLOW ?? 0),
5834
+ 0o600,
5835
+ );
5836
+ try {
5837
+ await handle.writeFile(payload);
5838
+ await handle.sync();
5839
+ } finally {
5840
+ await handle.close();
5841
+ }
5842
+ staged.set(target, binding);
5843
+ assets.push(assetBinding);
5844
+ }
5845
+ // The derived scene is a new no-replace document beside the sheet; the
5846
+ // authored source scene is never touched.
5847
+ await publishSpatialSource(derivedPath, variant.scene);
5848
+ const sceneBytes = await readBoundedPhysicalBytes(
5849
+ derivedPath,
5850
+ SPATIAL_SCENE_LIMITS.sourceBytes,
5851
+ );
5852
+ derivedArtifact = {
5853
+ bytes: sceneBytes,
5854
+ mediaType: "application/json",
5855
+ path: derivedPath,
5856
+ sha256: createHash("sha256").update(sceneBytes).digest("hex"),
5857
+ };
5858
+ const source = {
5859
+ path: relative(context.paths.repositoryRoot, derivedPath),
5860
+ };
5861
+ const rendered = context.renderGalleryScene === undefined
5862
+ ? await (async () => {
5863
+ const registry = createApplicationOperationRegistry({
5864
+ toolVersion: context.version,
5865
+ });
5866
+ const result = await registry.execute(
5867
+ {
5868
+ abortSignal: context.abortSignal ?? new AbortController().signal,
5869
+ application: applicationContext(context),
5870
+ },
5871
+ {
5872
+ input: {
5873
+ assets,
5874
+ request,
5875
+ sceneSha256: variant.sceneSha256,
5876
+ source,
5877
+ },
5878
+ kind: "scene.render",
5879
+ version: 1,
5880
+ },
5881
+ );
5882
+ return result.output as {
5883
+ readonly artifact: {
5884
+ readonly bytes: number;
5885
+ readonly path: string;
5886
+ readonly sha256: string;
5887
+ };
5888
+ readonly receipt: { readonly path: string };
5889
+ };
5890
+ })()
5891
+ : await context.renderGalleryScene({
5892
+ application: applicationContext(context),
5893
+ assets,
5894
+ request,
5895
+ sceneSha256: variant.sceneSha256,
5896
+ source,
5897
+ });
5898
+ if (rendered.receipt !== undefined) {
5899
+ const receiptPath = resolve(
5900
+ context.paths.repositoryRoot,
5901
+ rendered.receipt.path,
5902
+ );
5903
+ if (!isWithin(context.paths.repositoryRoot, receiptPath)) {
5904
+ throw new CliError(
5905
+ "unsafe-path",
5906
+ "A render receipt escapes the repository root.",
5907
+ );
5908
+ }
5909
+ renderReceiptPath = receiptPath;
5910
+ }
5911
+ const stillPath = resolve(
5912
+ context.paths.repositoryRoot,
5913
+ rendered.artifact.path,
5914
+ );
5915
+ if (!isWithin(context.paths.repositoryRoot, stillPath)) {
5916
+ throw new CliError(
5917
+ "unsafe-path",
5918
+ "A rendered variant still escapes the repository root.",
5919
+ );
5920
+ }
5921
+ const stillBytes = await readBoundedPhysicalBytes(
5922
+ stillPath,
5923
+ CLI_GATEWAY_IMAGE_INPUT_BYTES,
5924
+ );
5925
+ if (
5926
+ stillBytes.byteLength !== rendered.artifact.bytes
5927
+ || createHash("sha256").update(stillBytes).digest("hex")
5928
+ !== rendered.artifact.sha256
5929
+ ) {
5930
+ throw new CliError(
5931
+ "conflict",
5932
+ "A rendered variant still does not match its published artifact digest.",
5933
+ );
5934
+ }
5935
+ return {
5936
+ ...base,
5937
+ ...derivedArtifact,
5938
+ cellImage: {
5939
+ bytes: stillBytes,
5940
+ mediaType: "image/png",
5941
+ path: stillPath,
5942
+ sha256: rendered.artifact.sha256,
5943
+ },
5944
+ ...(renderReceiptPath === undefined ? {} : { job: renderReceiptPath }),
5945
+ status: "generated",
5946
+ };
5947
+ } catch (error) {
5948
+ return {
5949
+ ...base,
5950
+ ...derivedArtifact,
5951
+ ...(renderReceiptPath === undefined ? {} : { job: renderReceiptPath }),
5952
+ error: error instanceof Error ? error.message : String(error),
5953
+ status: "failed",
5954
+ };
5955
+ }
5956
+ }
5957
+
5958
+ async function handleAiSceneGallery(
5959
+ context: CommandContext,
5960
+ command: Extract<CliCommand, { readonly kind: "ai-scene-gallery" }>,
5961
+ ): Promise<void> {
5962
+ const scenePath = resolve(context.paths.repositoryRoot, command.scene);
5963
+ const variantsPath = resolve(context.paths.repositoryRoot, command.variants);
5964
+ const [repositoryRoot, physicalScenePath, physicalVariantsPath] = await Promise.all([
5965
+ realpath(context.paths.repositoryRoot),
5966
+ realpath(scenePath),
5967
+ realpath(variantsPath),
5968
+ ]);
5969
+ if (
5970
+ physicalScenePath !== resolve(
5971
+ repositoryRoot,
5972
+ relative(context.paths.repositoryRoot, scenePath),
5973
+ )
5974
+ || physicalVariantsPath !== resolve(
5975
+ repositoryRoot,
5976
+ relative(context.paths.repositoryRoot, variantsPath),
5977
+ )
5978
+ || !isWithin(repositoryRoot, physicalScenePath)
5979
+ || !isWithin(repositoryRoot, physicalVariantsPath)
5980
+ ) {
5981
+ throw new CliError(
5982
+ "unsafe-path",
5983
+ "Scene gallery inputs must be physical files inside the repository.",
5984
+ );
5985
+ }
5986
+ const scene = parseSpatialScene(await readSpatialJson(scenePath));
5987
+ const variantsValue = await readSpatialJson(
5988
+ variantsPath,
5989
+ slopcameraSceneGalleryLimits.variantsBytes,
5990
+ );
5991
+ // Every variant patch is validated and applied before the first render.
5992
+ const gallery = planSlopcameraSceneGallery({
5993
+ scene,
5994
+ variants: variantsValue,
5995
+ });
5996
+ const cameraId = command.camera ?? gallery.scene.cameras[0]?.cameraId;
5997
+ if (cameraId === undefined) {
5998
+ throw new CliError(
5999
+ "invalid-data",
6000
+ "The scene declares no camera to render from.",
6001
+ );
6002
+ }
6003
+ if (
6004
+ !gallery.scene.cameras.some(camera => camera.cameraId === cameraId)
6005
+ ) {
6006
+ throw new CliError(
6007
+ "invalid-data",
6008
+ `--camera ${cameraId} is not a scene camera.`,
6009
+ );
6010
+ }
6011
+ const timeUs = command.timeUs ?? 0;
6012
+ const outputDir = await resolveSafePath(
6013
+ context.paths.repositoryRoot,
6014
+ command.outputDir,
6015
+ );
6016
+ await mkdir(outputDir, { mode: 0o700, recursive: true });
6017
+ for (const name of ["gallery.png", "receipt.json", "variants"] as const) {
6018
+ const existing = await lstat(join(outputDir, name)).then(
6019
+ () => true,
6020
+ () => false,
6021
+ );
6022
+ if (existing) {
6023
+ throw new CliError(
6024
+ "conflict",
6025
+ `Gallery outputs are never replaced; ${name} already exists in ${command.outputDir}.`,
6026
+ );
6027
+ }
6028
+ }
6029
+ const renderRequest = {
6030
+ cameraId,
6031
+ mode: { kind: "beauty" as const },
6032
+ selection: { kind: "frame" as const, timeUs },
6033
+ };
6034
+ const bases = {
6035
+ repositoryRoot,
6036
+ scene: dirname(physicalScenePath),
6037
+ variants: dirname(physicalVariantsPath),
6038
+ };
6039
+ const resolved = await mapBounded(
6040
+ gallery.variants,
6041
+ slopcameraGalleryLimits.concurrency,
6042
+ async variant =>
6043
+ await dispatchSceneVariant(
6044
+ context,
6045
+ gallery.scene,
6046
+ variant,
6047
+ renderRequest,
6048
+ join(outputDir, "variants", variant.id, "scene.json"),
6049
+ bases,
6050
+ ),
6051
+ );
6052
+ const plan: SlopcameraGalleryPlan = {
6053
+ aspect: [16, 9],
6054
+ axes: [],
6055
+ candidates: gallery.variants.map(variant => ({
6056
+ id: variant.id,
6057
+ index: variant.index,
6058
+ label: variant.label,
6059
+ prompt: variant.prompt,
6060
+ })),
6061
+ kind: "image",
6062
+ subject: gallery.scene.sceneId,
6063
+ tiled: false,
6064
+ };
6065
+ const receipt = await composeSlopcameraImageGallery({
6066
+ candidates: resolved,
6067
+ ...(command.cell === undefined ? {} : { cellEdge: command.cell }),
6068
+ model: "local/scene-render",
6069
+ outputDir,
6070
+ plan,
6071
+ receipt: {
6072
+ baseSceneSha256: gallery.baseSceneSha256,
6073
+ kind: "slopcamera.scene-gallery",
6074
+ },
6075
+ });
6076
+ const summary = {
6077
+ baseSceneSha256: gallery.baseSceneSha256,
6078
+ gallery: displayPath(context.paths.repositoryRoot, receipt.gallery.path),
6079
+ kind: receipt.kind,
6080
+ receipt: displayPath(context.paths.repositoryRoot, receipt.receiptPath),
6081
+ variants: receipt.candidates.map(candidate => ({
6082
+ id: candidate.id,
6083
+ index: candidate.index,
6084
+ ...(candidate.cellImage === undefined
6085
+ ? {}
6086
+ : {
6087
+ cellImage: {
6088
+ ...(candidate.cellImage.path === undefined
6089
+ ? {}
6090
+ : {
6091
+ path: displayPath(
6092
+ context.paths.repositoryRoot,
6093
+ candidate.cellImage.path,
6094
+ ),
6095
+ }),
6096
+ sha256: candidate.cellImage.sha256,
6097
+ },
6098
+ }),
6099
+ ...(candidate.job === undefined
6100
+ ? {}
6101
+ : { job: displayPath(context.paths.repositoryRoot, candidate.job) }),
6102
+ ...(candidate.path === undefined
6103
+ ? {}
6104
+ : { path: displayPath(context.paths.repositoryRoot, candidate.path) }),
6105
+ ...(candidate.scene === undefined ? {} : { scene: candidate.scene }),
6106
+ status: candidate.status,
6107
+ })),
6108
+ };
6109
+ writeValue(
6110
+ context.io,
6111
+ command.json,
6112
+ summary,
6113
+ () =>
6114
+ [
6115
+ `scene.gallery ${receipt.counts.generated}/${receipt.counts.requested} variants rendered`,
6116
+ ...receipt.candidates.map(candidate => (
6117
+ `#${String(candidate.index)} ${candidate.id}\t${candidate.status}`
6118
+ + (candidate.cellImage?.path === undefined
6119
+ ? ""
6120
+ : `\tstill ${displayPath(context.paths.repositoryRoot, candidate.cellImage.path)}`)
6121
+ )),
6122
+ ].join("\n"),
6123
+ );
6124
+ }
6125
+
6126
+ async function handleAiImageGallery(
6127
+ context: CommandContext,
6128
+ command: Extract<CliCommand, { readonly kind: "ai-image-gallery" }>,
6129
+ ): Promise<void> {
6130
+ await withGatewayErrors(async () => {
6131
+ const timeoutMs = gatewayTimeoutMs(command.timeout);
6132
+ const vary = command.vary === undefined
6133
+ ? undefined
6134
+ : parseSlopcameraGalleryVary(command.vary);
6135
+ let explicitCandidates: readonly unknown[] | undefined;
6136
+ if (command.candidatesFile !== undefined) {
6137
+ const text = await readGatewayTextFile(
6138
+ context.io.cwd(),
6139
+ command.candidatesFile,
6140
+ 64 * 1024,
6141
+ );
6142
+ let parsedCandidates: unknown;
6143
+ try {
6144
+ parsedCandidates = JSON.parse(text) as unknown;
6145
+ } catch {
6146
+ throw new CliError(
6147
+ "usage",
6148
+ "--candidates must be valid JSON.",
6149
+ );
6150
+ }
6151
+ if (!Array.isArray(parsedCandidates)) {
6152
+ throw new CliError(
6153
+ "usage",
6154
+ "--candidates must be a JSON array of {id, prompt|variant} entries.",
6155
+ );
6156
+ }
6157
+ explicitCandidates = parsedCandidates;
6158
+ }
6159
+ const plan = planSlopcameraGallery({
6160
+ ...(explicitCandidates === undefined
6161
+ ? {}
6162
+ : { candidates: explicitCandidates }),
6163
+ ...(command.count === undefined ? {} : { count: command.count }),
6164
+ ...(command.galleryKind === undefined
6165
+ ? {}
6166
+ : { kind: command.galleryKind as SlopcameraGalleryKind }),
6167
+ subject: command.subject,
6168
+ ...(command.tiled === undefined ? {} : { tiled: command.tiled }),
6169
+ ...(vary === undefined ? {} : { vary }),
6170
+ });
6171
+ const outputDir = resolve(context.io.cwd(), command.outputDir);
6172
+ await mkdir(outputDir, { recursive: true });
6173
+ for (const name of ["gallery.png", "receipt.json"] as const) {
6174
+ const existing = await lstat(join(outputDir, name)).then(
6175
+ () => true,
6176
+ () => false,
6177
+ );
6178
+ if (existing) {
6179
+ throw new CliError(
6180
+ "conflict",
6181
+ `Gallery outputs are never replaced; ${name} already exists in ${command.outputDir}.`,
6182
+ );
6183
+ }
6184
+ }
6185
+ const resolved = await mapBounded(
6186
+ plan.candidates,
6187
+ slopcameraGalleryLimits.concurrency,
6188
+ async candidate => await dispatchGalleryCandidate(
6189
+ context,
6190
+ command,
6191
+ plan,
6192
+ candidate,
6193
+ timeoutMs,
6194
+ ),
6195
+ );
6196
+ const receipt = await composeSlopcameraImageGallery({
6197
+ candidates: resolved,
6198
+ ...(command.cell === undefined ? {} : { cellEdge: command.cell }),
6199
+ model: command.model,
6200
+ outputDir,
6201
+ plan,
6202
+ });
6203
+ const summary = {
6204
+ candidates: receipt.candidates.map(candidate => ({
6205
+ id: candidate.id,
6206
+ index: candidate.index,
6207
+ ...(candidate.job === undefined
6208
+ ? {}
6209
+ : { job: displayPath(context.paths.repositoryRoot, candidate.job) }),
6210
+ ...(candidate.path === undefined
6211
+ ? {}
6212
+ : { path: displayPath(context.paths.repositoryRoot, candidate.path) }),
6213
+ ...(candidate.sha256 === undefined ? {} : { sha256: candidate.sha256 }),
6214
+ status: candidate.status,
6215
+ ...(candidate.tiled === undefined ? {} : { tiled: candidate.tiled }),
6216
+ ...(candidate.cellImage === undefined
6217
+ ? {}
6218
+ : {
6219
+ cellImage: {
6220
+ ...(candidate.cellImage.path === undefined
6221
+ ? {}
6222
+ : {
6223
+ path: displayPath(
6224
+ context.paths.repositoryRoot,
6225
+ candidate.cellImage.path,
6226
+ ),
6227
+ }),
6228
+ sha256: candidate.cellImage.sha256,
6229
+ },
6230
+ }),
6231
+ ...(candidate.error === undefined ? {} : { error: candidate.error }),
6232
+ })),
6233
+ counts: receipt.counts,
6234
+ gallery: {
6235
+ bytes: receipt.gallery.bytes,
6236
+ height: receipt.gallery.height,
6237
+ path: displayPath(context.paths.repositoryRoot, receipt.gallery.path),
6238
+ sha256: receipt.gallery.sha256,
6239
+ width: receipt.gallery.width,
6240
+ },
6241
+ kind: receipt.galleryKind,
6242
+ model: receipt.model,
6243
+ receiptPath: displayPath(
6244
+ context.paths.repositoryRoot,
6245
+ receipt.receiptPath,
6246
+ ),
6247
+ subject: receipt.subject,
6248
+ };
6249
+ writeValue(
6250
+ context.io,
6251
+ command.json,
6252
+ summary,
6253
+ () => [
6254
+ `image.gallery ${receipt.counts.generated}/${receipt.counts.requested} candidates generated (${receipt.model})`,
6255
+ ...receipt.candidates.map(candidate => (
6256
+ `#${String(candidate.index)} ${candidate.id}\t${candidate.status}`
6257
+ + (candidate.job === undefined
6258
+ ? ""
6259
+ : `\tjob ${displayPath(context.paths.repositoryRoot, candidate.job)}`)
6260
+ )),
6261
+ `gallery ${summary.gallery.path}`,
6262
+ `receipt ${summary.receiptPath}`,
6263
+ ].join("\n"),
6264
+ );
6265
+ });
6266
+ }
6267
+
5115
6268
  async function handleAiVideoGenerate(
5116
6269
  context: CommandContext,
5117
6270
  command: Extract<CliCommand, { readonly kind: "ai-video-generate" }>,
@@ -5769,6 +6922,18 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
5769
6922
  writeValue(context.io, command.json, output, () => JSON.stringify(output, null, 2));
5770
6923
  return;
5771
6924
  }
6925
+ case "spatial-asset": {
6926
+ const output = await executeSpatialAssetCommand(applicationContext(context), command, {
6927
+ ...(context.abortSignal === undefined ? {} : { signal: context.abortSignal }),
6928
+ });
6929
+ writeValue(context.io, command.json, output, () => JSON.stringify(output, null, 2));
6930
+ return;
6931
+ }
6932
+ case "spatial-design": {
6933
+ const output = await executeSpatialDesignCommand(applicationContext(context), command, context.abortSignal);
6934
+ writeValue(context.io, command.json, output, () => JSON.stringify(output, null, 2));
6935
+ return;
6936
+ }
5772
6937
  case "spatial-scene": {
5773
6938
  const output = await executeSpatialSceneCommand(applicationContext(context), command, context.abortSignal);
5774
6939
  writeValue(context.io, command.json, output, () => JSON.stringify(output, null, 2));
@@ -5781,6 +6946,7 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
5781
6946
  }
5782
6947
  case "help": writeLine(context.io, commandHelp(command.topic)); return;
5783
6948
  case "version": writeLine(context.io, context.version); return;
6949
+ case "capabilities": writeCapabilityManifest(context.io, command.json, context.version); return;
5784
6950
  case "operations-list": {
5785
6951
  const operations = operationDiscoveryList(
5786
6952
  createApplicationOperationRegistry({ toolVersion: context.version }),
@@ -6290,6 +7456,8 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
6290
7456
  case "ai-models-list": await handleAiModelsList(context, command); return;
6291
7457
  case "ai-models-show": await handleAiModelsShow(context, command); return;
6292
7458
  case "ai-image-generate": await handleAiImageGenerate(context, command); return;
7459
+ case "ai-image-gallery": await handleAiImageGallery(context, command); return;
7460
+ case "ai-scene-gallery": await handleAiSceneGallery(context, command); return;
6293
7461
  case "ai-video-generate": await handleAiVideoGenerate(context, command); return;
6294
7462
  case "ai-speech-generate": await handleAiSpeechGenerate(context, command); return;
6295
7463
  case "ai-transcribe": await handleAiTranscribe(context, command); return;
@@ -6429,6 +7597,7 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
6429
7597
  case "project-metadata-edit": await handleProjectMetadataEdit(context, command); return;
6430
7598
  case "project-overlay-edit": await handleProjectOverlayEdit(context, command); return;
6431
7599
  case "project-render": await handleProjectRender(context, command); return;
7600
+ case "project-cinema": await handleProjectCinema(context, command); return;
6432
7601
  case "align-analyze": await handleAlignAnalyze(context, command); return;
6433
7602
  case "align-apply": await handleAlignApply(context, command); return;
6434
7603
  case "inspect": {
@@ -6545,7 +7714,9 @@ function commandMutationReference(command: CliCommand): MutationReference | unde
6545
7714
  case "studio": return undefined; // Native jobs and the machine custody marker own explicit leases.
6546
7715
  case "directing": return undefined; // The directing store owns its explicit lease.
6547
7716
  case "spatial-world": return undefined; // Immutable world attempts and imports own their publication custody.
6548
- case "spatial-scene": return command.action === "init" || command.action === "patch" || command.action === "camera-track" ? { kind: "workspace-private" } : undefined;
7717
+ case "spatial-asset": return undefined; // Content-addressed asset admission owns its publication custody.
7718
+ case "spatial-design": return undefined; // Fresh directories and no-replace source publication own custody.
7719
+ case "spatial-scene": return command.action === "init" || command.action === "patch" || command.action === "camera-track" || command.action === "solve" ? { kind: "workspace-private" } : undefined;
6549
7720
  case "spatial-project": return undefined; // Its explicit application adapter owns one version-aware lease.
6550
7721
  case "project-camera-edit": return command.action === "show"
6551
7722
  ? undefined
@@ -6555,6 +7726,7 @@ function commandMutationReference(command: CliCommand): MutationReference | unde
6555
7726
  case "project-metadata-edit":
6556
7727
  case "project-overlay-edit":
6557
7728
  case "project-render":
7729
+ case "project-cinema":
6558
7730
  case "align-analyze":
6559
7731
  case "align-apply":
6560
7732
  case "analyze-faces":
@@ -6583,11 +7755,14 @@ function commandMutationReference(command: CliCommand): MutationReference | unde
6583
7755
  case "diagram-render":
6584
7756
  case "image-vectorize": return { kind: "workspace-private" };
6585
7757
  case "ai-image-generate":
7758
+ case "ai-image-gallery":
7759
+ case "ai-scene-gallery":
6586
7760
  case "ai-video-generate":
6587
7761
  case "ai-speech-generate":
6588
7762
  case "ai-transcribe": return undefined;
6589
7763
  case "help":
6590
7764
  case "version":
7765
+ case "capabilities":
6591
7766
  case "operations-list":
6592
7767
  case "operations-show":
6593
7768
  case "diagram-check":
@@ -6697,6 +7872,10 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
6697
7872
  for (const completion of completions(command.words)) writeLine(io, completion);
6698
7873
  return 0;
6699
7874
  }
7875
+ if (command.kind === "capabilities") {
7876
+ writeCapabilityManifest(io, command.json, dependencies.version ?? SLOPCAMERA_VERSION);
7877
+ return 0;
7878
+ }
6700
7879
  const paths = dependencies.paths ?? await resolveRepositoryPaths(io.cwd(), io.env);
6701
7880
  const stateRoot = resolve(dependencies.stateRoot
6702
7881
  ?? defaultCliStateRoot(io.platform, io.env));
@@ -6764,8 +7943,13 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
6764
7943
  runner,
6765
7944
  sceneProviderFactory: dependencies.sceneProviderFactory
6766
7945
  ?? (options => createGatewaySceneProvider(options)),
7946
+ spatialReviewProviderFactory: dependencies.spatialReviewProviderFactory
7947
+ ?? (options => createGatewaySpatialReviewProvider(options)),
6767
7948
  sleep: dependencies.sleep ?? (async milliseconds => await Bun.sleep(milliseconds)),
6768
7949
  version: dependencies.version ?? SLOPCAMERA_VERSION,
7950
+ ...(dependencies.renderGalleryScene === undefined
7951
+ ? {}
7952
+ : { renderGalleryScene: dependencies.renderGalleryScene }),
6769
7953
  };
6770
7954
  const mutationTarget = await resolveMutationTarget(paths, stateRoot, command);
6771
7955
  await withCommandHostResources(context, command, async admittedContext => {