@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
@@ -0,0 +1,608 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+
4
+ import {
5
+ CinemaRenderInvocationSchema,
6
+ resolveProjectRenderEncoderRecipe,
7
+ type CinemaMediaSlice,
8
+ type CinemaRenderInvocation,
9
+ type CinemaRenderPlanV1,
10
+ type CinemaResolvedShot,
11
+ type CinemaResolvedTransition,
12
+ type ProjectRenderEncoderRecipe,
13
+ type ProjectRenderTier,
14
+ } from "../contracts";
15
+ import { CliError } from "./errors";
16
+ import { materializeFilterScript } from "./filter-script";
17
+ import { buildColorGradeFilter } from "./media-effects-service";
18
+ import {
19
+ resolveVerifiedProjectMedia,
20
+ verifyPhysicalProjectMedia,
21
+ type ExpectedProjectMediaIntegrity,
22
+ } from "./project-media-integrity";
23
+
24
+ function seconds(microseconds: number): string {
25
+ return (microseconds / 1_000_000).toFixed(6).replace(/0+$/u, "").replace(/\.$/u, "");
26
+ }
27
+
28
+ function decimal(value: number): string {
29
+ return Number.isInteger(value)
30
+ ? String(value)
31
+ : value.toFixed(10).replace(/0+$/u, "").replace(/\.$/u, "");
32
+ }
33
+
34
+ function isWithin(root: string, candidate: string): boolean {
35
+ const pathFromRoot = relative(root, candidate);
36
+ return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot));
37
+ }
38
+
39
+ function inputSpecifier(index: number, streamIndex: number): string {
40
+ return `${index}:${streamIndex}`;
41
+ }
42
+
43
+ function atempo(rate: number): string {
44
+ if (!Number.isFinite(rate) || rate <= 0) {
45
+ throw new CliError("unsupported-plan", "Audio tempo rate must be positive.");
46
+ }
47
+ const filters: string[] = [];
48
+ let remaining = rate;
49
+ while (remaining > 100) {
50
+ filters.push("atempo=100");
51
+ remaining /= 100;
52
+ }
53
+ while (remaining < 0.5) {
54
+ filters.push("atempo=0.5");
55
+ remaining /= 0.5;
56
+ }
57
+ filters.push(`atempo=${decimal(remaining)}`);
58
+ return filters.join(",");
59
+ }
60
+
61
+ function delayToOutput(startUs: number): string[] {
62
+ // amix consumes each input from its first sample; shifting timestamps alone
63
+ // does not place that input in the program. Insert silence at the output rate.
64
+ const samples = (BigInt(startUs) * 48_000n + 500_000n) / 1_000_000n;
65
+ return ["asetpts=PTS-STARTPTS", ...(samples === 0n ? [] : [`adelay=${samples}S:all=1`])];
66
+ }
67
+
68
+ const WIPE_XFADE = Object.freeze({
69
+ down: "wipedown",
70
+ left: "wipeleft",
71
+ right: "wiperight",
72
+ up: "wipeup",
73
+ } as const);
74
+
75
+ const WHIP_PAN_XFADE = Object.freeze({
76
+ down: "slidedown",
77
+ left: "slideleft",
78
+ right: "slideright",
79
+ up: "slideup",
80
+ } as const);
81
+
82
+ export interface CinemaRenderBuildOptions {
83
+ readonly dryRun?: boolean;
84
+ readonly ffmpeg: string;
85
+ readonly outputPath: string;
86
+ readonly projectDirectory: string;
87
+ readonly repositoryRoot: string;
88
+ /** Encoder recipe tier; animatics use "preview". */
89
+ readonly tier?: ProjectRenderTier;
90
+ /** Host-owned private root for materialized filter graphs. */
91
+ readonly workspaceDirectory?: string;
92
+ }
93
+
94
+ export interface CinemaRenderPinnedInput extends ExpectedProjectMediaIntegrity {
95
+ readonly label: string;
96
+ readonly path: string;
97
+ }
98
+
99
+ export interface BuiltCinemaRenderInvocation {
100
+ readonly argv: readonly [string, ...string[]];
101
+ readonly invocation: CinemaRenderInvocation;
102
+ readonly pinnedInputs: readonly CinemaRenderPinnedInput[];
103
+ }
104
+
105
+ export async function reverifyCinemaRenderInputs(
106
+ inputs: readonly CinemaRenderPinnedInput[],
107
+ ): Promise<void> {
108
+ await Promise.all(inputs.map(async input => {
109
+ await verifyPhysicalProjectMedia(input.path, input, input.label);
110
+ }));
111
+ }
112
+
113
+ function cinemaEncoderArguments(recipe: ProjectRenderEncoderRecipe): readonly string[] {
114
+ return [
115
+ "-threads:v", String(recipe.video.threads),
116
+ "-preset", recipe.video.preset,
117
+ "-crf", String(recipe.video.crf),
118
+ "-b:a", recipe.audio.bitrate,
119
+ ];
120
+ }
121
+
122
+ function cinemaThreadArguments(recipe: ProjectRenderEncoderRecipe): readonly string[] {
123
+ return [
124
+ "-filter_threads", String(recipe.filterThreads),
125
+ "-filter_complex_threads", String(recipe.filterComplexThreads),
126
+ ];
127
+ }
128
+
129
+ interface GraphContext {
130
+ readonly filters: string[];
131
+ readonly inputIndex: ReadonlyMap<string, number>;
132
+ readonly rate: string;
133
+ serial: number;
134
+ }
135
+
136
+ function label(context: GraphContext, prefix: string): string {
137
+ return `${prefix}_${context.serial++}`;
138
+ }
139
+
140
+ function sliceChain(slice: CinemaMediaSlice, inputIndex: ReadonlyMap<string, number>): string {
141
+ const input = inputIndex.get(slice.path)!;
142
+ const inputDurationUs = slice.fileRange.endUs - slice.fileRange.startUs;
143
+ const outputDurationUs = slice.outputRange.endUs - slice.outputRange.startUs;
144
+ return [
145
+ `[${inputSpecifier(input, slice.streamIndex)}]`,
146
+ `trim=start=${seconds(slice.fileRange.startUs)}:end=${seconds(slice.fileRange.endUs)}`,
147
+ "settb=AVTB",
148
+ // Source clocks may be coarse; cinema offsets stay in integer microseconds.
149
+ `setpts=(PTS-STARTPTS)*${decimal(outputDurationUs / inputDurationUs)}+${seconds(slice.outputRange.startUs)}/TB`,
150
+ "format=rgba",
151
+ ].join(",");
152
+ }
153
+
154
+ /** Concatenate the declared slices of one window into a cinema-clock stream. */
155
+ function windowChain(
156
+ context: GraphContext,
157
+ slices: readonly CinemaMediaSlice[],
158
+ fit: CinemaResolvedShot["fit"],
159
+ gradeFilter: string | undefined,
160
+ outputWidth: number,
161
+ outputHeight: number,
162
+ prefix: string,
163
+ ): string {
164
+ const parts: string[] = [];
165
+ const members: string[] = [];
166
+ for (const slice of slices) {
167
+ const member = label(context, `${prefix}_part`);
168
+ parts.push(`${sliceChain(slice, context.inputIndex)}[${member}]`);
169
+ members.push(member);
170
+ }
171
+ const geometry = fit === "cover"
172
+ ? `scale=w=${outputWidth}:h=${outputHeight}:force_original_aspect_ratio=increase,crop=${outputWidth}:${outputHeight}`
173
+ : `scale=w=${outputWidth}:h=${outputHeight}:force_original_aspect_ratio=decrease,pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:color=black@0`;
174
+ const joined = label(context, prefix);
175
+ parts.push(
176
+ `${members.map(member => `[${member}]`).join("")}concat=n=${members.length}:v=1:a=0,${geometry},format=rgba${gradeFilter === undefined ? "" : `,${gradeFilter}`}[${joined}]`,
177
+ );
178
+ context.filters.push(...parts);
179
+ return joined;
180
+ }
181
+
182
+ /** A placeholder shot is an explicit deterministic color source on the cinema clock. */
183
+ function placeholderChain(
184
+ context: GraphContext,
185
+ shot: CinemaResolvedShot,
186
+ startUs: number,
187
+ endUs: number,
188
+ outputWidth: number,
189
+ outputHeight: number,
190
+ ): string {
191
+ const color = shot.placeholder!.color;
192
+ const joined = label(context, "shot_placeholder");
193
+ context.filters.push(
194
+ `color=c=${color}:s=${outputWidth}x${outputHeight}:r=${context.rate}:d=${seconds(endUs - startUs)},format=rgba,setpts=PTS+${seconds(startUs)}/TB[${joined}]`,
195
+ );
196
+ return joined;
197
+ }
198
+
199
+ /**
200
+ * Compile one bounded transition into an owned filter recipe. Every branch is
201
+ * a typed composition of FFmpeg primitives; caller text never reaches a filter.
202
+ */
203
+ function transitionRecipe(transition: CinemaResolvedTransition): readonly string[] {
204
+ switch (transition.kind) {
205
+ case "dissolve":
206
+ return [`xfade=transition=fade:duration=${seconds(transition.durationUs)}:offset=0`];
207
+ case "wipe":
208
+ return [`xfade=transition=${WIPE_XFADE[transition.direction]}:duration=${seconds(transition.durationUs)}:offset=0`];
209
+ case "whip-pan":
210
+ return [
211
+ `xfade=transition=${WHIP_PAN_XFADE[transition.direction]}:duration=${seconds(transition.durationUs)}:offset=0`,
212
+ `gblur=sigma=${decimal(transition.blurSigma)}`,
213
+ ];
214
+ default:
215
+ throw new CliError("unsupported-plan", `Transition ${transition.kind} has no direct recipe.`);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Emit the transition window stream covering [startUs, endUs). Multi-stage
221
+ * recipes (dip-to-color, light-flash) concat owned sub-transitions; a retained
222
+ * spatial clip streams directly; cut and match-cut produce no stream.
223
+ */
224
+ function transitionWindowChain(
225
+ context: GraphContext,
226
+ transition: CinemaResolvedTransition,
227
+ before: CinemaResolvedShot,
228
+ after: CinemaResolvedShot,
229
+ gradeOf: (shot: CinemaResolvedShot) => string | undefined,
230
+ outputWidth: number,
231
+ outputHeight: number,
232
+ ): string | null {
233
+ if (transition.kind === "cut" || transition.kind === "match-cut") return null;
234
+ const startUs = transition.outputRange.startUs;
235
+ const endUs = transition.outputRange.endUs;
236
+ const durationUs = transition.durationUs;
237
+
238
+ const windowOf = (
239
+ shot: CinemaResolvedShot,
240
+ slices: readonly CinemaMediaSlice[],
241
+ prefix: string,
242
+ limitUs?: number,
243
+ ): string => {
244
+ const grade = gradeOf(shot);
245
+ let stream: string;
246
+ if (slices.length === 0) {
247
+ if (shot.placeholder === undefined) {
248
+ throw new CliError(
249
+ "invalid-data",
250
+ `Shot ${shot.shotId} has no media covering a required transition window.`,
251
+ );
252
+ }
253
+ stream = placeholderChain(context, shot, startUs, endUs, outputWidth, outputHeight);
254
+ } else {
255
+ stream = windowChain(context, slices, shot.fit, grade, outputWidth, outputHeight, prefix);
256
+ }
257
+ const zeroed = label(context, `${prefix}_zero`);
258
+ context.filters.push(
259
+ `[${stream}]setpts=PTS-STARTPTS,fps=${context.rate}${limitUs === undefined ? "" : `,trim=end=${seconds(limitUs)},setpts=PTS-STARTPTS`}[${zeroed}]`,
260
+ );
261
+ return zeroed;
262
+ };
263
+
264
+ const joinSegments = (segments: readonly string[], prefix: string): string => {
265
+ if (segments.length === 1) return segments[0]!;
266
+ const joined = label(context, prefix);
267
+ context.filters.push(
268
+ `${segments.map(segment => `[${segment}]`).join("")}concat=n=${segments.length}:v=1:a=0[${joined}]`,
269
+ );
270
+ return joined;
271
+ };
272
+
273
+ const xfade = (first: string, second: string, durationText: string, name: string, prefix: string): string => {
274
+ const joined = label(context, prefix);
275
+ context.filters.push(`[${first}][${second}]xfade=transition=${name}:duration=${durationText}:offset=0[${joined}]`);
276
+ return joined;
277
+ };
278
+
279
+ const colorSource = (color: string, durationText: string, prefix: string): string => {
280
+ const stream = label(context, prefix);
281
+ context.filters.push(
282
+ `color=c=${color}:s=${outputWidth}x${outputHeight}:r=${context.rate}:d=${durationText},format=rgba[${stream}]`,
283
+ );
284
+ return stream;
285
+ };
286
+
287
+ let body: string;
288
+ if (transition.kind === "spatial") {
289
+ const clipIndex = context.inputIndex.get(transition.clip.path)!;
290
+ const stream = label(context, "transition_spatial");
291
+ context.filters.push(
292
+ `[${clipIndex}:v:0]trim=start=0:end=${seconds(durationUs)},settb=AVTB,setpts=PTS-STARTPTS,fps=${context.rate},scale=w=${outputWidth}:h=${outputHeight}:force_original_aspect_ratio=decrease,pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:color=black@0,format=rgba[${stream}]`,
293
+ );
294
+ body = stream;
295
+ } else {
296
+ const a = windowOf(before, before.video.outHandle, "transition_out");
297
+ if (transition.kind === "dip-to-color") {
298
+ const firstHalfUs = Math.floor(durationUs / 2);
299
+ const secondHalfUs = durationUs - firstHalfUs;
300
+ const b = windowOf(after, after.video.inHandle, "transition_in", secondHalfUs);
301
+ const first = xfade(
302
+ a,
303
+ colorSource(transition.color, seconds(firstHalfUs), "dip_color_a"),
304
+ seconds(firstHalfUs),
305
+ "fade",
306
+ "dip_a",
307
+ );
308
+ const second = xfade(
309
+ colorSource(transition.color, seconds(secondHalfUs), "dip_color_b"),
310
+ b,
311
+ seconds(secondHalfUs),
312
+ "fade",
313
+ "dip_b",
314
+ );
315
+ body = joinSegments([first, second], "dip_joined");
316
+ } else if (transition.kind === "light-flash") {
317
+ const rampUs = Math.floor((durationUs - transition.peakHoldUs) / 2);
318
+ const tailUs = durationUs - transition.peakHoldUs - rampUs;
319
+ const b = windowOf(after, after.video.inHandle, "transition_in", tailUs);
320
+ const segments = [
321
+ xfade(a, colorSource("white", seconds(rampUs), "flash_color_a"), seconds(rampUs), "fade", "flash_a"),
322
+ ...(transition.peakHoldUs === 0
323
+ ? []
324
+ : [colorSource("white", seconds(transition.peakHoldUs), "flash_hold")]),
325
+ xfade(colorSource("white", seconds(tailUs), "flash_color_b"), b, seconds(tailUs), "fade", "flash_b"),
326
+ ];
327
+ body = joinSegments(segments, "flash_joined");
328
+ } else {
329
+ const b = windowOf(after, after.video.inHandle, "transition_in");
330
+ const recipe = transitionRecipe(transition);
331
+ const blended = label(context, "transition_blend");
332
+ context.filters.push(`[${a}][${b}]${recipe.join(",")}[${blended}]`);
333
+ body = blended;
334
+ }
335
+ }
336
+ const positioned = label(context, "transition_window");
337
+ context.filters.push(`[${body}]setpts=PTS+${seconds(startUs)}/TB[${positioned}]`);
338
+ return positioned;
339
+ }
340
+
341
+ /**
342
+ * Build a deterministic FFmpeg invocation for a compiled cinema render plan.
343
+ * Pure planning stays in `core/cinema-plan.ts`; this builder owns argv, graph
344
+ * materialization, and media re-verification only.
345
+ */
346
+ export async function buildCinemaFfmpegInvocation(
347
+ plan: CinemaRenderPlanV1,
348
+ options: CinemaRenderBuildOptions,
349
+ ): Promise<BuiltCinemaRenderInvocation> {
350
+ if (plan.output.durationUs <= 0) {
351
+ throw new CliError("unsupported-plan", "A zero-duration cinema sequence cannot render.");
352
+ }
353
+ const recipe = resolveProjectRenderEncoderRecipe(options.tier ?? "preview");
354
+ const emittedRate = decimal(plan.output.frameRate);
355
+ const projectRoot = await realpath(options.projectDirectory);
356
+ const output = resolve(options.outputPath);
357
+ if (!isWithin(projectRoot, output)) {
358
+ throw new CliError("unsafe-path", "Cinema render output must remain in its project directory.");
359
+ }
360
+ const outputRelative = relative(projectRoot, output);
361
+ if (!outputRelative.startsWith("renders/") || isAbsolute(outputRelative)) {
362
+ throw new CliError("unsafe-path", "Cinema render output must remain under renders/.");
363
+ }
364
+
365
+ const mediaIntegrity = new Map<string, ExpectedProjectMediaIntegrity>();
366
+ const register = (path: string, expected: ExpectedProjectMediaIntegrity): void => {
367
+ const prior = mediaIntegrity.get(path);
368
+ if (prior !== undefined && (prior.bytes !== expected.bytes || prior.sha256 !== expected.sha256)) {
369
+ throw new CliError("invalid-data", `Cinema slices disagree about media integrity for ${path}.`);
370
+ }
371
+ mediaIntegrity.set(path, expected);
372
+ };
373
+ for (const shot of plan.shots) {
374
+ for (const slice of [
375
+ ...shot.video.inHandle,
376
+ ...shot.video.body,
377
+ ...shot.video.outHandle,
378
+ ...(shot.audio === undefined ? [] : [
379
+ ...shot.audio.media.inHandle,
380
+ ...shot.audio.media.body,
381
+ ...shot.audio.media.outHandle,
382
+ ]),
383
+ ]) {
384
+ register(slice.path, { bytes: slice.bytes, sha256: slice.sha256 });
385
+ }
386
+ }
387
+ for (const cue of plan.audioCues) register(cue.path, { bytes: cue.bytes, sha256: cue.sha256 });
388
+ for (const transition of plan.transitions) {
389
+ if (transition.kind === "spatial") {
390
+ register(transition.clip.path, { bytes: transition.clip.bytes, sha256: transition.clip.sha256 });
391
+ }
392
+ }
393
+
394
+ const inputIndex = new Map<string, number>();
395
+ const inputArguments: string[] = [];
396
+ const pinnedInputs: CinemaRenderPinnedInput[] = [];
397
+ for (const [path, expected] of mediaIntegrity) {
398
+ inputIndex.set(path, inputIndex.size);
399
+ const spatialOutput = /^spatial\/outputs\/[a-f0-9]{64}\.[a-z0-9]+$/u.test(path);
400
+ const physical = await resolveVerifiedProjectMedia({
401
+ expected,
402
+ label: `Cinema media ${path}`,
403
+ path,
404
+ repositoryRoot: spatialOutput ? projectRoot : options.repositoryRoot,
405
+ });
406
+ inputArguments.push("-threads", String(recipe.decoderThreads), "-i", physical);
407
+ pinnedInputs.push({ ...expected, label: `Cinema media ${path}`, path: physical });
408
+ }
409
+
410
+ const context: GraphContext = {
411
+ filters: [
412
+ `color=c=${plan.output.background}:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:r=${emittedRate}:d=${seconds(plan.output.durationUs)},format=rgba[canvas_0]`,
413
+ ],
414
+ inputIndex,
415
+ rate: emittedRate,
416
+ serial: 0,
417
+ };
418
+ const width = plan.output.pixelWidth;
419
+ const height = plan.output.pixelHeight;
420
+ const gradeByShot = new Map<string, string>();
421
+ for (const look of plan.looks) {
422
+ gradeByShot.set(look.lookId, buildColorGradeFilter({
423
+ grade: look.grade,
424
+ kind: "slopcamera.color-grade-transform",
425
+ outputProfile: "preserve",
426
+ schemaVersion: 1,
427
+ videoStreamIndex: 0,
428
+ }).filter);
429
+ }
430
+ const gradeOf = (shot: CinemaResolvedShot): string | undefined => (
431
+ shot.lookId === undefined ? undefined : gradeByShot.get(shot.lookId)
432
+ );
433
+
434
+ let currentVideo = "canvas_0";
435
+ const overlay = (stream: string, startUs: number, endUs: number): void => {
436
+ const next = label(context, "canvas");
437
+ context.filters.push(
438
+ `[${currentVideo}][${stream}]overlay=x=0:y=0:eof_action=repeat:repeatlast=1:enable='gte(t,${seconds(startUs)})*lt(t,${seconds(endUs)})'[${next}]`,
439
+ );
440
+ currentVideo = next;
441
+ };
442
+
443
+ for (const [index, shot] of plan.shots.entries()) {
444
+ const body = shot.placeholder === undefined
445
+ ? windowChain(context, shot.video.body, shot.fit, gradeOf(shot), width, height, "shot_body")
446
+ : placeholderChain(
447
+ context,
448
+ shot,
449
+ shot.outputRange.startUs,
450
+ shot.outputRange.endUs,
451
+ width,
452
+ height,
453
+ );
454
+ overlay(body, shot.outputRange.startUs, shot.outputRange.endUs);
455
+
456
+ const transition = plan.transitions[index];
457
+ if (transition !== undefined) {
458
+ const segment = transitionWindowChain(
459
+ context,
460
+ transition,
461
+ shot,
462
+ plan.shots[index + 1]!,
463
+ gradeOf,
464
+ width,
465
+ height,
466
+ );
467
+ if (segment !== null && "outputRange" in transition) {
468
+ overlay(segment, transition.outputRange.startUs, transition.outputRange.endUs);
469
+ }
470
+ }
471
+ }
472
+ context.filters.push(`[${currentVideo}]format=yuv420p[video_out]`);
473
+
474
+ const audioLabels: string[] = [];
475
+ for (const shot of plan.shots) {
476
+ if (shot.audio === undefined) continue;
477
+ const slices = [
478
+ ...shot.audio.media.inHandle,
479
+ ...shot.audio.media.body,
480
+ ...shot.audio.media.outHandle,
481
+ ];
482
+ if (slices.length === 0) continue;
483
+ const parts: string[] = [];
484
+ const members: string[] = [];
485
+ for (const slice of slices) {
486
+ const member = label(context, "shot_audio_part");
487
+ const input = inputIndex.get(slice.path)!;
488
+ const inputDurationUs = slice.fileRange.endUs - slice.fileRange.startUs;
489
+ const outputDurationUs = slice.outputRange.endUs - slice.outputRange.startUs;
490
+ parts.push(
491
+ `[${inputSpecifier(input, slice.streamIndex)}]atrim=start=${seconds(slice.fileRange.startUs)}:end=${seconds(slice.fileRange.endUs)},asetpts=PTS-STARTPTS,aresample=48000,${atempo(inputDurationUs / outputDurationUs)},atrim=duration=${seconds(outputDurationUs)}[${member}]`,
492
+ );
493
+ members.push(member);
494
+ }
495
+ const joined = label(context, "shot_audio");
496
+ parts.push(
497
+ `${members.map(member => `[${member}]`).join("")}concat=n=${members.length}:v=0:a=1[${joined}]`,
498
+ );
499
+ // Handle regions crossfade inside each adjoining transition window; bodies
500
+ // stay at the placement gain. The extended stream starts at the pre-roll.
501
+ const extendedStartUs = shot.outputRange.startUs - shot.audio.media.inHandle.reduce(
502
+ (total, slice) => total + (slice.outputRange.endUs - slice.outputRange.startUs),
503
+ 0,
504
+ );
505
+ const inHandleUs = shot.audio.media.inHandle.reduce(
506
+ (total, slice) => total + (slice.outputRange.endUs - slice.outputRange.startUs),
507
+ 0,
508
+ );
509
+ const outHandleUs = shot.audio.media.outHandle.reduce(
510
+ (total, slice) => total + (slice.outputRange.endUs - slice.outputRange.startUs),
511
+ 0,
512
+ );
513
+ const bodyEndUs = inHandleUs + (shot.outputRange.endUs - shot.outputRange.startUs);
514
+ const shaped = label(context, "shot_audio_shaped");
515
+ parts.push(
516
+ `[${joined}]asetpts=PTS-STARTPTS${inHandleUs === 0 ? "" : `,afade=t=in:st=0:d=${seconds(inHandleUs)}`}${outHandleUs === 0 ? "" : `,afade=t=out:st=${seconds(bodyEndUs)}:d=${seconds(outHandleUs)}`},volume=${decimal(shot.audio.gainDb)}dB,aformat=channel_layouts=stereo${shot.audio.pan === 0 ? "" : `,stereotools=balance_out=${decimal(shot.audio.pan)}`}[${shaped}]`,
517
+ );
518
+ const delayed = label(context, "shot_audio_out");
519
+ parts.push(`[${shaped}]${delayToOutput(extendedStartUs).join(",")}[${delayed}]`);
520
+ context.filters.push(...parts);
521
+ audioLabels.push(delayed);
522
+ }
523
+
524
+ const cueById = new Map<string, CinemaRenderPlanV1["audioCues"][number][]>();
525
+ for (const cue of plan.audioCues) {
526
+ const group = cueById.get(cue.cueId) ?? [];
527
+ group.push(cue);
528
+ cueById.set(cue.cueId, group);
529
+ }
530
+ for (const [cueId, slices] of [...cueById.entries()].sort((left, right) => (
531
+ left[0].localeCompare(right[0])
532
+ ))) {
533
+ const ordered = [...slices].sort((left, right) => left.outputRange.startUs - right.outputRange.startUs);
534
+ const parts: string[] = [];
535
+ const members: string[] = [];
536
+ for (const slice of ordered) {
537
+ const member = label(context, "cue_part");
538
+ const input = inputIndex.get(slice.path)!;
539
+ parts.push(
540
+ `[${inputSpecifier(input, slice.streamIndex)}]atrim=start=${seconds(slice.fileRange.startUs)}:end=${seconds(slice.fileRange.endUs)},asetpts=PTS-STARTPTS,aresample=48000,atrim=duration=${seconds(slice.outputRange.endUs - slice.outputRange.startUs)}[${member}]`,
541
+ );
542
+ members.push(member);
543
+ }
544
+ const first = ordered[0]!;
545
+ const cueDurationUs = ordered.reduce(
546
+ (total, slice) => total + (slice.outputRange.endUs - slice.outputRange.startUs),
547
+ 0,
548
+ );
549
+ const joined = label(context, `cue_${cueId.replaceAll(/[^a-zA-Z0-9_]/g, "_")}`);
550
+ const fades = [
551
+ first.fadeInUs === 0 ? "" : `,afade=t=in:st=0:d=${seconds(first.fadeInUs)}`,
552
+ first.fadeOutUs === 0
553
+ ? ""
554
+ : `,afade=t=out:st=${seconds(cueDurationUs - first.fadeOutUs)}:d=${seconds(first.fadeOutUs)}`,
555
+ ].join("");
556
+ parts.push(
557
+ `${members.map(member => `[${member}]`).join("")}concat=n=${members.length}:v=0:a=1,asetpts=PTS-STARTPTS${fades},volume=${decimal(first.gainDb)}dB,aformat=channel_layouts=stereo[${joined}]`,
558
+ );
559
+ const delayed = label(context, "cue_out");
560
+ parts.push(`[${joined}]${delayToOutput(first.outputRange.startUs).join(",")}[${delayed}]`);
561
+ context.filters.push(...parts);
562
+ audioLabels.push(delayed);
563
+ }
564
+
565
+ context.filters.push(`anullsrc=r=48000:cl=stereo:d=${seconds(plan.output.durationUs)}[silence]`);
566
+ context.filters.push(
567
+ `[silence]${audioLabels.map(item => `[${item}]`).join("")}amix=inputs=${audioLabels.length + 1}:duration=longest:dropout_transition=0:normalize=0,atrim=duration=${seconds(plan.output.durationUs)}[audio_out]`,
568
+ );
569
+
570
+ const filterGraph = await materializeFilterScript({
571
+ graph: context.filters.join(";"),
572
+ relativeDirectory: options.workspaceDirectory === undefined
573
+ ? "renders/.filter-graphs"
574
+ : "filter-graphs",
575
+ root: options.workspaceDirectory ?? projectRoot,
576
+ });
577
+ const arguments_: string[] = [
578
+ "-hide_banner", "-nostdin", "-y",
579
+ ...cinemaThreadArguments(recipe),
580
+ ...inputArguments,
581
+ "-filter_complex_script", filterGraph.path,
582
+ "-map", "[video_out]",
583
+ "-map", "[audio_out]",
584
+ "-c:v", recipe.video.codec,
585
+ ...cinemaEncoderArguments(recipe),
586
+ "-pix_fmt", recipe.video.pixelFormat,
587
+ "-c:a", recipe.audio.codec,
588
+ "-t", seconds(plan.output.durationUs),
589
+ "-movflags", recipe.container.movflags,
590
+ output,
591
+ ];
592
+ const invocation = CinemaRenderInvocationSchema.parse({
593
+ arguments: arguments_,
594
+ executable: "ffmpeg",
595
+ filterGraph: {
596
+ bytes: filterGraph.bytes,
597
+ path: filterGraph.repositoryPath,
598
+ sha256: filterGraph.sha256,
599
+ },
600
+ outputPath: outputRelative,
601
+ renderPlanSha256: plan.planSha256,
602
+ });
603
+ return {
604
+ argv: [options.ffmpeg, ...arguments_],
605
+ invocation,
606
+ pinnedInputs,
607
+ };
608
+ }
@@ -168,6 +168,16 @@ export function commandHostResourceClaims(
168
168
  "project-render",
169
169
  ])
170
170
  : claims(coordinator, ["cpu", "local-io"]);
171
+ case "project-cinema":
172
+ return command.action === "animatic" || command.action === "run"
173
+ ? claims(coordinator, [
174
+ "cpu",
175
+ "ffmpeg",
176
+ "local-io",
177
+ "output-publication",
178
+ "project-render",
179
+ ])
180
+ : claims(coordinator, ["cpu", "local-io"]);
171
181
  case "analyze-faces":
172
182
  return claims(coordinator, ["cpu", "local-io", "vision"]);
173
183
  case "analyze-zooms":
@@ -185,11 +195,18 @@ export function commandHostResourceClaims(
185
195
  ? claims(coordinator, ["cpu", "local-io", "ffmpeg", "output-publication"])
186
196
  : claims(coordinator, ["cpu", "local-io"]);
187
197
  case "spatial-world":
198
+ case "spatial-asset":
199
+ case "spatial-design":
188
200
  return claims(coordinator, ["cpu", "local-io"]);
189
201
  case "spatial-scene":
190
202
  return command.action === "render"
191
203
  ? claims(coordinator, ["cpu", "local-io", "browser", "ffmpeg", "output-publication"])
192
- : claims(coordinator, ["cpu", "local-io"]);
204
+ : command.action === "render-audit"
205
+ ? claims(coordinator, ["cpu", "local-io", "browser", "ffmpeg"])
206
+ : command.action === "review"
207
+ // Local render plus one bounded paid dispatch in a single claim.
208
+ ? claims(coordinator, ["cpu", "local-io", "browser", "ffmpeg", "network", "paid-call"])
209
+ : claims(coordinator, ["cpu", "local-io"]);
193
210
  case "spatial-project":
194
211
  return command.action === "prepare-render"
195
212
  ? claims(coordinator, ["cpu", "local-io", "browser", "ffmpeg", "output-publication", "project-render"])
@@ -208,7 +225,17 @@ export function commandHostResourceClaims(
208
225
  // Local media inspection acquires its complete CPU/FFmpeg/I/O vector
209
226
  // only while validating bytes. Paid provider waits must not pin render
210
227
  // capacity across agents for their potentially long network lifetime.
228
+ // A gallery command is one admitted paid batch; it bounds its own
229
+ // in-flight candidate concurrency internally.
230
+ return claims(coordinator, ["network", "paid-call"]);
231
+ case "ai-image-gallery":
232
+ // Probe previews admit the qualified browser render vector only for the
233
+ // settled-candidate render phase so paid waits never pin it.
211
234
  return claims(coordinator, ["network", "paid-call"]);
235
+ case "ai-scene-gallery":
236
+ // Scene-variant galleries do no paid work: every variant render is a
237
+ // local qualified browser still plus content-addressed publication.
238
+ return claims(coordinator, ["cpu", "local-io", "browser", "ffmpeg", "output-publication"]);
212
239
  case "ai-models-list":
213
240
  case "ai-models-show":
214
241
  return claims(coordinator, ["network"]);
@@ -225,16 +252,18 @@ export function commandHostResourceClaims(
225
252
  case "fillers-apply":
226
253
  case "fillers-list":
227
254
  case "help":
255
+ case "capabilities":
228
256
  case "inspect":
257
+ case "menubar":
229
258
  case "operations-list":
230
259
  case "operations-show":
260
+ case "outputs":
231
261
  case "project-camera-edit":
232
262
  case "project-edit":
233
263
  case "project-inspect":
234
264
  case "project-metadata-edit":
235
265
  case "projects-create":
236
266
  case "projects-list":
237
- case "record":
238
267
  case "recordings-list":
239
268
  case "runs-approve":
240
269
  case "runs-cancel":