@hraness/slopcamera 3.2.7 → 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 (188) hide show
  1. package/NOTICE.md +1 -7
  2. package/PRIVACY.md +16 -7
  3. package/README.md +65 -20
  4. package/apps/desktop/README.md +14 -130
  5. package/apps/desktop/application/context.ts +6 -9
  6. package/apps/desktop/application/default-registry.ts +26 -10
  7. package/apps/desktop/application/operation.ts +14 -5
  8. package/apps/desktop/application/operations/index.ts +4 -1
  9. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  10. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  11. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  12. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  13. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  14. package/apps/desktop/application/registry.ts +2 -2
  15. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  16. package/apps/desktop/application/spatial-assets.ts +160 -31
  17. package/apps/desktop/application/spatial-render.ts +217 -20
  18. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  19. package/apps/desktop/application/spatial-review.ts +361 -0
  20. package/apps/desktop/application/spatial-spz.ts +48 -3
  21. package/apps/desktop/application/spatial-world-import.ts +26 -5
  22. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  23. package/apps/desktop/cli/args.ts +528 -97
  24. package/apps/desktop/cli/capability-manifest.ts +374 -0
  25. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  26. package/apps/desktop/cli/command-host-resources.ts +31 -2
  27. package/apps/desktop/cli/commands.ts +1215 -70
  28. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  29. package/apps/desktop/cli/help.ts +183 -23
  30. package/apps/desktop/cli/html-scene.ts +139 -28
  31. package/apps/desktop/cli/io.ts +2 -1
  32. package/apps/desktop/cli/main.ts +26 -50
  33. package/apps/desktop/cli/menubar.ts +244 -0
  34. package/apps/desktop/cli/music-analysis-service.ts +61 -0
  35. package/apps/desktop/cli/portable-surface.ts +18 -5
  36. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  37. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  38. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  39. package/apps/desktop/cli/spatial-scene-service.ts +268 -4
  40. package/apps/desktop/cli/workflow-code.ts +1 -1
  41. package/apps/desktop/code/application-node-planner.ts +0 -47
  42. package/apps/desktop/code/host-source-layout.ts +46 -0
  43. package/apps/desktop/code/public.ts +0 -1
  44. package/apps/desktop/code/runtime-identity.ts +50 -8
  45. package/apps/desktop/code/semantic-builder.ts +24 -43
  46. package/apps/desktop/code/source-bundle.ts +23 -6
  47. package/apps/desktop/code/source-typecheck.ts +131 -43
  48. package/apps/desktop/code/worker-client.ts +14 -2
  49. package/apps/desktop/contracts/cinema.ts +736 -0
  50. package/apps/desktop/contracts/index.ts +1 -0
  51. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  52. package/apps/desktop/contracts/spatial-world.ts +26 -3
  53. package/apps/desktop/core/cinema-plan.ts +1320 -0
  54. package/apps/desktop/core/index.ts +1 -0
  55. package/apps/desktop/core/music-analysis.ts +124 -1
  56. package/apps/desktop/core/storage.ts +75 -0
  57. package/apps/desktop/dist/cli/main.js +737 -291
  58. package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
  59. package/apps/desktop/html-overlay/index.ts +9 -0
  60. package/apps/desktop/html-overlay/runtime.ts +2 -0
  61. package/apps/desktop/html-overlay/scene.ts +29 -0
  62. package/apps/desktop/html-overlay/spatial.ts +619 -57
  63. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  64. package/apps/desktop/workflows/index.ts +6 -0
  65. package/dist/cli.js +32 -434
  66. package/dist/code/advanced.js +1 -269
  67. package/dist/code/index.js +16 -2239
  68. package/dist/generate.js +1 -23
  69. package/dist/host-resources.js +1 -25
  70. package/dist/index-0bbesn4m.js +35 -0
  71. package/dist/index-296qhpf2.js +3 -0
  72. package/dist/index-3cxf2c58.js +3 -0
  73. package/dist/index-77fjfg9f.js +3 -0
  74. package/dist/index-80a36bc0.js +3 -0
  75. package/dist/index-a3vvs3rf.js +3 -0
  76. package/dist/index-b3geqczn.js +5 -0
  77. package/dist/index-cgpkyjqa.js +3 -0
  78. package/dist/index-f0ptv8dm.js +7 -0
  79. package/dist/index-grk47tgs.js +3 -0
  80. package/dist/index-qry58nj2.js +10 -0
  81. package/dist/index.js +1 -240
  82. package/dist/operations.js +1 -28
  83. package/dist/skill-install-gx7afxsd.js +2 -0
  84. package/dist/vectorize/worker.js +1 -141
  85. package/dist/workflow.js +1 -15
  86. package/docs/README.md +5 -1
  87. package/docs/studio.md +1 -1
  88. package/package.json +11 -24
  89. package/skills/slopcamera/SKILL.md +14 -1
  90. package/skills/slopcamera/references/diagrams.md +5 -14
  91. package/skills/slopcamera/references/directed-scenes.md +22 -2
  92. package/skills/slopcamera/references/gateway-media.md +15 -1
  93. package/skills/slopcamera/references/image-galleries.md +166 -0
  94. package/skills/slopcamera/references/install.md +2 -2
  95. package/skills/slopcamera/references/music-video.md +23 -3
  96. package/skills/slopcamera/references/parametric-design.md +14 -0
  97. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  98. package/skills/slopcamera/references/scene-building.md +32 -0
  99. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  100. package/skills/slopcamera/references/support.md +25 -0
  101. package/skills/slopcamera/references/web-media-excerpts.md +120 -0
  102. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  103. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  104. package/src/capability-manifest.ts +361 -0
  105. package/src/cli.ts +250 -102
  106. package/src/code/canonical-json.ts +9 -0
  107. package/src/generate.ts +2 -2
  108. package/src/icon.ts +1111 -0
  109. package/src/image-gallery.ts +1049 -0
  110. package/src/index.ts +11 -23
  111. package/src/mcp/boundary.ts +11 -11
  112. package/src/mcp/index.ts +3 -0
  113. package/src/mcp/server.ts +2 -1
  114. package/src/mcp/tools.ts +1642 -130
  115. package/src/mcp/types.ts +19 -0
  116. package/src/operations.ts +412 -0
  117. package/src/portable-capability-manifest.ts +67 -0
  118. package/src/scene-gallery.ts +184 -0
  119. package/src/spatial-scene/asset-admission.ts +183 -0
  120. package/src/spatial-scene/audit-rendered.ts +692 -0
  121. package/src/spatial-scene/audit.ts +617 -0
  122. package/src/spatial-scene/behavior-audit.ts +252 -0
  123. package/src/spatial-scene/behavior-bake.ts +233 -0
  124. package/src/spatial-scene/behavior-fns.ts +585 -0
  125. package/src/spatial-scene/behavior-gallery.ts +109 -0
  126. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  127. package/src/spatial-scene/behavior-trace.ts +325 -0
  128. package/src/spatial-scene/behavior.ts +618 -0
  129. package/src/spatial-scene/build.ts +632 -0
  130. package/src/spatial-scene/camera-rig.ts +125 -0
  131. package/src/spatial-scene/camera-track.ts +4 -2
  132. package/src/spatial-scene/character.ts +124 -0
  133. package/src/spatial-scene/contracts.ts +75 -7
  134. package/src/spatial-scene/design-templates.ts +351 -0
  135. package/src/spatial-scene/design.ts +327 -0
  136. package/src/spatial-scene/direction-compile.ts +584 -0
  137. package/src/spatial-scene/direction.ts +122 -0
  138. package/src/spatial-scene/effects.ts +149 -0
  139. package/src/spatial-scene/evaluate.ts +109 -29
  140. package/src/spatial-scene/gallery.ts +277 -0
  141. package/src/spatial-scene/generate.ts +239 -0
  142. package/src/spatial-scene/geometry-native.ts +165 -0
  143. package/src/spatial-scene/geometry.ts +1216 -0
  144. package/src/spatial-scene/gltf.ts +600 -77
  145. package/src/spatial-scene/identity.ts +55 -2
  146. package/src/spatial-scene/index.ts +36 -0
  147. package/src/spatial-scene/inspect.ts +35 -6
  148. package/src/spatial-scene/material-lighting.ts +609 -0
  149. package/src/spatial-scene/math.ts +21 -4
  150. package/src/spatial-scene/motion-evidence.ts +88 -0
  151. package/src/spatial-scene/parametric.ts +765 -0
  152. package/src/spatial-scene/particle-preparation.ts +183 -0
  153. package/src/spatial-scene/particle.ts +195 -0
  154. package/src/spatial-scene/patch.ts +109 -15
  155. package/src/spatial-scene/performance.ts +1613 -0
  156. package/src/spatial-scene/probe.ts +137 -0
  157. package/src/spatial-scene/recipe-pack.ts +86 -0
  158. package/src/spatial-scene/render-effects.ts +240 -0
  159. package/src/spatial-scene/review.ts +639 -0
  160. package/src/spatial-scene/simulation-bake.ts +206 -0
  161. package/src/spatial-scene/simulation.ts +187 -0
  162. package/src/spatial-scene/solve.ts +366 -0
  163. package/src/spatial-scene/temporal-audit.ts +354 -0
  164. package/src/support-completion.ts +16 -0
  165. package/src/support.ts +42 -0
  166. package/src/version.ts +1 -1
  167. package/apps/desktop/application/operations/recording/index.ts +0 -5
  168. package/apps/desktop/application/operations/recording/pause.ts +0 -34
  169. package/apps/desktop/application/operations/recording/resume.ts +0 -34
  170. package/apps/desktop/application/operations/recording/shared.ts +0 -199
  171. package/apps/desktop/application/operations/recording/start.ts +0 -169
  172. package/apps/desktop/application/operations/recording/stop.ts +0 -34
  173. package/apps/desktop/capture/protocol.ts +0 -689
  174. package/apps/desktop/cli/capture-bundle.ts +0 -1117
  175. package/apps/desktop/cli/recording-controller.ts +0 -1567
  176. package/apps/desktop/cli/recording-daemon.ts +0 -931
  177. package/dist/index-42zsesc1.js +0 -2257
  178. package/dist/index-7308egqr.js +0 -48
  179. package/dist/index-8txs6fkn.js +0 -712
  180. package/dist/index-mcy8z0br.js +0 -192
  181. package/dist/index-p63wavx0.js +0 -2029
  182. package/dist/index-r7gdhmsp.js +0 -424
  183. package/dist/index-rvghzt7b.js +0 -2013
  184. package/dist/index-sh6xbav6.js +0 -1219
  185. package/dist/index-z1w83f81.js +0 -4
  186. package/dist/index-z7239b4h.js +0 -2363
  187. package/dist/skill-install-dh0ntqba.js +0 -10
  188. package/src/desktop.ts +0 -253
