@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
@@ -0,0 +1,1320 @@
1
+ import {
2
+ CINEMA_LIMITS,
3
+ CinemaGalleryAxisSchema,
4
+ CinemaGalleryPlanSchema,
5
+ CinemaRenderPlanV1Schema,
6
+ CinemaTemporalReportSchema,
7
+ ProjectCinemaPlanV1Schema,
8
+ type CinemaAnchor,
9
+ type CinemaAudioCue,
10
+ type CinemaContinuityFinding,
11
+ type CinemaContinuityReport,
12
+ type CinemaGalleryAxis,
13
+ type CinemaGalleryCandidate,
14
+ type CinemaGalleryPlan,
15
+ type CinemaMediaSlice,
16
+ type CinemaRenderPlanV1,
17
+ type CinemaResolvedAudioCue,
18
+ type CinemaResolvedShot,
19
+ type CinemaResolvedShotMedia,
20
+ type CinemaResolvedTransition,
21
+ type CinemaShot,
22
+ type CinemaShotContinuity,
23
+ type CinemaTemporalFinding,
24
+ type CinemaTemporalReport,
25
+ type CinemaTransition,
26
+ type ProjectCinemaPlanV1,
27
+ ProjectEditPlanV1Schema,
28
+ type ProjectPlacementV1,
29
+ type SourceInterval,
30
+ VideoProjectV1Schema,
31
+ type VideoProjectV1,
32
+ } from "../contracts";
33
+ import { canonicalJsonSha256 } from "./canonical-json";
34
+ import { hashProjectEditPlan, hashProjectStructure } from "./project-plan";
35
+ import { mapProjectIntervalToAssetSlices } from "./project-time";
36
+
37
+ type CinemaShotId = ProjectCinemaPlanV1["shots"][number]["shotId"];
38
+ type CinemaLookId = ProjectCinemaPlanV1["looks"][number]["lookId"];
39
+
40
+ type ProjectMediaStream = VideoProjectV1["assets"][number]["streams"][number];
41
+
42
+ export interface CinemaSequenceWindow {
43
+ readonly endUs: number;
44
+ readonly startUs: number;
45
+ }
46
+
47
+ export interface CinemaSequenceLayout {
48
+ readonly durationUs: number;
49
+ readonly shots: readonly (CinemaSequenceWindow & { readonly shotId: string })[];
50
+ readonly transitions: readonly (CinemaSequenceWindow & {
51
+ readonly afterShotId: string;
52
+ readonly beforeShotId: string;
53
+ readonly durationUs: number;
54
+ readonly index: number;
55
+ })[];
56
+ }
57
+
58
+ export interface CinemaAnalysis {
59
+ readonly findings: readonly CinemaContinuityFinding[];
60
+ readonly layout: CinemaSequenceLayout | null;
61
+ }
62
+
63
+ export interface CinemaCompileOptions {
64
+ /** Animatics may substitute deterministic placeholder frames for absent spatial artifacts. */
65
+ readonly placeholders: "allow" | "reject";
66
+ }
67
+
68
+ export function hashCinemaPlanComposition(planInput: ProjectCinemaPlanV1): string {
69
+ const plan = ProjectCinemaPlanV1Schema.parse(planInput);
70
+ const { cinemaPlanSha256: _cinemaPlanSha256, ...body } = plan;
71
+ void _cinemaPlanSha256;
72
+ return canonicalJsonSha256(body);
73
+ }
74
+
75
+ export function assertCinemaPlanComposition(planInput: ProjectCinemaPlanV1): ProjectCinemaPlanV1 {
76
+ const plan = ProjectCinemaPlanV1Schema.parse(planInput);
77
+ const actual = hashCinemaPlanComposition(plan);
78
+ if (actual !== plan.cinemaPlanSha256) {
79
+ throw new TypeError(
80
+ `Cinema plan composition hash mismatch: expected ${plan.cinemaPlanSha256}, received ${actual}.`,
81
+ );
82
+ }
83
+ return plan;
84
+ }
85
+
86
+ export function hashCinemaRenderPlanComposition(planInput: CinemaRenderPlanV1): string {
87
+ const plan = CinemaRenderPlanV1Schema.parse(planInput);
88
+ const { planSha256: _planSha256, ...body } = plan;
89
+ void _planSha256;
90
+ return canonicalJsonSha256(body);
91
+ }
92
+
93
+ export function transitionDurationUs(transition: CinemaTransition): number {
94
+ return transition.kind === "cut" || transition.kind === "match-cut" ? 0 : transition.durationUs;
95
+ }
96
+
97
+ /** Lay out every shot body and transition segment on the cinema output clock. */
98
+ export function cinemaSequenceLayout(plan: ProjectCinemaPlanV1): CinemaSequenceLayout {
99
+ const shots: CinemaSequenceLayout["shots"][number][] = [];
100
+ const transitions: CinemaSequenceLayout["transitions"][number][] = [];
101
+ let cursorUs = 0;
102
+ for (const [index, shot] of plan.shots.entries()) {
103
+ const durationUs = shot.source.kind === "placement"
104
+ ? shot.source.range.endUs - shot.source.range.startUs
105
+ : shot.source.sourceRange.endUs - shot.source.sourceRange.startUs;
106
+ shots.push({ endUs: cursorUs + durationUs, shotId: shot.shotId, startUs: cursorUs });
107
+ cursorUs += durationUs;
108
+ const transition = plan.transitions[index];
109
+ if (transition !== undefined) {
110
+ const transitionUs = transitionDurationUs(transition);
111
+ if (transitionUs > 0) {
112
+ transitions.push({
113
+ afterShotId: plan.shots[index + 1]!.shotId,
114
+ beforeShotId: shot.shotId,
115
+ durationUs: transitionUs,
116
+ endUs: cursorUs + transitionUs,
117
+ index,
118
+ startUs: cursorUs,
119
+ });
120
+ cursorUs += transitionUs;
121
+ }
122
+ }
123
+ }
124
+ return { durationUs: cursorUs, shots, transitions };
125
+ }
126
+
127
+ function anchorTimeUs(
128
+ anchor: CinemaAnchor,
129
+ layout: CinemaSequenceLayout,
130
+ ): number | null {
131
+ if (anchor.kind === "sequence") return anchor.offsetUs;
132
+ const window = layout.shots.find(shot => shot.shotId === anchor.shotId);
133
+ if (window === undefined) return null;
134
+ const timeUs = window.startUs + anchor.offsetUs;
135
+ return Number.isSafeInteger(timeUs) ? timeUs : null;
136
+ }
137
+
138
+ function finding(
139
+ code: CinemaContinuityFinding["code"],
140
+ severity: CinemaContinuityFinding["severity"],
141
+ message: string,
142
+ shotIds: readonly CinemaContinuityFinding["shotIds"][number][],
143
+ timeUs: number,
144
+ transitionIndex?: number,
145
+ ): CinemaContinuityFinding {
146
+ return {
147
+ code,
148
+ message,
149
+ severity,
150
+ shotIds,
151
+ timeUs,
152
+ ...(transitionIndex === undefined ? {} : { transitionIndex }),
153
+ };
154
+ }
155
+
156
+ interface ResolvedStreamWindow {
157
+ readonly coverage: readonly CinemaMediaSlice[];
158
+ readonly complete: boolean;
159
+ }
160
+
161
+ function placementStreamWindow(
162
+ placement: ProjectPlacementV1,
163
+ stream: ProjectMediaStream,
164
+ projectWindow: SourceInterval,
165
+ outputStartUs: number,
166
+ ): ResolvedStreamWindow {
167
+ const assetSlices = mapProjectIntervalToAssetSlices(placement, projectWindow);
168
+ const requestedUs = projectWindow.endUs - projectWindow.startUs;
169
+ const mappedUs = assetSlices.reduce(
170
+ (total, slice) => total + (slice.project.endUs - slice.project.startUs),
171
+ 0,
172
+ );
173
+ const slices: CinemaMediaSlice[] = [];
174
+ let outputCursorUs = outputStartUs;
175
+ let complete = mappedUs >= requestedUs;
176
+ for (const assetSlice of assetSlices) {
177
+ const projectDurationUs = assetSlice.project.endUs - assetSlice.project.startUs;
178
+ const assetDurationUs = assetSlice.asset.endUs - assetSlice.asset.startUs;
179
+ let coveredAssetUs = 0;
180
+ for (const media of stream.segments) {
181
+ const startUs = Math.max(assetSlice.asset.startUs, media.assetRange.startUs);
182
+ const endUs = Math.min(assetSlice.asset.endUs, media.assetRange.endUs);
183
+ if (endUs <= startUs) continue;
184
+ const outputSliceStartUs = outputCursorUs + Math.round(
185
+ projectDurationUs * coveredAssetUs / assetDurationUs,
186
+ );
187
+ coveredAssetUs += endUs - startUs;
188
+ const outputSliceEndUs = outputCursorUs + Math.round(
189
+ projectDurationUs * coveredAssetUs / assetDurationUs,
190
+ );
191
+ if (outputSliceEndUs <= outputSliceStartUs) continue;
192
+ const fileStartUs = media.fileRange.startUs + (startUs - media.assetRange.startUs);
193
+ slices.push({
194
+ bytes: media.bytes,
195
+ fileRange: {
196
+ endUs: fileStartUs + (endUs - startUs),
197
+ startUs: fileStartUs,
198
+ },
199
+ outputRange: {
200
+ endUs: outputSliceEndUs,
201
+ startUs: outputSliceStartUs,
202
+ },
203
+ path: media.path,
204
+ sha256: media.sha256,
205
+ streamIndex: media.streamIndex,
206
+ });
207
+ }
208
+ if (coveredAssetUs < assetDurationUs) complete = false;
209
+ outputCursorUs += projectDurationUs;
210
+ }
211
+ return { complete, coverage: slices };
212
+ }
213
+
214
+ function spatialArtifactWindow(
215
+ artifact: { readonly bytes: number; readonly path: string; readonly sha256: string },
216
+ window: SourceInterval,
217
+ outputStartUs: number,
218
+ ): ResolvedStreamWindow {
219
+ return {
220
+ complete: true,
221
+ coverage: [{
222
+ bytes: artifact.bytes,
223
+ fileRange: { endUs: window.endUs, startUs: window.startUs },
224
+ outputRange: {
225
+ endUs: outputStartUs + (window.endUs - window.startUs),
226
+ startUs: outputStartUs,
227
+ },
228
+ path: artifact.path,
229
+ sha256: artifact.sha256,
230
+ streamIndex: 0,
231
+ }],
232
+ };
233
+ }
234
+
235
+ const EMPTY_MEDIA: CinemaResolvedShotMedia = { body: [], inHandle: [], outHandle: [] };
236
+
237
+ function placeholderColor(shotId: string): string {
238
+ // Dark, muted placeholder hues stay visually distinct from authored media.
239
+ let hash = 0;
240
+ for (const character of shotId) {
241
+ hash = (hash * 31 + (character.codePointAt(0) ?? 0)) >>> 0;
242
+ }
243
+ const hue = (hash % 360) / 360;
244
+ const lightness = 0.22;
245
+ const saturation = 0.28;
246
+ const high = lightness * (1 + saturation);
247
+ const low = 2 * lightness - high;
248
+ const channel = (offset: number): number => {
249
+ let t = hue + offset;
250
+ if (t < 0) t += 1;
251
+ if (t > 1) t -= 1;
252
+ if (t < 1 / 6) return low + (high - low) * 6 * t;
253
+ if (t < 1 / 2) return high;
254
+ if (t < 2 / 3) return low + (high - low) * (2 / 3 - t) * 6;
255
+ return low;
256
+ };
257
+ const hex = (value: number): string => (
258
+ Math.round(value * 255).toString(16).padStart(2, "0")
259
+ );
260
+ return `#${hex(channel(1 / 3))}${hex(channel(0))}${hex(channel(-1 / 3))}`;
261
+ }
262
+
263
+ export function compileCinemaSequence(
264
+ cinemaInput: unknown,
265
+ projectInput: unknown,
266
+ editPlanInput: unknown,
267
+ options: CinemaCompileOptions = { placeholders: "reject" },
268
+ ): CinemaRenderPlanV1 {
269
+ const cinema = ProjectCinemaPlanV1Schema.parse(cinemaInput);
270
+ const project = VideoProjectV1Schema.parse(projectInput);
271
+ const editPlan = ProjectEditPlanV1Schema.parse(editPlanInput);
272
+ const analysis = analyzeCinemaPlan(cinema, project, editPlan);
273
+ const blocking = analysis.findings.find(candidate => candidate.severity === "error");
274
+ if (blocking !== undefined) {
275
+ throw new TypeError(`cinema-${blocking.code}: ${blocking.message}`);
276
+ }
277
+ if (analysis.layout === null) throw new TypeError("cinema-invalid: Cinema plan could not be laid out.");
278
+ const layout = analysis.layout;
279
+
280
+ const advisories: CinemaRenderPlanV1["advisories"][number][] = [];
281
+ const resolvedShots: CinemaResolvedShot[] = [];
282
+ const resolvedTransitions: CinemaResolvedTransition[] = [];
283
+
284
+ for (const [index, shot] of cinema.shots.entries()) {
285
+ const window = layout.shots[index]!;
286
+ const durationUs = window.endUs - window.startUs;
287
+ const incoming = index === 0 ? 0 : transitionDurationUs(cinema.transitions[index - 1]!);
288
+ const outgoing = index === cinema.shots.length - 1 ? 0 : transitionDurationUs(cinema.transitions[index]!);
289
+ let video: CinemaResolvedShotMedia = EMPTY_MEDIA;
290
+ let audio: CinemaResolvedShot["audio"];
291
+ let placeholder: CinemaResolvedShot["placeholder"];
292
+ let sourceFrame: CinemaResolvedShot["sourceFrame"] | undefined;
293
+
294
+ if (shot.source.kind === "placement") {
295
+ const source = shot.source;
296
+ const placement = project.placements.find(candidate => candidate.placementId === source.placementId)!;
297
+ const asset = project.assets.find(candidate => candidate.assetId === placement.assetId)!;
298
+ const videoStream = asset.streams.find(
299
+ candidate => candidate.streamId === source.streamId && candidate.kind === "video",
300
+ );
301
+ if (videoStream?.kind !== "video") {
302
+ throw new TypeError(`cinema-missing-media-coverage: Shot ${shot.shotId} has no enabled video stream.`);
303
+ }
304
+ const bodyWindow = source.range;
305
+ const inWindow = { endUs: bodyWindow.startUs, startUs: bodyWindow.startUs - incoming };
306
+ const outWindow = { endUs: bodyWindow.endUs + outgoing, startUs: bodyWindow.endUs };
307
+ const body = placementStreamWindow(placement, videoStream, bodyWindow, window.startUs);
308
+ const inHandle = incoming === 0
309
+ ? { complete: true, coverage: [] }
310
+ : placementStreamWindow(placement, videoStream, inWindow, window.startUs - incoming);
311
+ const outHandle = outgoing === 0
312
+ ? { complete: true, coverage: [] }
313
+ : placementStreamWindow(placement, videoStream, outWindow, window.endUs);
314
+ if (!body.complete || !inHandle.complete || !outHandle.complete) {
315
+ throw new TypeError(`cinema-missing-media-coverage: Shot ${shot.shotId} lacks complete media coverage.`);
316
+ }
317
+ video = {
318
+ body: body.coverage,
319
+ inHandle: inHandle.coverage,
320
+ outHandle: outHandle.coverage,
321
+ };
322
+ const audioConfiguration = placement.audio.find(
323
+ configured => configured.presentation.enabled
324
+ && asset.streams.some(
325
+ stream => stream.streamId === configured.streamId && stream.kind === "audio",
326
+ ),
327
+ );
328
+ if (audioConfiguration !== undefined) {
329
+ const audioStream = asset.streams.find(
330
+ stream => stream.streamId === audioConfiguration.streamId,
331
+ )!;
332
+ const audioBody = placementStreamWindow(placement, audioStream, bodyWindow, window.startUs);
333
+ const audioIn = incoming === 0
334
+ ? { complete: true, coverage: [] }
335
+ : placementStreamWindow(placement, audioStream, inWindow, window.startUs - incoming);
336
+ const audioOut = outgoing === 0
337
+ ? { complete: true, coverage: [] }
338
+ : placementStreamWindow(placement, audioStream, outWindow, window.endUs);
339
+ if (audioBody.complete && audioIn.complete && audioOut.complete) {
340
+ const presentation = audioConfiguration.presentation;
341
+ if (presentation.enabled) {
342
+ audio = {
343
+ gainDb: presentation.gainDb,
344
+ media: {
345
+ body: audioBody.coverage,
346
+ inHandle: audioIn.coverage,
347
+ outHandle: audioOut.coverage,
348
+ },
349
+ pan: presentation.pan,
350
+ };
351
+ }
352
+ }
353
+ }
354
+ sourceFrame = {
355
+ pixelHeight: videoStream.pixelHeight,
356
+ pixelWidth: videoStream.pixelWidth,
357
+ };
358
+ } else {
359
+ const source = shot.source;
360
+ if (source.artifact !== undefined) {
361
+ const artifact = source.artifact;
362
+ sourceFrame = {
363
+ pixelHeight: artifact.pixelHeight,
364
+ pixelWidth: artifact.pixelWidth,
365
+ };
366
+ const inWindow = {
367
+ endUs: source.sourceRange.startUs,
368
+ startUs: source.sourceRange.startUs - incoming,
369
+ };
370
+ const outWindow = {
371
+ endUs: source.sourceRange.endUs + outgoing,
372
+ startUs: source.sourceRange.endUs,
373
+ };
374
+ video = {
375
+ body: spatialArtifactWindow(artifact, source.sourceRange, window.startUs).coverage,
376
+ inHandle: incoming === 0
377
+ ? []
378
+ : spatialArtifactWindow(artifact, inWindow, window.startUs - incoming).coverage,
379
+ outHandle: outgoing === 0
380
+ ? []
381
+ : spatialArtifactWindow(artifact, outWindow, window.endUs).coverage,
382
+ };
383
+ } else {
384
+ if (options.placeholders !== "allow") {
385
+ throw new TypeError(`cinema-shot-media-missing: Shot ${shot.shotId} has no materialized artifact.`);
386
+ }
387
+ placeholder = { color: placeholderColor(shot.shotId), reason: "missing-artifact" };
388
+ sourceFrame = {
389
+ pixelHeight: cinema.output.pixelHeight,
390
+ pixelWidth: cinema.output.pixelWidth,
391
+ };
392
+ advisories.push({
393
+ code: "shot-placeholder",
394
+ message: `Shot ${shot.shotId} renders from a deterministic placeholder; its spatial artifact is absent.`,
395
+ shotId: shot.shotId,
396
+ });
397
+ }
398
+ }
399
+
400
+ if (sourceFrame === undefined) {
401
+ throw new TypeError(`cinema-invalid: Shot ${shot.shotId} frame geometry could not be resolved.`);
402
+ }
403
+ resolvedShots.push({
404
+ ...(audio === undefined ? {} : { audio }),
405
+ durationUs,
406
+ fit: shot.fit,
407
+ handles: shot.handles,
408
+ ...(shot.lookId === undefined ? {} : { lookId: shot.lookId }),
409
+ outputRange: { endUs: window.endUs, startUs: window.startUs },
410
+ ...(placeholder === undefined ? {} : { placeholder }),
411
+ shotId: shot.shotId,
412
+ sourceFrame,
413
+ video,
414
+ });
415
+
416
+ const transition = cinema.transitions[index];
417
+ if (transition !== undefined) {
418
+ const transitionWindow = layout.transitions.find(candidate => candidate.index === index);
419
+ resolvedTransitions.push(resolveTransition(
420
+ transition,
421
+ index,
422
+ shot.shotId,
423
+ cinema.shots[index + 1]!.shotId,
424
+ transitionWindow,
425
+ advisories,
426
+ ));
427
+ }
428
+ }
429
+
430
+ const audioCues: CinemaResolvedAudioCue[] = [];
431
+ for (const cue of cinema.audioCues) {
432
+ const startUs = anchorTimeUs(cue.at, layout)!;
433
+ const asset = project.assets.find(candidate => candidate.assetId === cue.assetId)!;
434
+ const stream = asset.streams.find(
435
+ candidate => candidate.streamId === cue.streamId && candidate.kind === "audio",
436
+ )!;
437
+ let outputCursorUs = startUs;
438
+ const cueDurationUs = cue.assetRange.endUs - cue.assetRange.startUs;
439
+ for (const media of stream.segments) {
440
+ const startUsAsset = Math.max(cue.assetRange.startUs, media.assetRange.startUs);
441
+ const endUsAsset = Math.min(cue.assetRange.endUs, media.assetRange.endUs);
442
+ if (endUsAsset <= startUsAsset) continue;
443
+ const fileStartUs = media.fileRange.startUs + (startUsAsset - media.assetRange.startUs);
444
+ const durationUs = endUsAsset - startUsAsset;
445
+ audioCues.push({
446
+ assetId: cue.assetId,
447
+ bytes: media.bytes,
448
+ cueId: cue.cueId,
449
+ fadeInUs: cue.fadeInUs,
450
+ fadeOutUs: cue.fadeOutUs,
451
+ fileRange: { endUs: fileStartUs + durationUs, startUs: fileStartUs },
452
+ gainDb: cue.gainDb,
453
+ outputRange: {
454
+ endUs: outputCursorUs + durationUs,
455
+ startUs: outputCursorUs,
456
+ },
457
+ path: media.path,
458
+ role: cue.role,
459
+ sha256: media.sha256,
460
+ streamIndex: media.streamIndex,
461
+ });
462
+ outputCursorUs += durationUs;
463
+ }
464
+ if (outputCursorUs !== startUs + cueDurationUs) {
465
+ throw new TypeError(`cinema-missing-media-coverage: Audio cue ${cue.cueId} lacks complete media coverage.`);
466
+ }
467
+ }
468
+
469
+ const body = {
470
+ advisories,
471
+ audioCues,
472
+ cinemaPlanSha256: cinema.cinemaPlanSha256,
473
+ kind: "slopcamera.cinema-render-plan" as const,
474
+ looks: cinema.looks,
475
+ output: {
476
+ background: cinema.output.background,
477
+ durationUs: layout.durationUs,
478
+ frameRate: cinema.output.frameRate,
479
+ pixelHeight: cinema.output.pixelHeight,
480
+ pixelWidth: cinema.output.pixelWidth,
481
+ },
482
+ projectEditPlanSha256: cinema.projectEditPlanSha256,
483
+ projectId: cinema.projectId,
484
+ projectStructureSha256: cinema.projectStructureSha256,
485
+ schemaVersion: 1 as const,
486
+ shots: resolvedShots,
487
+ transitions: resolvedTransitions,
488
+ };
489
+ // Hash the normalized body so schema defaults cannot drift the identity.
490
+ const draft = CinemaRenderPlanV1Schema.parse({ ...body, planSha256: "0".repeat(64) });
491
+ return CinemaRenderPlanV1Schema.parse({
492
+ ...draft,
493
+ planSha256: hashCinemaRenderPlanComposition(draft),
494
+ });
495
+ }
496
+
497
+ function resolveTransition(
498
+ transition: CinemaTransition,
499
+ index: number,
500
+ beforeShotId: CinemaShot["shotId"],
501
+ afterShotId: CinemaShot["shotId"],
502
+ window: CinemaSequenceWindow | undefined,
503
+ advisories: CinemaRenderPlanV1["advisories"][number][],
504
+ ): CinemaResolvedTransition {
505
+ if (transition.kind === "cut") return { kind: "cut" };
506
+ if (transition.kind === "match-cut") {
507
+ advisories.push(transition.basis === "declared"
508
+ ? {
509
+ code: "match-cut-declared",
510
+ message: "Match-cut similarity is declared by the plan author; it is not measured.",
511
+ transitionIndex: index,
512
+ }
513
+ : {
514
+ code: "match-cut-perceptual-unverified",
515
+ message: "Perceptual match-cut requests are advisory: similarity is not measured.",
516
+ transitionIndex: index,
517
+ });
518
+ return {
519
+ afterShotId,
520
+ basis: transition.basis,
521
+ beforeShotId,
522
+ kind: "match-cut",
523
+ };
524
+ }
525
+ const range = window!;
526
+ const shared = {
527
+ afterShotId,
528
+ beforeShotId,
529
+ durationUs: transition.durationUs,
530
+ outputRange: { endUs: range.endUs, startUs: range.startUs },
531
+ };
532
+ switch (transition.kind) {
533
+ case "dissolve": return { ...shared, kind: "dissolve" };
534
+ case "wipe": return { ...shared, direction: transition.direction, kind: "wipe" };
535
+ case "dip-to-color": return { ...shared, color: transition.color, kind: "dip-to-color" };
536
+ case "whip-pan": return {
537
+ ...shared,
538
+ blurSigma: transition.blurSigma,
539
+ direction: transition.direction,
540
+ kind: "whip-pan",
541
+ };
542
+ case "light-flash": return { ...shared, kind: "light-flash", peakHoldUs: transition.peakHoldUs };
543
+ case "spatial": {
544
+ if (transition.clip === undefined) {
545
+ throw new TypeError(`cinema-transition-unresolved: Transition ${index} requests a spatial transition without a materialized clip.`);
546
+ }
547
+ return { ...shared, clip: transition.clip, kind: "spatial" };
548
+ }
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Structural plus continuity findings for one cinema sidecar. Schema-level
554
+ * failures still throw; every content problem is a deterministic finding.
555
+ */
556
+ export function analyzeCinemaPlan(
557
+ cinemaInput: unknown,
558
+ projectInput: unknown,
559
+ editPlanInput: unknown,
560
+ ): CinemaAnalysis {
561
+ const cinema = ProjectCinemaPlanV1Schema.parse(cinemaInput);
562
+ const project = VideoProjectV1Schema.parse(projectInput);
563
+ const editPlan = ProjectEditPlanV1Schema.parse(editPlanInput);
564
+ const findings: CinemaContinuityFinding[] = [];
565
+ const firstShotId = cinema.shots[0]!.shotId;
566
+
567
+ const structureSha256 = hashProjectStructure(project);
568
+ const editPlanSha256 = hashProjectEditPlan(editPlan);
569
+ if (
570
+ cinema.projectId !== project.projectId
571
+ || cinema.projectStructureSha256 !== structureSha256
572
+ || cinema.projectEditPlanSha256 !== editPlanSha256
573
+ || editPlan.projectStructureSha256 !== structureSha256
574
+ ) {
575
+ findings.push(finding(
576
+ "stale-sidecar",
577
+ "error",
578
+ "Cinema sidecar no longer matches the current project structure or edit plan digests.",
579
+ [firstShotId],
580
+ 0,
581
+ ));
582
+ }
583
+
584
+ const layout = cinemaSequenceLayout(cinema);
585
+ if (layout.durationUs > CINEMA_LIMITS.outputDurationUs) {
586
+ findings.push(finding(
587
+ "output-bounds-exceeded",
588
+ "error",
589
+ `Cinema output duration ${layout.durationUs}us exceeds the bounded limit.`,
590
+ [firstShotId],
591
+ 0,
592
+ ));
593
+ }
594
+
595
+ for (const [index, shot] of cinema.shots.entries()) {
596
+ const window = layout.shots[index]!;
597
+ const incoming = index === 0 ? 0 : transitionDurationUs(cinema.transitions[index - 1]!);
598
+ const outgoing = index === cinema.shots.length - 1 ? 0 : transitionDurationUs(cinema.transitions[index]!);
599
+ if (incoming > shot.handles.preRollUs) {
600
+ findings.push(finding(
601
+ "insufficient-handles",
602
+ "error",
603
+ `Shot ${shot.shotId} provides ${shot.handles.preRollUs}us of pre-roll; the incoming transition needs ${incoming}us.`,
604
+ [shot.shotId],
605
+ window.startUs,
606
+ index - 1,
607
+ ));
608
+ }
609
+ if (outgoing > shot.handles.postRollUs) {
610
+ findings.push(finding(
611
+ "insufficient-handles",
612
+ "error",
613
+ `Shot ${shot.shotId} provides ${shot.handles.postRollUs}us of post-roll; the outgoing transition needs ${outgoing}us.`,
614
+ [shot.shotId],
615
+ window.endUs,
616
+ index,
617
+ ));
618
+ }
619
+ if (shot.source.kind === "placement") {
620
+ const source = shot.source;
621
+ const placement = project.placements.find(candidate => candidate.placementId === source.placementId);
622
+ const asset = placement === undefined
623
+ ? undefined
624
+ : project.assets.find(candidate => candidate.assetId === placement.assetId);
625
+ const stream = asset?.streams.find(
626
+ candidate => candidate.streamId === source.streamId && candidate.kind === "video",
627
+ );
628
+ if (placement === undefined || asset === undefined || stream === undefined || !placement.enabled) {
629
+ findings.push(finding(
630
+ "missing-media-coverage",
631
+ "error",
632
+ `Shot ${shot.shotId} references an unknown or disabled placement video stream.`,
633
+ [shot.shotId],
634
+ window.startUs,
635
+ ));
636
+ } else {
637
+ const expanded = {
638
+ endUs: source.range.endUs + outgoing,
639
+ startUs: source.range.startUs - incoming,
640
+ };
641
+ if (expanded.startUs < 0) {
642
+ findings.push(finding(
643
+ "insufficient-handles",
644
+ "error",
645
+ `Shot ${shot.shotId} pre-roll reaches before project time zero.`,
646
+ [shot.shotId],
647
+ window.startUs,
648
+ ));
649
+ } else {
650
+ const coverage = placementStreamWindow(placement, stream, expanded, 0);
651
+ if (!coverage.complete) {
652
+ findings.push(finding(
653
+ "missing-media-coverage",
654
+ "error",
655
+ `Shot ${shot.shotId} expanded window is not fully covered by its placement media.`,
656
+ [shot.shotId],
657
+ window.startUs,
658
+ ));
659
+ }
660
+ }
661
+ }
662
+ } else {
663
+ const source = shot.source;
664
+ if (source.artifact === undefined) {
665
+ findings.push(finding(
666
+ "shot-media-missing",
667
+ "warning",
668
+ `Shot ${shot.shotId} has no materialized artifact; animatics render a placeholder.`,
669
+ [shot.shotId],
670
+ window.startUs,
671
+ ));
672
+ } else {
673
+ const artifact = source.artifact;
674
+ if (
675
+ source.sourceRange.startUs - incoming < 0
676
+ || source.sourceRange.endUs + outgoing > artifact.durationUs
677
+ ) {
678
+ findings.push(finding(
679
+ "insufficient-handles",
680
+ "error",
681
+ `Shot ${shot.shotId} expanded window exceeds its retained artifact duration.`,
682
+ [shot.shotId],
683
+ window.startUs,
684
+ ));
685
+ }
686
+ }
687
+ }
688
+ }
689
+
690
+ for (const [index, transition] of cinema.transitions.entries()) {
691
+ const window = layout.transitions.find(candidate => candidate.index === index);
692
+ if (transition.kind === "spatial" && transition.clip === undefined) {
693
+ findings.push(finding(
694
+ "transition-unresolved",
695
+ "error",
696
+ `Transition ${index} requests a spatial transition without a materialized clip.`,
697
+ [cinema.shots[index]!.shotId, cinema.shots[index + 1]!.shotId],
698
+ window?.startUs ?? layout.shots[index]!.endUs,
699
+ index,
700
+ ));
701
+ }
702
+ if (
703
+ transition.kind === "spatial"
704
+ && transition.clip !== undefined
705
+ && transition.clip.durationUs < transition.durationUs
706
+ ) {
707
+ findings.push(finding(
708
+ "transition-unresolved",
709
+ "error",
710
+ `Transition ${index} clip is shorter than the requested spatial transition.`,
711
+ [cinema.shots[index]!.shotId, cinema.shots[index + 1]!.shotId],
712
+ window?.startUs ?? layout.shots[index]!.endUs,
713
+ index,
714
+ ));
715
+ }
716
+ }
717
+
718
+ for (const cue of cinema.audioCues) {
719
+ const asset = project.assets.find(candidate => candidate.assetId === cue.assetId);
720
+ const stream = asset?.streams.find(
721
+ candidate => candidate.streamId === cue.streamId && candidate.kind === "audio",
722
+ );
723
+ const startUs = anchorTimeUs(cue.at, layout);
724
+ const cueDurationUs = cue.assetRange.endUs - cue.assetRange.startUs;
725
+ if (
726
+ asset === undefined
727
+ || stream === undefined
728
+ || startUs === null
729
+ || startUs < 0
730
+ || startUs + cueDurationUs > layout.durationUs
731
+ ) {
732
+ findings.push(finding(
733
+ "cue-out-of-range",
734
+ "error",
735
+ `Audio cue ${cue.cueId} does not fit the declared sequence or media.`,
736
+ [firstShotId],
737
+ Math.max(0, startUs ?? 0),
738
+ ));
739
+ continue;
740
+ }
741
+ let coveredUs = 0;
742
+ for (const media of stream.segments) {
743
+ coveredUs += Math.max(0, Math.min(cue.assetRange.endUs, media.assetRange.endUs)
744
+ - Math.max(cue.assetRange.startUs, media.assetRange.startUs));
745
+ }
746
+ if (coveredUs < cueDurationUs) {
747
+ findings.push(finding(
748
+ "missing-media-coverage",
749
+ "error",
750
+ `Audio cue ${cue.cueId} is not fully covered by its asset media.`,
751
+ [firstShotId],
752
+ Math.max(0, startUs),
753
+ ));
754
+ }
755
+ }
756
+
757
+ const sorted = [...findings].sort((left, right) => (
758
+ left.timeUs - right.timeUs
759
+ || left.code.localeCompare(right.code)
760
+ || left.shotIds.join("").localeCompare(right.shotIds.join(""))
761
+ ));
762
+ return {
763
+ findings: sorted,
764
+ layout: sorted.some(candidate => candidate.severity === "error") ? null : layout,
765
+ };
766
+ }
767
+
768
+ function shotCameraKey(shot: CinemaShot): string {
769
+ if (shot.continuity?.cameraKey !== undefined) return shot.continuity.cameraKey;
770
+ return shot.source.kind === "placement"
771
+ ? `placement:${shot.source.placementId}:${shot.source.streamId}`
772
+ : `spatial:${shot.source.shotId}`;
773
+ }
774
+
775
+ const JUMP_CUT_GAP_US = 250_000;
776
+ const EXPOSURE_JUMP_EV = 1;
777
+ const FOCUS_JUMP_RATIO = 3;
778
+
779
+ function continuityPairFindings(
780
+ before: CinemaShot,
781
+ after: CinemaShot,
782
+ boundaryUs: number,
783
+ transition: CinemaTransition,
784
+ transitionIndex: number,
785
+ ): CinemaContinuityFinding[] {
786
+ const findings: CinemaContinuityFinding[] = [];
787
+ const left: CinemaShotContinuity | undefined = before.continuity;
788
+ const right: CinemaShotContinuity | undefined = after.continuity;
789
+ const shots = [before.shotId, after.shotId] as const;
790
+
791
+ if (
792
+ left?.screenDirection !== undefined
793
+ && right?.screenDirection !== undefined
794
+ && left.screenDirection !== "center"
795
+ && right.screenDirection !== "center"
796
+ && left.screenDirection !== right.screenDirection
797
+ ) {
798
+ findings.push(finding(
799
+ "axis-crossing",
800
+ "warning",
801
+ `Screen direction crosses the axis: ${before.shotId} is ${left.screenDirection}, ${after.shotId} is ${right.screenDirection}.`,
802
+ shots,
803
+ boundaryUs,
804
+ transitionIndex,
805
+ ));
806
+ }
807
+ if (
808
+ left?.eyeline !== undefined
809
+ && right?.eyeline !== undefined
810
+ && left.eyeline !== "center"
811
+ && right.eyeline !== "center"
812
+ && left.eyeline === right.eyeline
813
+ ) {
814
+ findings.push(finding(
815
+ "eyeline-mismatch",
816
+ "warning",
817
+ `Adjacent shots share a ${left.eyeline} eyeline; a reverse-angle pair should oppose.`,
818
+ shots,
819
+ boundaryUs,
820
+ transitionIndex,
821
+ ));
822
+ }
823
+ if (
824
+ left?.exposureEv !== undefined
825
+ && right?.exposureEv !== undefined
826
+ && Math.abs(left.exposureEv - right.exposureEv) > EXPOSURE_JUMP_EV
827
+ ) {
828
+ findings.push(finding(
829
+ "exposure-jump",
830
+ "advisory",
831
+ `Exposure changes by ${Math.abs(left.exposureEv - right.exposureEv)} EV between adjacent shots.`,
832
+ shots,
833
+ boundaryUs,
834
+ transitionIndex,
835
+ ));
836
+ }
837
+ if (
838
+ left?.focusDistanceM !== undefined
839
+ && right?.focusDistanceM !== undefined
840
+ && Math.max(left.focusDistanceM, right.focusDistanceM)
841
+ / Math.min(left.focusDistanceM, right.focusDistanceM) > FOCUS_JUMP_RATIO
842
+ ) {
843
+ findings.push(finding(
844
+ "focus-jump",
845
+ "advisory",
846
+ "Focus distance jumps more than 3× between adjacent shots.",
847
+ shots,
848
+ boundaryUs,
849
+ transitionIndex,
850
+ ));
851
+ }
852
+ const gapUs = transitionDurationUs(transition);
853
+ if (shotCameraKey(before) === shotCameraKey(after) && gapUs < JUMP_CUT_GAP_US) {
854
+ findings.push(finding(
855
+ "jump-cut",
856
+ "warning",
857
+ `Adjacent shots share camera ${shotCameraKey(before)} with a ${gapUs}us boundary.`,
858
+ shots,
859
+ boundaryUs,
860
+ transitionIndex,
861
+ ));
862
+ }
863
+ if (transition.kind === "match-cut") {
864
+ findings.push(finding(
865
+ "match-on-action",
866
+ "advisory",
867
+ "Match-on-action timing is declared, not measured; the boundary carries shot/time evidence only.",
868
+ shots,
869
+ boundaryUs,
870
+ transitionIndex,
871
+ ));
872
+ }
873
+ return findings;
874
+ }
875
+
876
+ /** Pure deterministic continuity audit over declared shot evidence. */
877
+ export function auditCinemaContinuity(cinemaInput: unknown): CinemaContinuityReport {
878
+ const cinema = ProjectCinemaPlanV1Schema.parse(cinemaInput);
879
+ const layout = cinemaSequenceLayout(cinema);
880
+ const findings: CinemaContinuityFinding[] = [];
881
+ for (const [index, transition] of cinema.transitions.entries()) {
882
+ const before = cinema.shots[index]!;
883
+ const after = cinema.shots[index + 1]!;
884
+ const boundaryUs = transition.kind === "cut" || transition.kind === "match-cut"
885
+ ? layout.shots[index]!.endUs
886
+ : layout.transitions.find(candidate => candidate.index === index)!.startUs;
887
+ findings.push(...continuityPairFindings(
888
+ before,
889
+ after,
890
+ boundaryUs,
891
+ transition,
892
+ index,
893
+ ));
894
+ }
895
+ findings.sort((left, right) => (
896
+ left.timeUs - right.timeUs
897
+ || left.code.localeCompare(right.code)
898
+ || left.shotIds.join("").localeCompare(right.shotIds.join(""))
899
+ ));
900
+ return {
901
+ cinemaPlanSha256: cinema.cinemaPlanSha256,
902
+ findings,
903
+ kind: "slopcamera.cinema-continuity-report",
904
+ schemaVersion: 1,
905
+ };
906
+ }
907
+
908
+ /** Deterministic starter sidecar: one placement shot per enabled video stream. */
909
+ export function createCinemaPlanScaffold(
910
+ projectInput: unknown,
911
+ editPlanInput: unknown,
912
+ planId: ProjectCinemaPlanV1["planId"],
913
+ timestamp: string,
914
+ ): ProjectCinemaPlanV1 {
915
+ const project = VideoProjectV1Schema.parse(projectInput);
916
+ const editPlan = ProjectEditPlanV1Schema.parse(editPlanInput);
917
+ const structureSha256 = hashProjectStructure(project);
918
+ if (editPlan.projectStructureSha256 !== structureSha256) {
919
+ throw new TypeError("Project structure and edit plan are out of sync.");
920
+ }
921
+ const shots: ProjectCinemaPlanV1["shots"][number][] = [];
922
+ const placements = [...project.placements].sort((left, right) => (
923
+ left.placementId.localeCompare(right.placementId)
924
+ ));
925
+ for (const placement of placements) {
926
+ if (!placement.enabled) continue;
927
+ const asset = project.assets.find(candidate => candidate.assetId === placement.assetId);
928
+ if (asset === undefined) continue;
929
+ const videoStream = placement.video.find(configured => (
930
+ configured.presentation.enabled
931
+ && asset.streams.some(
932
+ stream => stream.streamId === configured.streamId && stream.kind === "video",
933
+ )
934
+ ));
935
+ if (videoStream === undefined) continue;
936
+ const first = placement.sync.anchors[0]!;
937
+ const last = placement.sync.anchors.at(-1)!;
938
+ const suffix = String(placement.placementId).slice("placement_".length).replaceAll("-", "_");
939
+ shots.push({
940
+ fit: "contain",
941
+ handles: { postRollUs: 0, preRollUs: 0 },
942
+ shotId: `cshot_${suffix}` as ProjectCinemaPlanV1["shots"][number]["shotId"],
943
+ source: {
944
+ kind: "placement",
945
+ placementId: placement.placementId,
946
+ range: { endUs: last.projectTimeUs, startUs: first.projectTimeUs },
947
+ streamId: videoStream.streamId,
948
+ },
949
+ });
950
+ }
951
+ if (shots.length === 0) {
952
+ throw new TypeError("Cinema init requires at least one enabled placement video stream.");
953
+ }
954
+ const body = {
955
+ audioCues: [],
956
+ beats: [],
957
+ createdAt: timestamp,
958
+ kind: "slopcamera.project-cinema-plan" as const,
959
+ looks: [],
960
+ markers: [],
961
+ output: {},
962
+ planId,
963
+ projectEditPlanSha256: hashProjectEditPlan(editPlan),
964
+ projectId: project.projectId,
965
+ projectStructureSha256: structureSha256,
966
+ schemaVersion: 1 as const,
967
+ shots,
968
+ transitions: shots.slice(1).map(() => ({ kind: "cut" as const })),
969
+ updatedAt: timestamp,
970
+ };
971
+ const parsed = ProjectCinemaPlanV1Schema.parse({
972
+ ...body,
973
+ cinemaPlanSha256: "0".repeat(64),
974
+ });
975
+ const { cinemaPlanSha256: _, ...parsedBody } = parsed;
976
+ void _;
977
+ const cinemaPlanSha256 = canonicalJsonSha256(parsedBody);
978
+ return { ...parsed, cinemaPlanSha256 };
979
+ }
980
+
981
+ /** A shot shorter than this is a staccato cut that transitions cannot serve. */
982
+ const PACING_COLLAPSE_US = 250_000;
983
+ /** More than this many simultaneous audio cues is a concurrency finding. */
984
+ const CUE_CONCURRENCY_LIMIT = 3;
985
+ /** Uniform pacing fires only when every shot sits within this band of the median. */
986
+ const UNIFORM_PACING_TOLERANCE = 0.1;
987
+ const UNIFORM_PACING_MIN_SHOTS = 4;
988
+
989
+ function temporalFinding(
990
+ code: CinemaTemporalFinding["code"],
991
+ severity: CinemaTemporalFinding["severity"],
992
+ message: string,
993
+ shotIds: readonly CinemaShotId[],
994
+ timeUs: number,
995
+ transitionIndex?: number,
996
+ ): CinemaTemporalFinding {
997
+ return {
998
+ code,
999
+ message,
1000
+ severity,
1001
+ shotIds: [...shotIds],
1002
+ timeUs,
1003
+ ...(transitionIndex === undefined ? {} : { transitionIndex }),
1004
+ };
1005
+ }
1006
+
1007
+ /**
1008
+ * Audits one cinema plan's compiled layout for pacing, transition, and cue
1009
+ * evidence the media audit does not derive. All measures come from the layout
1010
+ * and declared cues; nothing inspects pixels or decodes media.
1011
+ */
1012
+ export function auditCinemaTemporal(
1013
+ cinemaInput: unknown,
1014
+ projectInput: unknown,
1015
+ editPlanInput: unknown,
1016
+ ): CinemaTemporalReport {
1017
+ const cinema = ProjectCinemaPlanV1Schema.parse(cinemaInput);
1018
+ VideoProjectV1Schema.parse(projectInput);
1019
+ ProjectEditPlanV1Schema.parse(editPlanInput);
1020
+ const layout = cinemaSequenceLayout(cinema);
1021
+ const findings: CinemaTemporalFinding[] = [];
1022
+ const shotId = (id: string): CinemaShotId => cinema.shots.find((shot) => shot.shotId === id)!.shotId;
1023
+
1024
+ for (const transition of layout.transitions) {
1025
+ const before = layout.shots.find((shot) => shot.shotId === transition.beforeShotId)!;
1026
+ const after = layout.shots.find((shot) => shot.shotId === transition.afterShotId)!;
1027
+ const shorterUs = Math.min(before.endUs - before.startUs, after.endUs - after.startUs);
1028
+ if (transition.durationUs > shorterUs) {
1029
+ findings.push(temporalFinding(
1030
+ "transition-overrun",
1031
+ "warning",
1032
+ `Transition ${transition.index} lasts ${transition.durationUs}us, longer than the shorter adjacent shot window ${shorterUs}us.`,
1033
+ [shotId(transition.beforeShotId), shotId(transition.afterShotId)],
1034
+ transition.startUs,
1035
+ transition.index,
1036
+ ));
1037
+ }
1038
+ }
1039
+
1040
+ for (const shot of layout.shots) {
1041
+ const windowUs = shot.endUs - shot.startUs;
1042
+ if (windowUs < PACING_COLLAPSE_US) {
1043
+ findings.push(temporalFinding(
1044
+ "pacing-collapse",
1045
+ "warning",
1046
+ `Shot ${shot.shotId} is visible for only ${windowUs}us, below the ${PACING_COLLAPSE_US}us pacing floor.`,
1047
+ [shotId(shot.shotId)],
1048
+ shot.startUs,
1049
+ ));
1050
+ }
1051
+ }
1052
+
1053
+ const cueWindows: { endUs: number; startUs: number }[] = [];
1054
+ for (const cue of cinema.audioCues) {
1055
+ const startUs = anchorTimeUs(cue.at, layout);
1056
+ if (startUs === null) continue;
1057
+ cueWindows.push({ endUs: startUs + (cue.assetRange.endUs - cue.assetRange.startUs), startUs });
1058
+ }
1059
+ const instants = new Set<number>();
1060
+ for (const window of cueWindows) {
1061
+ instants.add(window.startUs);
1062
+ instants.add(window.endUs);
1063
+ }
1064
+ let maxConcurrentCues = 0;
1065
+ for (const instant of [...instants].sort((a, b) => a - b)) {
1066
+ const concurrent = cueWindows.filter((window) => window.startUs < instant && instant < window.endUs
1067
+ || window.startUs === instant).length;
1068
+ maxConcurrentCues = Math.max(maxConcurrentCues, concurrent);
1069
+ if (concurrent > CUE_CONCURRENCY_LIMIT) {
1070
+ findings.push(temporalFinding(
1071
+ "cue-concurrency",
1072
+ "warning",
1073
+ `${concurrent} audio cues overlap at ${instant}us, above the ${CUE_CONCURRENCY_LIMIT}-cue limit.`,
1074
+ [shotId(layout.shots.find((shot) => shot.startUs <= instant && instant < shot.endUs)?.shotId ?? layout.shots[0]!.shotId)],
1075
+ instant,
1076
+ ));
1077
+ }
1078
+ }
1079
+
1080
+ const durations = layout.shots.map((shot) => shot.endUs - shot.startUs);
1081
+ const sortedDurations = [...durations].sort((a, b) => a - b);
1082
+ const medianShotUs = sortedDurations[Math.floor(sortedDurations.length / 2)]!;
1083
+ if (
1084
+ durations.length >= UNIFORM_PACING_MIN_SHOTS
1085
+ && durations.every((duration) => Math.abs(duration - medianShotUs) <= medianShotUs * UNIFORM_PACING_TOLERANCE)
1086
+ ) {
1087
+ findings.push(temporalFinding(
1088
+ "uniform-pacing",
1089
+ "advisory",
1090
+ `All ${durations.length} shots sit within ${UNIFORM_PACING_TOLERANCE * 100}% of the ${medianShotUs}us median; the cut has no pacing contrast.`,
1091
+ [shotId(layout.shots[0]!.shotId)],
1092
+ 0,
1093
+ ));
1094
+ }
1095
+
1096
+ const totalTransitionUs = layout.transitions.reduce((sum, transition) => sum + transition.durationUs, 0);
1097
+ const totalShotUs = durations.reduce((sum, duration) => sum + duration, 0);
1098
+ const sorted = [...findings].sort((left, right) => (
1099
+ left.timeUs - right.timeUs
1100
+ || left.code.localeCompare(right.code)
1101
+ || left.shotIds.join("").localeCompare(right.shotIds.join(""))
1102
+ ));
1103
+ return CinemaTemporalReportSchema.parse({
1104
+ cinemaPlanSha256: cinema.cinemaPlanSha256,
1105
+ findings: sorted,
1106
+ kind: "slopcamera.cinema-temporal-report",
1107
+ metrics: {
1108
+ audioCueCount: cinema.audioCues.length,
1109
+ maxConcurrentCues,
1110
+ maxShotUs: sortedDurations[sortedDurations.length - 1]!,
1111
+ medianShotUs,
1112
+ minShotUs: sortedDurations[0]!,
1113
+ shotCount: layout.shots.length,
1114
+ totalDurationUs: layout.durationUs,
1115
+ transitionCount: layout.transitions.length,
1116
+ transitionOverheadRatio: totalShotUs === 0 ? 0 : totalTransitionUs / totalShotUs,
1117
+ },
1118
+ schemaVersion: 1,
1119
+ });
1120
+ }
1121
+
1122
+ interface CinemaGalleryVariant {
1123
+ readonly label: string;
1124
+ readonly mutate: (plan: ProjectCinemaPlanV1) => ProjectCinemaPlanV1;
1125
+ readonly parameter: string;
1126
+ }
1127
+
1128
+ const scaleShotRange = (range: SourceInterval, factor: number): SourceInterval => ({
1129
+ endUs: range.startUs + Math.max(1, Math.floor((range.endUs - range.startUs) * factor)),
1130
+ startUs: range.startUs,
1131
+ });
1132
+
1133
+ const PACING_FACTORS = [1, 0.9, 0.8, 0.7, 0.6, 0.5] as const;
1134
+
1135
+ function pacingVariant(factor: number): CinemaGalleryVariant {
1136
+ return {
1137
+ label: factor === 1 ? "authored pacing" : `tighter coverage ${Math.round(factor * 100)}%`,
1138
+ mutate: (plan) => ({
1139
+ ...plan,
1140
+ shots: plan.shots.map((shot) => ({
1141
+ ...shot,
1142
+ source: shot.source.kind === "placement"
1143
+ ? { ...shot.source, range: scaleShotRange(shot.source.range, factor) }
1144
+ : { ...shot.source, sourceRange: scaleShotRange(shot.source.sourceRange, factor) },
1145
+ })),
1146
+ }),
1147
+ parameter: `factor:${factor}`,
1148
+ };
1149
+ }
1150
+
1151
+ const TRANSITION_PRESETS: readonly (CinemaTransition & { readonly label: string })[] = [
1152
+ { kind: "cut", label: "hard cuts" } as CinemaTransition & { readonly label: string },
1153
+ { durationUs: 250_000, kind: "dissolve", label: "short dissolves" } as CinemaTransition & { readonly label: string },
1154
+ { durationUs: 500_000, kind: "dissolve", label: "half-second dissolves" } as CinemaTransition & { readonly label: string },
1155
+ { durationUs: 1_000_000, kind: "dissolve", label: "one-second dissolves" } as CinemaTransition & { readonly label: string },
1156
+ { color: "#000000", durationUs: 500_000, kind: "dip-to-color", label: "dips to black" } as CinemaTransition & { readonly label: string },
1157
+ { durationUs: 400_000, kind: "light-flash", label: "light flashes" } as CinemaTransition & { readonly label: string },
1158
+ ];
1159
+
1160
+ function transitionVariant(index: number): CinemaGalleryVariant {
1161
+ const preset = TRANSITION_PRESETS[index]!;
1162
+ const { label: _label, ...transition } = preset;
1163
+ void _label;
1164
+ return {
1165
+ label: preset.label,
1166
+ mutate: (plan) => ({ ...plan, transitions: plan.transitions.map(() => ({ ...transition })) }),
1167
+ parameter: `preset:${preset.kind}`,
1168
+ };
1169
+ }
1170
+
1171
+ const LOOK_ASSIGNMENTS: readonly {
1172
+ readonly assign: (index: number, lookIds: readonly CinemaLookId[]) => CinemaLookId | undefined;
1173
+ readonly label: string;
1174
+ readonly parameter: string;
1175
+ }[] = [
1176
+ { assign: () => undefined, label: "strip all looks", parameter: "strip" },
1177
+ { assign: (_index, lookIds) => lookIds[0], label: "single look", parameter: "first" },
1178
+ { assign: (index, lookIds) => lookIds[index % lookIds.length], label: "round-robin looks", parameter: "cycle" },
1179
+ { assign: (index, lookIds) => lookIds[lookIds.length - 1 - (index % lookIds.length)], label: "reverse round-robin", parameter: "reverse-cycle" },
1180
+ { assign: (index, lookIds) => (index % 2 === 0 ? lookIds[0] : lookIds[Math.min(1, lookIds.length - 1)]), label: "alternating looks", parameter: "alternate" },
1181
+ { assign: (index, lookIds) => (index === 0 ? lookIds[0] : undefined), label: "hero shot only", parameter: "hero" },
1182
+ ];
1183
+
1184
+ function lookVariant(index: number): CinemaGalleryVariant {
1185
+ const assignment = LOOK_ASSIGNMENTS[index]!;
1186
+ return {
1187
+ label: assignment.label,
1188
+ mutate: (plan) => {
1189
+ const lookIds = plan.looks.map((look) => look.lookId);
1190
+ return {
1191
+ ...plan,
1192
+ shots: plan.shots.map((shot, shotIndex) => {
1193
+ const lookId = assignment.assign(shotIndex, lookIds);
1194
+ const next = { ...shot };
1195
+ if (lookId === undefined) delete (next as { lookId?: CinemaLookId }).lookId;
1196
+ else next.lookId = lookId;
1197
+ return next;
1198
+ }),
1199
+ };
1200
+ },
1201
+ parameter: `assignment:${assignment.parameter}`,
1202
+ };
1203
+ }
1204
+
1205
+ const AUDIO_OFFSETS_DB = [-12, -6, -3, 0, 3, 6] as const;
1206
+
1207
+ function audioVariant(offsetDb: number): CinemaGalleryVariant {
1208
+ return {
1209
+ label: offsetDb === 0 ? "authored gains" : `cue gain ${offsetDb > 0 ? "+" : ""}${offsetDb}dB`,
1210
+ mutate: (plan) => ({
1211
+ ...plan,
1212
+ audioCues: plan.audioCues.map((cue): CinemaAudioCue => ({
1213
+ ...cue,
1214
+ gainDb: Math.max(-60, Math.min(24, cue.gainDb + offsetDb)),
1215
+ })),
1216
+ }),
1217
+ parameter: `offset:${offsetDb}dB`,
1218
+ };
1219
+ }
1220
+
1221
+ const STRUCTURE_ORDERS: readonly {
1222
+ readonly label: string;
1223
+ readonly order: (count: number) => number[];
1224
+ readonly parameter: string;
1225
+ }[] = [
1226
+ { label: "authored order", order: (count) => Array.from({ length: count }, (_value, index) => index), parameter: "identity" },
1227
+ { label: "reverse order", order: (count) => Array.from({ length: count }, (_value, index) => count - 1 - index), parameter: "reverse" },
1228
+ { label: "rotate by one", order: (count) => Array.from({ length: count }, (_value, index) => (index + 1) % count), parameter: "rotate-1" },
1229
+ { label: "rotate by half", order: (count) => Array.from({ length: count }, (_value, index) => (index + Math.floor(count / 2)) % count), parameter: "rotate-half" },
1230
+ {
1231
+ label: "interleaved ends",
1232
+ order: (count) => {
1233
+ const order: number[] = [];
1234
+ for (let index = 0; index < count; index += 1) {
1235
+ order.push(index % 2 === 0 ? index / 2 : count - 1 - Math.floor(index / 2));
1236
+ }
1237
+ return order;
1238
+ },
1239
+ parameter: "interleave",
1240
+ },
1241
+ {
1242
+ label: "evens first",
1243
+ order: (count) => [...Array.from({ length: count }, (_value, index) => index).filter((index) => index % 2 === 0), ...Array.from({ length: count }, (_value, index) => index).filter((index) => index % 2 !== 0)],
1244
+ parameter: "evens-first",
1245
+ },
1246
+ ];
1247
+
1248
+ function structureVariant(index: number): CinemaGalleryVariant {
1249
+ const order = STRUCTURE_ORDERS[index]!;
1250
+ return {
1251
+ label: order.label,
1252
+ mutate: (plan) => ({ ...plan, shots: order.order(plan.shots.length).map((shotIndex) => plan.shots[shotIndex]!) }),
1253
+ parameter: `order:${order.parameter}`,
1254
+ };
1255
+ }
1256
+
1257
+ const MIXED_COMPOSITIONS: readonly { readonly label: string; readonly mutate: (plan: ProjectCinemaPlanV1) => ProjectCinemaPlanV1; readonly parameter: string }[] = [
1258
+ { label: "tighter cuts, short dissolves", mutate: (plan) => transitionVariant(1).mutate(pacingVariant(0.8).mutate(plan)), parameter: "pacing-0.8+dissolve-250" },
1259
+ { label: "tighter cuts, hard cuts", mutate: (plan) => transitionVariant(0).mutate(pacingVariant(0.7).mutate(plan)), parameter: "pacing-0.7+cut" },
1260
+ { label: "reversed, quiet cues", mutate: (plan) => audioVariant(-6).mutate(structureVariant(1).mutate(plan)), parameter: "reverse+-6dB" },
1261
+ { label: "rotated, long dissolves", mutate: (plan) => transitionVariant(3).mutate(structureVariant(2).mutate(plan)), parameter: "rotate-1+dissolve-1000" },
1262
+ { label: "single look, dipped", mutate: (plan) => transitionVariant(4).mutate(lookVariant(1).mutate(plan)), parameter: "first-look+dip" },
1263
+ { label: "interleaved, louder cues", mutate: (plan) => audioVariant(6).mutate(structureVariant(4).mutate(plan)), parameter: "interleave++6dB" },
1264
+ ];
1265
+
1266
+ function variantsForCinemaAxis(axis: CinemaGalleryAxis): readonly CinemaGalleryVariant[] {
1267
+ switch (axis) {
1268
+ case "audio": return AUDIO_OFFSETS_DB.map(audioVariant);
1269
+ case "looks": return LOOK_ASSIGNMENTS.map((_assignment, index) => lookVariant(index));
1270
+ case "mixed": return MIXED_COMPOSITIONS;
1271
+ case "pacing": return PACING_FACTORS.map(pacingVariant);
1272
+ case "structure": return STRUCTURE_ORDERS.map((_order, index) => structureVariant(index));
1273
+ case "transitions": return TRANSITION_PRESETS.map((_preset, index) => transitionVariant(index));
1274
+ }
1275
+ }
1276
+
1277
+ const CINEMA_GALLERY_CANDIDATES = 6;
1278
+
1279
+ /**
1280
+ * Builds a bounded gallery of candidate cinema plans. Every candidate mutates
1281
+ * the authored plan, re-parses through the canonical schema, and re-hashes
1282
+ * through the composition digest; duplicates collapse to the first occurrence.
1283
+ * `selection` is always null — the planner never promotes a candidate.
1284
+ */
1285
+ export function planCinemaGallery(cinemaInput: unknown, axisInput: unknown): CinemaGalleryPlan {
1286
+ const cinema = assertCinemaPlanComposition(ProjectCinemaPlanV1Schema.parse(cinemaInput));
1287
+ const axis = CinemaGalleryAxisSchema.parse(axisInput);
1288
+ const seen = new Set<string>([cinema.cinemaPlanSha256]);
1289
+ const candidates: CinemaGalleryCandidate[] = [];
1290
+ for (const variant of variantsForCinemaAxis(axis)) {
1291
+ const mutated = variant.mutate(cinema);
1292
+ const { cinemaPlanSha256: _sha, ...body } = mutated;
1293
+ void _sha;
1294
+ const parsed = ProjectCinemaPlanV1Schema.parse({ ...body, cinemaPlanSha256: "0".repeat(64) });
1295
+ const { cinemaPlanSha256: _placeholder, ...parsedBody } = parsed;
1296
+ void _placeholder;
1297
+ const cinemaPlanSha256 = canonicalJsonSha256(parsedBody);
1298
+ if (seen.has(cinemaPlanSha256)) continue;
1299
+ seen.add(cinemaPlanSha256);
1300
+ candidates.push({
1301
+ candidateId: `gallery_${axis}_${variant.parameter.replaceAll(/[^a-zA-Z0-9]+/gu, "-")}_${cinemaPlanSha256.slice(0, 8)}`,
1302
+ cinemaPlanSha256,
1303
+ label: variant.label,
1304
+ parameter: variant.parameter,
1305
+ plan: { ...parsed, cinemaPlanSha256 },
1306
+ });
1307
+ if (candidates.length >= CINEMA_GALLERY_CANDIDATES) break;
1308
+ }
1309
+ if (candidates.length === 0) {
1310
+ throw new TypeError(`Cinema gallery axis ${axis} produced no distinct candidates from this plan.`);
1311
+ }
1312
+ return CinemaGalleryPlanSchema.parse({
1313
+ axis,
1314
+ candidates,
1315
+ cinemaPlanSha256: cinema.cinemaPlanSha256,
1316
+ kind: "slopcamera.cinema-gallery-plan",
1317
+ schemaVersion: 1,
1318
+ selection: null,
1319
+ });
1320
+ }