@@ -2,6 +2,7 @@ export * from "./auto-zoom";
2
2
  export * from "./audio-alignment";
3
3
  export * from "./alignment-apply";
4
4
  export * from "./canonical-json";
5
+ export * from "./cinema-plan";
5
6
  export * from "./emoji";
6
7
  export * from "./events";
7
8
  export * from "./face-framing";
@@ -89,7 +89,7 @@ function validatePcm(pcm: MonoPcm): void {
89
89
  }
90
90
  }
91
91
 
92
- function fftPowerSpectrum(samples: readonly number[]): Float64Array {
92
+ export function fftPowerSpectrum(samples: readonly number[]): Float64Array {
93
93
  let size = 1;
94
94
  while (size < samples.length) size *= 2;
95
95
  const real = new Float64Array(size);
@@ -145,6 +145,129 @@ function fftPowerSpectrum(samples: readonly number[]): Float64Array {
145
145
  return power;
146
146
  }
147
147
 
148
+ export const AUDIO_REACTIVITY_PROFILE = "bands-v1" as const;
149
+ export const AUDIO_REACTIVITY_SAMPLE_RATE_HZ = 24_000;
150
+ export const AUDIO_REACTIVITY_RATE_HZ = 60;
151
+ export const AUDIO_REACTIVITY_MAX_DURATION_US = 600_000_000;
152
+
153
+ const AUDIO_REACTIVITY_WINDOW_SAMPLES = 2_048;
154
+ const AUDIO_REACTIVITY_HOP_SAMPLES = AUDIO_REACTIVITY_SAMPLE_RATE_HZ / AUDIO_REACTIVITY_RATE_HZ;
155
+ const AUDIO_REACTIVITY_MAXIMUM_SAMPLES = AUDIO_REACTIVITY_MAX_DURATION_US * AUDIO_REACTIVITY_SAMPLE_RATE_HZ / 1_000_000;
156
+ const AUDIO_REACTIVITY_MAXIMUM_PCM_AMPLITUDE = 16;
157
+ // Suppress quiet input below -80 dBFS RMS, and band leakage below 2% of the
158
+ // simultaneous broadband RMS. Subtracting the floor avoids a hard onset step.
159
+ const AUDIO_REACTIVITY_ABSOLUTE_RMS_FLOOR = 0.0001;
160
+ const AUDIO_REACTIVITY_LEAKAGE_RMS_RATIO = 0.02;
161
+ const AUDIO_REACTIVITY_FLAT_RANGE_RATIO = 0.001;
162
+ const AUDIO_REACTIVITY_ATTACK_SECONDS = 0.03;
163
+ const AUDIO_REACTIVITY_RELEASE_SECONDS = 0.18;
164
+ // The FFT already applies a symmetric Hann window and divides power by N².
165
+ // Correct its mean squared gain when recovering one-sided RMS energy.
166
+ const AUDIO_REACTIVITY_HANN_POWER_GAIN = 3 * (AUDIO_REACTIVITY_WINDOW_SAMPLES - 1) / (8 * AUDIO_REACTIVITY_WINDOW_SAMPLES);
167
+
168
+ export interface AudioReactivityBands {
169
+ readonly bass: readonly number[];
170
+ readonly mid: readonly number[];
171
+ readonly treble: readonly number[];
172
+ readonly energy: readonly number[];
173
+ }
174
+
175
+ type MutableAudioReactivityBands = { [Band in keyof AudioReactivityBands]: number[] };
176
+ const AUDIO_REACTIVITY_BAND_NAMES = ["bass", "mid", "treble", "energy"] as const;
177
+
178
+ function audioReactivityPercentile(ordered: readonly number[], fraction: number): number {
179
+ const position = (ordered.length - 1) * fraction;
180
+ const left = Math.floor(position);
181
+ const a = ordered[left]!;
182
+ return a + ((ordered[Math.min(left + 1, ordered.length - 1)] ?? a) - a) * (position - left);
183
+ }
184
+
185
+ function normalizeAndSmoothAudioReactivity(values: readonly number[], energy: readonly number[], band: boolean): readonly number[] {
186
+ const gated = values.map((value, index) => Math.max(0, value - Math.max(
187
+ AUDIO_REACTIVITY_ABSOLUTE_RMS_FLOOR,
188
+ band ? energy[index]! * AUDIO_REACTIVITY_LEAKAGE_RMS_RATIO : 0,
189
+ )));
190
+ const ordered = [...gated].sort((left, right) => left - right);
191
+ let low = audioReactivityPercentile(ordered, 0.05);
192
+ let high = audioReactivityPercentile(ordered, 0.95);
193
+ // Events occupying less than 5% of the track still have a useful range.
194
+ if (high <= EPSILON) high = ordered.at(-1)!;
195
+ // A sustained tone has energy even when its useful percentile span is flat.
196
+ if (high - low <= Math.max(EPSILON, high * AUDIO_REACTIVITY_FLAT_RANGE_RATIO)) low = 0;
197
+ const span = high - low;
198
+ const attack = 1 - Math.exp(-1 / (AUDIO_REACTIVITY_RATE_HZ * AUDIO_REACTIVITY_ATTACK_SECONDS));
199
+ const release = 1 - Math.exp(-1 / (AUDIO_REACTIVITY_RATE_HZ * AUDIO_REACTIVITY_RELEASE_SECONDS));
200
+ let previous = 0;
201
+ const result = gated.map(value => {
202
+ const target = span <= EPSILON ? 0 : Math.min(1, Math.max(0, (value - low) / span));
203
+ previous += (target - previous) * (target > previous ? attack : release);
204
+ return Math.min(1, Math.max(0, previous));
205
+ });
206
+ // This endpoint is at the exact PCM duration, not the next regular grid tick.
207
+ result.push(0);
208
+ return Object.freeze(result);
209
+ }
210
+
211
+ /**
212
+ * Analyze already-decoded mono PCM on a fixed absolute timeline. Each band has
213
+ * ceil(sampleCount / 400) samples at i / 60 seconds, then a zero endpoint at
214
+ * durationUs. Windows are centered with zero padding; their 42.7 ms lookahead
215
+ * and offline normalization are intentional. This detects energy, not beats.
216
+ */
217
+ export function analyzeAudioReactivity(pcm: {
218
+ readonly sampleRateHz: number;
219
+ readonly samples: readonly number[] | Float32Array;
220
+ }): { readonly durationUs: number; readonly bands: AudioReactivityBands } {
221
+ if (pcm.sampleRateHz !== AUDIO_REACTIVITY_SAMPLE_RATE_HZ) {
222
+ throw new TypeError(`Audio reactivity PCM must use exactly ${AUDIO_REACTIVITY_SAMPLE_RATE_HZ} Hz.`);
223
+ }
224
+ if ((!Array.isArray(pcm.samples) && !(pcm.samples instanceof Float32Array))
225
+ || pcm.samples.length === 0 || pcm.samples.length > AUDIO_REACTIVITY_MAXIMUM_SAMPLES) {
226
+ throw new TypeError(`Audio reactivity PCM must contain 1–${AUDIO_REACTIVITY_MAXIMUM_SAMPLES} samples.`);
227
+ }
228
+ let nonzero = false;
229
+ for (let index = 0; index < pcm.samples.length; index++) {
230
+ const sample = pcm.samples[index]!;
231
+ if (!Number.isFinite(sample) || Math.abs(sample) > AUDIO_REACTIVITY_MAXIMUM_PCM_AMPLITUDE) {
232
+ throw new TypeError(`Audio reactivity PCM samples must be finite numbers within ±${AUDIO_REACTIVITY_MAXIMUM_PCM_AMPLITUDE}.`);
233
+ }
234
+ nonzero ||= sample !== 0;
235
+ }
236
+ const durationUs = Math.round(pcm.samples.length * 1_000_000 / AUDIO_REACTIVITY_SAMPLE_RATE_HZ);
237
+ const count = Math.ceil(pcm.samples.length / AUDIO_REACTIVITY_HOP_SAMPLES);
238
+ if (!nonzero) {
239
+ const silence = Object.freeze(Array<number>(count + 1).fill(0));
240
+ return Object.freeze({ durationUs, bands: Object.freeze({ bass: silence, mid: silence, treble: silence, energy: silence }) });
241
+ }
242
+ const raw: MutableAudioReactivityBands = { bass: [], mid: [], treble: [], energy: [] };
243
+ const window = Array<number>(AUDIO_REACTIVITY_WINDOW_SAMPLES).fill(0);
244
+ for (let frame = 0; frame < count; frame++) {
245
+ const start = frame * AUDIO_REACTIVITY_HOP_SAMPLES - AUDIO_REACTIVITY_WINDOW_SAMPLES / 2;
246
+ for (let index = 0; index < AUDIO_REACTIVITY_WINDOW_SAMPLES; index++) {
247
+ const source = start + index;
248
+ window[index] = source < 0 || source >= pcm.samples.length ? 0 : pcm.samples[source]!;
249
+ }
250
+ const spectrum = fftPowerSpectrum(window);
251
+ const power = { bass: 0, mid: 0, treble: 0, energy: 0 };
252
+ for (let bin = 0; bin < spectrum.length; bin++) {
253
+ const frequencyHz = bin * AUDIO_REACTIVITY_SAMPLE_RATE_HZ / AUDIO_REACTIVITY_WINDOW_SAMPLES;
254
+ const value = spectrum[bin]! * (bin === 0 || bin === spectrum.length - 1 ? 1 : 2) / AUDIO_REACTIVITY_HANN_POWER_GAIN;
255
+ power.energy += value;
256
+ if (frequencyHz >= 35 && frequencyHz < 180) power.bass += value;
257
+ else if (frequencyHz >= 180 && frequencyHz < 2_000) power.mid += value;
258
+ else if (frequencyHz >= 2_000 && frequencyHz <= 12_000) power.treble += value;
259
+ }
260
+ for (const name of AUDIO_REACTIVITY_BAND_NAMES) raw[name].push(Math.sqrt(power[name]));
261
+ }
262
+ const bands = Object.freeze({
263
+ bass: normalizeAndSmoothAudioReactivity(raw.bass, raw.energy, true),
264
+ mid: normalizeAndSmoothAudioReactivity(raw.mid, raw.energy, true),
265
+ treble: normalizeAndSmoothAudioReactivity(raw.treble, raw.energy, true),
266
+ energy: normalizeAndSmoothAudioReactivity(raw.energy, raw.energy, false),
267
+ });
268
+ return Object.freeze({ durationUs, bands });
269
+ }
270
+
148
271
  /** Fold pitched energy from C2 through B6 into twelve pitch classes. */
149
272
  export function computeChroma(frame: readonly number[], sampleRateHz: number): readonly number[] {
150
273
  if (!Number.isSafeInteger(sampleRateHz) || sampleRateHz < 8_000 || sampleRateHz > 192_000) {
@@ -18,6 +18,7 @@ import {
18
18
  FaceAnalysisV1Schema,
19
19
  MusicAnalysisV1Schema,
20
20
  ProjectInactivityAnalysisV1Schema,
21
+ ProjectCinemaPlanV1Schema,
21
22
  ProjectEditPlanV1Schema,
22
23
  RecordingManifestV1Schema,
23
24
  RepositoryRelativePathSchema,
@@ -30,6 +31,7 @@ import {
30
31
  type FaceAnalysisV1,
31
32
  type MusicAnalysisV1,
32
33
  type ProjectInactivityAnalysisV1,
34
+ type ProjectCinemaPlanV1,
33
35
  type ProjectEditPlanV1,
34
36
  type RecordingManifestV1,
35
37
  type SceneAnalysisV1,
@@ -41,6 +43,7 @@ import { canonicalJson, sha256Hex } from "./canonical-json";
41
43
  export const RECORDING_MANIFEST_PATH = "manifest.json";
42
44
  export const VIDEO_PROJECT_PATH = "project.json";
43
45
  export const CURRENT_PROJECT_EDIT_PLAN_PATH = "edits/current.json";
46
+ export const CURRENT_PROJECT_CINEMA_PLAN_PATH = "cinema/current.json";
44
47
  const MAXIMUM_STRUCTURED_FILE_BYTES = 256 * 1024 * 1024;
45
48
  const IMMUTABLE_COPY_TEMP_PREFIX = ".slopcamera-copy-";
46
49
 
@@ -89,6 +92,7 @@ export interface BundleFileIntegrity {
89
92
  export type SlopcameraPersistenceDocument =
90
93
  | AnalysisArtifact
91
94
  | EditPlanV1
95
+ | ProjectCinemaPlanV1
92
96
  | ProjectEditPlanV1
93
97
  | RecordingManifestV1
94
98
  | VideoProjectV1;
@@ -264,6 +268,77 @@ export async function saveProjectEditRevision(
264
268
  return path;
265
269
  }
266
270
 
271
+ export function projectCinemaRevisionPath(artifactSha256: string): string {
272
+ const sha256 = Sha256Schema.parse(artifactSha256);
273
+ return RepositoryRelativePathSchema.parse(`cinema/revisions/${sha256}.json`);
274
+ }
275
+
276
+ export function projectCinemaRenderPlanPath(planSha256: string): string {
277
+ const sha256 = Sha256Schema.parse(planSha256);
278
+ return RepositoryRelativePathSchema.parse(`cinema/plans/${sha256}.json`);
279
+ }
280
+
281
+ /**
282
+ * Load the live cinema sidecar, or null when the project has none. Its absence
283
+ * is meaningful: project render planning must stay byte-identical without it.
284
+ */
285
+ export async function loadProjectCinemaPlan(
286
+ fileSystem: BundleFileSystem,
287
+ path = CURRENT_PROJECT_CINEMA_PLAN_PATH,
288
+ ): Promise<ProjectCinemaPlanV1 | null> {
289
+ RepositoryRelativePathSchema.parse(path);
290
+ let text: string;
291
+ try {
292
+ text = await fileSystem.readText(path);
293
+ } catch (error) {
294
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
295
+ throw error;
296
+ }
297
+ const input: unknown = JSON.parse(text);
298
+ return ProjectCinemaPlanV1Schema.parse(input);
299
+ }
300
+
301
+ export async function saveProjectCinemaPlan(
302
+ fileSystem: BundleFileSystem,
303
+ plan: ProjectCinemaPlanV1,
304
+ path = CURRENT_PROJECT_CINEMA_PLAN_PATH,
305
+ ): Promise<void> {
306
+ RepositoryRelativePathSchema.parse(path);
307
+ const parsed = ProjectCinemaPlanV1Schema.parse(canonicalSlopcameraPersistenceDocument(
308
+ ProjectCinemaPlanV1Schema.parse(plan),
309
+ ));
310
+ await fileSystem.writeTextAtomic(path, `${canonicalJson(parsed)}\n`);
311
+ }
312
+
313
+ /** Publish one content-addressed cinema sidecar revision without replacement. */
314
+ export async function saveProjectCinemaRevision(
315
+ fileSystem: BundleFileSystem,
316
+ contents: string,
317
+ expectedArtifactSha256: string,
318
+ ): Promise<string> {
319
+ const sha256 = Sha256Schema.parse(expectedArtifactSha256);
320
+ const actualSha256 = sha256Hex(contents);
321
+ if (actualSha256 !== sha256) {
322
+ throw new Error(
323
+ `Project cinema revision artifact hash mismatch: expected ${sha256}, received ${actualSha256}.`,
324
+ );
325
+ }
326
+ if (fileSystem.writeTextNoReplace === undefined) {
327
+ throw new Error("Bundle file system does not support immutable no-replace publication.");
328
+ }
329
+ const path = projectCinemaRevisionPath(sha256);
330
+ const disposition = await fileSystem.writeTextNoReplace(path, contents);
331
+ const installed = await fileSystem.readText(path);
332
+ if (installed !== contents) {
333
+ throw new Error(
334
+ disposition === "exists"
335
+ ? `Project cinema revision path already contains different bytes: ${path}`
336
+ : `Published project cinema revision failed read-back verification: ${path}`,
337
+ );
338
+ }
339
+ return path;
340
+ }
341
+
267
342
  export type AnalysisArtifact =
268
343
  | AudioAlignmentAnalysisV1
269
344
  | FaceAnalysisV1