@hraness/slopcamera 3.3.2 → 3.3.4

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 (37) hide show
  1. package/README.md +49 -25
  2. package/apps/desktop/application/directing-contract.ts +15 -3
  3. package/apps/desktop/application/directing-plan.ts +2 -1
  4. package/apps/desktop/application/operation.ts +1 -1
  5. package/apps/desktop/cli/args.ts +1 -1
  6. package/apps/desktop/cli/cinema-renderer.ts +9 -6
  7. package/apps/desktop/cli/commands.ts +8 -1
  8. package/apps/desktop/cli/directing-blob.ts +17 -12
  9. package/apps/desktop/cli/directing-media.ts +14 -4
  10. package/apps/desktop/cli/directing-quote.ts +29 -11
  11. package/apps/desktop/cli/directing-service.ts +6 -2
  12. package/apps/desktop/cli/gateway-media-catalog.ts +57 -0
  13. package/apps/desktop/cli/gateway-media-service.ts +12 -2
  14. package/apps/desktop/cli/help.ts +4 -4
  15. package/apps/desktop/cli/main.ts +1 -1
  16. package/apps/desktop/cli/project-renderer.ts +23 -9
  17. package/apps/desktop/cli/spatial-scene-service.ts +1 -1
  18. package/apps/desktop/dist/cli/main.js +254 -248
  19. package/apps/desktop/html-overlay/spatial-splat-runtime.ts +6 -0
  20. package/dist/cli.js +1 -1
  21. package/dist/code/index.js +4 -4
  22. package/dist/{index-gs48afpt.js → index-c8jpst4g.js} +2 -2
  23. package/dist/index-z1kd1x8j.js +3 -0
  24. package/dist/index.js +1 -1
  25. package/docs/README.md +6 -3
  26. package/docs/studio.md +63 -3
  27. package/package.json +13 -7
  28. package/skills/slopcamera/references/directing-video.md +3 -3
  29. package/skills/slopcamera/references/educational-video.md +1 -1
  30. package/skills/slopcamera/references/install.md +1 -1
  31. package/skills/slopcamera/references/parametric-design.md +5 -1
  32. package/skills/slopcamera/references/scene-building.md +2 -0
  33. package/skills/slopcamera/references/workflows-sdk.md +1 -1
  34. package/src/spatial-scene/geometry.ts +88 -35
  35. package/src/spatial-scene/parametric.ts +42 -15
  36. package/src/version.ts +1 -1
  37. package/dist/index-zhg36tf9.js +0 -3
package/README.md CHANGED
@@ -9,10 +9,21 @@ from retained sources.**
9
9
  Describe a finished result to Codex, Claude, or another coding agent. Slopcamera gives
10
10
  it a Bun CLI, TypeScript SDK, and version-matched Agent Skill to inspect sources,
11
11
  direct cameras, edit a composition, and render the result. A separate MCP server
12
- exposes a fixed diagram and image toolset. There is no Slopcamera account or hosted
12
+ exposes fixed tools for diagrams, images, and scene inspection and planning. There is no Slopcamera account or hosted
13
13
  project database.
14
14
 
15
- [Install](#install-slopcamera) · [Make a first diagram](#make-your-first-diagram) · [Capabilities](#what-slopcamera-does) · [Documentation](docs/README.md) · [slopcamera.com](https://slopcamera.com)
15
+ [Install](#install-slopcamera) · [Make a first diagram](#make-your-first-diagram) · [Capabilities](#what-slopcamera-does) · [Documentation](https://slopcamera.com/docs) · [slopcamera.com](https://slopcamera.com)
16
+
17
+ ## Made with Slopcamera
18
+
19
+ [![An original brass optical instrument rendered in Blender](apps/web/media/native-product-poster-51b1658c46d3.webp)](https://slopcamera.com/docs/tutorials/first-native-film#inspect-the-finished-example)
20
+
21
+ Watch the [six-second product film](https://slopcamera.com/docs/tutorials/first-native-film#inspect-the-finished-example),
22
+ then change its [geometry, materials and camera](examples/showcase/native/product).
23
+ For 2D motion, try the [editable editorial animation](https://slopcamera.com/docs/tutorials/first-animation).
24
+ The [portrait geometry lesson](https://slopcamera.com/docs/how-to/educational-video#inspect-the-finished-example)
25
+ adds mathematical typesetting, a presenter and authored captions. Each guide pairs
26
+ the rendered output with source, runtime requirements and reproduction steps.
16
27
 
17
28
  ## Why Slopcamera
18
29
 
@@ -33,14 +44,14 @@ project database.
33
44
 
34
45
  ## Install Slopcamera
35
46
 
36
- Slopcamera v3.3.1 adds [parametric architectural designs](docs/how-to/parametric-design.md): editable dimensions, constraints, four starters, and galleries of retained 3D scenes. The Three renderer fits shadows to the casting geometry.
47
+ Slopcamera v3.3.3 corrects wall openings, arched crowns, and Boolean surfaces, and documents [film integration](docs/how-to/parametric-design.md#integrate-a-design-into-an-existing-film).
37
48
 
38
49
  Install [Bun 1.3.14 or newer](https://bun.sh), then install the verified
39
- [Slopcamera v3.3.1 release](https://github.com/hraness/slopcamera/releases/tag/v3.3.1)
50
+ [Slopcamera v3.3.3 release](https://github.com/hraness/slopcamera/releases/tag/v3.3.3)
40
51
  from its canonical archive:
41
52
 
42
53
  ```sh
43
- bun add --global https://github.com/hraness/slopcamera/releases/download/v3.3.1/hraness-slopcamera-3.3.1.tgz
54
+ bun add --global https://github.com/hraness/slopcamera/releases/download/v3.3.3/hraness-slopcamera-3.3.3.tgz
44
55
  slopcamera skill install --target agents
45
56
  ```
46
57
 
@@ -98,7 +109,7 @@ change an existing release.
98
109
 
99
110
  ## Make your first diagram
100
111
 
101
- After the source build above, this local task needs no model account. In a new directory,
112
+ After installing Slopcamera, this local task needs no model account. In a new directory,
102
113
  create the included diagram, check it, and render it:
103
114
 
104
115
  ```sh
@@ -115,10 +126,11 @@ You now have `example-flow.tldr`, `example-flow.light.svg`,
115
126
  editable, and the `.tldr` file is editable tldraw interchange. Rendering again
116
127
  replaces those five derived files.
117
128
 
118
- Follow [Your first diagram](docs/tutorials/first-diagram.md) to change a label and
119
- see the result. For a moving 3D subject, use [Directed scenes](docs/spatial-scenes.md).
120
- For detailed native 3D from the current source build, follow
121
- [Your first native film](docs/tutorials/first-native-film.md).
129
+ Follow [Your first diagram](https://slopcamera.com/docs/tutorials/first-diagram) to change a label and
130
+ see the result. To animate type and SVG, [make your first animation](https://slopcamera.com/docs/tutorials/first-animation). For a moving 3D subject, use [Directed scenes](docs/spatial-scenes.md).
131
+ For detailed native 3D, follow
132
+ [Your first native film](docs/tutorials/first-native-film.md). To generate editable
133
+ architecture from dimensions, [build a parametric design](https://slopcamera.com/docs/how-to/parametric-design).
122
134
 
123
135
  ## What Slopcamera does
124
136
 
@@ -134,12 +146,12 @@ GPU path. Spark admits saved splat worlds for local camera direction.
134
146
  > mount the product diagram on a screen in the scene, and show me contact frames
135
147
  > before rendering the video.
136
148
 
137
- The portable GLB profile has a defined geometry and material subset; the
138
- additive rigged profile admits bounded skins and morph targets. Saved splats
149
+ The portable GLB profiles have defined geometry and material subsets. The
150
+ rigged/morph profile in v3.3.1 admits bounded skins and morph targets. Saved splats
139
151
  capture appearance; they do not establish collision geometry or editable native
140
152
  meshes. See [Directed scenes](docs/spatial-scenes.md).
141
153
 
142
- Current source adds the cinematic planning loop: a `slopcamera.spatial-direction`
154
+ Slopcamera v3.3.1 includes the cinematic planning loop: a `slopcamera.spatial-direction`
143
155
  document describes beats, actions, camera coverage, and look intents, compiles
144
156
  into proposed performance/camera/cinema/material-lighting/shot documents, and
145
157
  fans out into bounded galleries per axis. Declared effects bind into renders,
@@ -160,7 +172,7 @@ See [Direct a cinematic world](docs/how-to/direct-cinematic-worlds.md) and the
160
172
 
161
173
  ### Film native worlds and educational animation
162
174
 
163
- The current source build can direct Blender for detailed sets, materials,
175
+ Slopcamera can direct Blender for detailed sets, materials,
164
176
  lighting, skinned characters, cloth and liquid caches; CadQuery for parametric
165
177
  solids and STEP; and Manim Community for mathematical animation. Seven editable
166
178
  starters include a product, character, shaded street, cloth, liquid, CAD bracket,
@@ -181,11 +193,19 @@ and [Make an educational video](docs/how-to/educational-video.md).
181
193
  ### Build diagrams and motion graphics
182
194
 
183
195
  Create editable diagrams with tldraw, SVG, and PNG outputs, or turn raster artwork
184
- into SVG locally with VTracer. Animate graphic layers with HTML, SVG, Motion,
196
+ into SVG locally with VTracer. [Compare a color trace and duotone](https://slopcamera.com/docs/how-to/vectorize-images)
197
+ from the same original raster. Animate graphic layers with HTML, SVG, Motion,
185
198
  p5, Two, Paper Shaders, or Three.js. Outputs can stand alone or join a video
186
199
  project. The optional vgpu example renders programmable WebGPU passes into
187
200
  retained raster frames for use on a world-space screen.
188
201
 
202
+ [Render motion graphics from HTML](https://slopcamera.com/docs/how-to/render-motion-graphics)
203
+ connects original examples across all seven profiles to editable source and exact
204
+ render requests. Try the [editorial title](examples/showcase/html/editorial.json),
205
+ [kinetic typography](examples/showcase/html/kinetic-title.json), or
206
+ [Three.js island with original music](examples/showcase/html/island-pulse.json).
207
+ These source examples live in the current repository; `html render` ships in v3.2.8.
208
+
189
209
  Use `slopcamera html catalog` to inspect the admitted local creative tools. The
190
210
  [creative toolkit reference](docs/html-overlay-creative-toolkit.md) distinguishes
191
211
  available profiles from upstream possibilities. vgpu does not enable shared GPU
@@ -194,11 +214,12 @@ textures or a Three WebGPU renderer inside the current WebGL2/Spark profile.
194
214
  ### Generate and direct media
195
215
 
196
216
  Discover image, video, speech, and transcription models through your own Vercel
197
- AI Gateway access. Generate images from text and references, add a voiceover,
217
+ AI Gateway access. Set `AI_GATEWAY_API_KEY` in the local process environment;
218
+ the Vercel CLI is optional. Generate images from text and references, add a voiceover,
198
219
  transcribe sound, or create video shots using the selected model's supported
199
220
  inputs. Availability and pricing come from the live catalog.
200
221
 
201
- The current source build also provides `direct` shot recipes: retain a film
222
+ The released CLI also provides `direct` shot recipes: retain a film
202
223
  budget across attempts, review each take before accepting it, and use an accepted
203
224
  clip's last decoded frame as the next shot's reference. Changed predecessors
204
225
  invalidate affected continuations while earlier paid results remain retained.
@@ -220,8 +241,11 @@ from the same edit.
220
241
  > Edit my product demo: cut the pauses, zoom into each important click, keep the
221
242
  > speaker framed, add captions and `logo.svg`, and show a preview before export.
222
243
 
223
- Recording bundles can carry clicks, cursor movement, key activity, and
224
- focused-input information. Read [`PRIVACY.md`](PRIVACY.md) before editing
244
+ The released and current CLIs edit existing recording bundles; they do not
245
+ capture new recordings. Bundles can carry clicks, cursor movement, key activity,
246
+ and focused-input information. Automatic screen-action zooms need this metadata.
247
+ Standalone media imports need an existing project; `html render`, `studio assemble`,
248
+ or `direct assemble` can create one. Read [`PRIVACY.md`](PRIVACY.md) before editing
225
249
  sensitive material.
226
250
 
227
251
  Start with [Edit a video](docs/how-to/edit-video.md) or inspect a reusable recipe:
@@ -236,7 +260,7 @@ slopcamera workflows show social-variants --json
236
260
  Keep the source that owns each creative decision. A native scene owns a rig or
237
261
  simulation; a portable scene owns supported geometry, cameras, and media surfaces;
238
262
  a diagram owns its objects and labels; a video project owns cuts and delivery.
239
- SLOPCAMERA connects these through explicit assets and rendered derivatives.
263
+ Slopcamera connects these through explicit assets and rendered derivatives.
240
264
 
241
265
  1. **Prepare the sources.** Import footage and assets or author a scene, diagram,
242
266
  or native program. Inspect available tools before choosing an engine.
@@ -274,9 +298,9 @@ declarative graphs, approvals, and resuming work.
274
298
  - **Trusted code is not sandboxed.** Native Python and caller-authored Bun
275
299
  workflows run with the current user's access. Hashes and receipts identify
276
300
  observed inputs and outputs; they do not make arbitrary code hermetic.
277
- - **MCP is a subset.** Its fixed tools check and render diagrams, plan and audit
278
- scenes, and run the bounded portable operations. It does not expose every
279
- local CLI operation and never mutates project state.
301
+ - **MCP is a subset.** Its 17 fixed tools check and render diagrams, plan and audit
302
+ scenes, and run six bounded portable operation codes in v3.3.1. It does not
303
+ expose every local CLI operation and never mutates project state.
280
304
 
281
305
  ## Design and trust
282
306
 
@@ -295,7 +319,7 @@ Native tools, providers, codecs, and GPU drivers can affect results, so retained
295
319
  source identity alone does not promise identical pixels on another machine.
296
320
 
297
321
  See [Architecture](docs/architecture.md), [`SECURITY.md`](SECURITY.md),
298
- [`PRIVACY.md`](PRIVACY.md), and [`NOTICE`](NOTICE) for the detailed boundaries.
322
+ [`PRIVACY.md`](PRIVACY.md), and [`NOTICE.md`](NOTICE.md) for the detailed boundaries.
299
323
 
300
324
  ## Documentation
301
325
 
@@ -347,7 +371,7 @@ changing a package or runtime boundary. Report vulnerabilities through
347
371
 
348
372
  ## License
349
373
 
350
- [MIT](LICENSE), with third-party notices in [`NOTICE`](NOTICE).
374
+ [MIT](LICENSE), with third-party notices in [`NOTICE.md`](NOTICE.md).
351
375
 
352
376
  ## Optional support
353
377
 
@@ -20,6 +20,10 @@ const MoneySchema = z.number().int().safe().positive();
20
20
  const TextSchema = z.string().min(1).max(100_000).refine(value => value.trim().length > 0 && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(value), "Text must be nonempty and contain no disallowed control characters.");
21
21
  const ImageSchema = GatewayMediaSourceReferenceSchema.refine(source => source.mediaType.startsWith("image/") && source.bytes <= 50 * 1024 * 1024, "A directing frame must be an image of at most 50 MiB.");
22
22
  const VideoSourceSchema = GatewayMediaSourceReferenceSchema.refine(source => source.mediaType.startsWith("video/") && source.facts?.durationSeconds !== undefined, "Endpoint video sources require a measured duration.");
23
+ const ReferenceSchema = GatewayMediaSourceReferenceSchema.refine(source =>
24
+ (source.mediaType.startsWith("image/") && source.bytes <= 50 * 1024 * 1024)
25
+ || (source.mediaType.startsWith("video/") && source.bytes <= 256 * 1024 * 1024), "A directing reference must be an image of at most 50 MiB or a video of at most 256 MiB.");
26
+ const DIRECTING_REFERENCE_LIMITS = Object.freeze({ count: 8, totalBytes: 512 * 1024 * 1024 });
23
27
 
24
28
  function capture(input: unknown, name: string, maximumBytes: number): unknown {
25
29
  return createBoundedJsonValueSnapshot(input, maximumBytes, name, { maximumDepth: 32, maximumValues: 1_000_000 }).value;
@@ -39,8 +43,13 @@ export const DirectingShotSchema = z.strictObject({
39
43
  z.strictObject({ kind: z.literal("shot-end"), shotId: SlugSchema }),
40
44
  ]).optional(),
41
45
  lastFrame: ImageSchema.optional(),
46
+ references: z.array(ReferenceSchema).min(1).max(DIRECTING_REFERENCE_LIMITS.count).optional(),
42
47
  }).superRefine((shot, context) => {
43
48
  if (shot.lastFrame !== undefined && shot.firstFrame === undefined) context.addIssue({ code: "custom", message: "A last frame requires an authored first-frame reference." });
49
+ const references = shot.references ?? [];
50
+ if (references.length > 0 && (shot.firstFrame !== undefined || shot.lastFrame !== undefined)) context.addIssue({ code: "custom", message: "Directing references are mutually exclusive with frame conditioning." });
51
+ if (references.reduce((total, reference) => total + reference.bytes, 0) > DIRECTING_REFERENCE_LIMITS.totalBytes) context.addIssue({ code: "custom", message: "Directing references exceed the 512 MiB aggregate bound." });
52
+ if (new Set(references.map(reference => `${reference.path}${reference.sha256}`)).size !== references.length) context.addIssue({ code: "custom", message: "Directing references must be distinct retained media." });
44
53
  });
45
54
 
46
55
  export const DirectingRecipeSchema = z.preprocess(input => capture(input, "directing recipe", 8 * 1024 * 1024), z.strictObject({
@@ -101,7 +110,7 @@ const ReviewSchema = z.strictObject({ decision: z.enum(["accepted", "rejected"])
101
110
  const FailureSchema = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/u);
102
111
  const VideoRequestSchema = z.strictObject({ operation: z.literal("video"), request: GatewayVideoOperationInputSchema }).superRefine((input, context) => {
103
112
  const request = input.request;
104
- if (request.providerOptions !== undefined || request.references !== undefined || request.generateAudio !== undefined || (request.n ?? 1) !== 1 || (request.maxVideosPerCall ?? 1) !== 1) context.addIssue({ code: "custom", message: "Directing uses exactly one video with authored frame conditioning and no provider options." });
113
+ if (request.providerOptions !== undefined || request.generateAudio !== undefined || (request.n ?? 1) !== 1 || (request.maxVideosPerCall ?? 1) !== 1) context.addIssue({ code: "custom", message: "Directing uses exactly one video with authored conditioning and no provider options." });
105
114
  });
106
115
  const AttemptBase = {
107
116
  id: AttemptIdSchema,
@@ -149,7 +158,7 @@ export function directingRequestId(input: {
149
158
  return `gateway_${canonicalJsonSha256({ domain: "slopcamera.directing-request/v1", id, attemptId, recipeSha256, shotSha256, request })}`;
150
159
  }
151
160
 
152
- /** Verify the submitted request against the authored recipe and retained frames. */
161
+ /** Verify the submitted request against the authored recipe and retained conditioning. */
153
162
  export function directingRequestMatchesShot(attempt: DirectingAttempt, shot: DirectingShot, firstFrame: GatewayMediaSourceReference | undefined): boolean {
154
163
  const request = attempt.request.request;
155
164
  for (const key of ["prompt", "model", "durationSeconds", "resolution", "aspectRatio", "fps", "seed"] as const) if (request[key] !== shot[key]) return false;
@@ -157,7 +166,10 @@ export function directingRequestMatchesShot(attempt: DirectingAttempt, shot: Dir
157
166
  const actualFirst = request.promptImage ?? frames.find(frame => frame.frameType === "first_frame")?.source;
158
167
  const actualLast = frames.find(frame => frame.frameType === "last_frame")?.source;
159
168
  const equal = (left: GatewayMediaSourceReference | undefined, right: GatewayMediaSourceReference | undefined) => left === undefined || right === undefined ? left === right : sameDirectingSource(left, right);
160
- return equal(firstFrame, actualFirst) && equal(shot.lastFrame, actualLast);
169
+ const actualReferences = request.references ?? [];
170
+ return equal(firstFrame, actualFirst) && equal(shot.lastFrame, actualLast)
171
+ && actualReferences.length === (shot.references ?? []).length
172
+ && (shot.references ?? []).every((reference, index) => sameDirectingSource(reference, actualReferences[index]!));
161
173
  }
162
174
 
163
175
  export const DirectingStateSchema = z.preprocess(input => capture(input, "directing state", DIRECTING_LIMITS.stateBytes), z.strictObject({
@@ -28,6 +28,7 @@ export interface ResolvedDirectingShot {
28
28
  readonly dependencies: DirectingDependency[];
29
29
  readonly firstFrame?: GatewayMediaSourceReference;
30
30
  readonly lastFrame?: GatewayMediaSourceReference;
31
+ readonly references?: readonly GatewayMediaSourceReference[];
31
32
  }
32
33
 
33
34
  function resolveShot(shot: DirectingShot, accepted: ReadonlyMap<string, CompletedDirectingAttempt>): ResolvedDirectingShot | undefined {
@@ -43,7 +44,7 @@ function resolveShot(shot: DirectingShot, accepted: ReadonlyMap<string, Complete
43
44
  boundDependencies.push({ ...dependency, shotSha256: predecessor.shotSha256 });
44
45
  firstFrame = predecessor.endpoint.image;
45
46
  }
46
- return { shot, shotSha256: directingBoundShotSha256(shot, boundDependencies), dependencies, ...(firstFrame === undefined ? {} : { firstFrame }), ...(shot.lastFrame === undefined ? {} : { lastFrame: shot.lastFrame }) };
47
+ return { shot, shotSha256: directingBoundShotSha256(shot, boundDependencies), dependencies, ...(firstFrame === undefined ? {} : { firstFrame }), ...(shot.lastFrame === undefined ? {} : { lastFrame: shot.lastFrame }), ...(shot.references === undefined ? {} : { references: shot.references }) };
47
48
  }
48
49
 
49
50
  function acceptedSelections(state: DirectingState): Map<string, CompletedDirectingAttempt> {
@@ -4,7 +4,7 @@ import type { z } from "zod";
4
4
 
5
5
  import type { ApplicationContext } from "./context";
6
6
 
7
- export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.3.2" as const;
7
+ export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.3.4" as const;
8
8
 
9
9
  export const OPERATION_KINDS = [
10
10
  "scene.audit",
@@ -3231,7 +3231,7 @@ function parseDirectingArgs(argv: readonly string[]): DirectingCommand {
3231
3231
  const required = (name: string): string => optionString(parsed, name) ?? fail(`direct ${action} requires ${name}.`);
3232
3232
  const common = { kind: "directing" as const, json: optionFlag(parsed, "--json") };
3233
3233
  if (action === "anchor") {
3234
- exactPositionals(parsed, 0, "slopcamera direct anchor --input <image>");
3234
+ exactPositionals(parsed, 0, "slopcamera direct anchor --input <media>");
3235
3235
  return { ...common, action, input: required("--input") };
3236
3236
  }
3237
3237
  const [value] = exactPositionals(parsed, 1, `slopcamera direct ${action} <${["init", "plan", "start"].includes(action) ? "recipe.json" : "direct-id"}>`);
@@ -142,11 +142,11 @@ function sliceChain(slice: CinemaMediaSlice, inputIndex: ReadonlyMap<string, num
142
142
  const inputDurationUs = slice.fileRange.endUs - slice.fileRange.startUs;
143
143
  const outputDurationUs = slice.outputRange.endUs - slice.outputRange.startUs;
144
144
  return [
145
- `[${inputSpecifier(input, slice.streamIndex)}]`,
146
- `trim=start=${seconds(slice.fileRange.startUs)}:end=${seconds(slice.fileRange.endUs)}`,
145
+ `[${inputSpecifier(input, slice.streamIndex)}]trim=start=${seconds(slice.fileRange.startUs)}:end=${seconds(slice.fileRange.endUs)}`,
147
146
  "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`,
147
+ // Concat requires each member to start at zero. Position the complete
148
+ // window once after joining, so internal slice boundaries stay contiguous.
149
+ `setpts=(PTS-STARTPTS)*${decimal(outputDurationUs / inputDurationUs)}`,
150
150
  "format=rgba",
151
151
  ].join(",");
152
152
  }
@@ -173,7 +173,7 @@ function windowChain(
173
173
  : `scale=w=${outputWidth}:h=${outputHeight}:force_original_aspect_ratio=decrease,pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:color=black@0`;
174
174
  const joined = label(context, prefix);
175
175
  parts.push(
176
- `${members.map(member => `[${member}]`).join("")}concat=n=${members.length}:v=1:a=0,${geometry},format=rgba${gradeFilter === undefined ? "" : `,${gradeFilter}`}[${joined}]`,
176
+ `${members.map(member => `[${member}]`).join("")}concat=n=${members.length}:v=1:a=0,setpts=PTS+${seconds(slices[0]!.outputRange.startUs)}/TB,${geometry},format=rgba${gradeFilter === undefined ? "" : `,${gradeFilter}`}[${joined}]`,
177
177
  );
178
178
  context.filters.push(...parts);
179
179
  return joined;
@@ -255,8 +255,11 @@ function transitionWindowChain(
255
255
  stream = windowChain(context, slices, shot.fit, grade, outputWidth, outputHeight, prefix);
256
256
  }
257
257
  const zeroed = label(context, `${prefix}_zero`);
258
+ // A dip/flash reveals only the tail of the incoming handle. Its hidden
259
+ // prefix still consumes source time, so the tail must meet the shot body
260
+ // without skipping the last part of the handle at the transition boundary.
258
261
  context.filters.push(
259
- `[${stream}]setpts=PTS-STARTPTS,fps=${context.rate}${limitUs === undefined ? "" : `,trim=end=${seconds(limitUs)},setpts=PTS-STARTPTS`}[${zeroed}]`,
262
+ `[${stream}]setpts=PTS-STARTPTS${limitUs === undefined ? "" : `,trim=start=${seconds(durationUs - limitUs)}:end=${seconds(durationUs)},setpts=PTS-STARTPTS`},fps=${context.rate}[${zeroed}]`,
260
263
  );
261
264
  return zeroed;
262
265
  };
@@ -355,7 +355,7 @@ import {
355
355
  workflowRunStore,
356
356
  } from "./workflow-runs";
357
357
 
358
- export const SLOPCAMERA_VERSION = "3.3.2";
358
+ export const SLOPCAMERA_VERSION = "3.3.4";
359
359
 
360
360
  // Legacy direct renders predate per-target output contracts. Keep them
361
361
  // bounded generously enough for long-form production while preventing one
@@ -3897,6 +3897,11 @@ async function inspectStagedGatewayMediaFile(
3897
3897
  ffprobe: string,
3898
3898
  signal?: AbortSignal,
3899
3899
  ): Promise<LoadedGatewayPhysicalFile> {
3900
+ // On-disk JPEG files probe through ffmpeg's image2 sequence demuxer, which the
3901
+ // whitelist deliberately omits; pin the single-image pipe demuxer instead.
3902
+ const forcedInputFormat = file.mediaType === "image/jpeg"
3903
+ ? ["-f", "jpeg_pipe"]
3904
+ : [];
3900
3905
  const probe = await context.runner.run([
3901
3906
  ffprobe,
3902
3907
  "-hide_banner",
@@ -3906,6 +3911,7 @@ async function inspectStagedGatewayMediaFile(
3906
3911
  "-show_entries",
3907
3912
  "format=duration:stream=codec_type,width,height,duration,channels,sample_rate,avg_frame_rate,r_frame_rate",
3908
3913
  "-of", "json",
3914
+ ...forcedInputFormat,
3909
3915
  inspectionPath,
3910
3916
  ], {
3911
3917
  ...(signal === undefined ? {} : { abortSignal: signal }),
@@ -4078,6 +4084,7 @@ async function inspectStagedGatewayMediaFile(
4078
4084
  "-xerror",
4079
4085
  ...SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS,
4080
4086
  "-threads", "1",
4087
+ ...forcedInputFormat,
4081
4088
  "-i", inspectionPath,
4082
4089
  "-map", "0:V?",
4083
4090
  "-map", "0:a?",
@@ -18,7 +18,7 @@ import { resolveVerifiedProjectMedia } from "./project-media-integrity";
18
18
  // put.ts, del.ts, and signed-token.ts in https://github.com/vercel/storage.
19
19
  // The SDK's network retry/environment overrides are not part of this adapter.
20
20
  const API = "https://vercel.com/api/blob";
21
- const LIMITS = { imageBytes: 30 * 1024 * 1024, entries: 2, receiptBytes: 64 * 1024, responseBytes: 32 * 1024, requestMs: 60_000, cleanupMs: 120_000, accessMs: 15 * 60_000 } as const;
21
+ const LIMITS = { imageBytes: 30 * 1024 * 1024, videoBytes: 256 * 1024 * 1024, entries: 8, receiptBytes: 64 * 1024, responseBytes: 32 * 1024, requestMs: 60_000, uploadMs: 15 * 60_000, cleanupMs: 120_000, accessMs: 15 * 60_000 } as const;
22
22
  const RECEIPT = "receipt.json";
23
23
  const NOTICE = "Private reference hosting uses Vercel Blob operations, storage, and transfer billed separately from the Gateway generation budget. Expiring model access does not delete the stored object.";
24
24
  const IdSchema = z.string().regex(/^direct_[a-z][a-z0-9_-]{0,63}$/u);
@@ -26,10 +26,15 @@ const TakeSchema = z.string().regex(/^take_[a-z][a-z0-9_-]{0,63}$/u);
26
26
  const StoreSchema = z.string().regex(/^[A-Za-z0-9]{1,64}$/u);
27
27
  const TimeSchema = z.number().int().safe().positive();
28
28
  const EtagSchema = z.string().min(1).max(256).regex(/^[\x21-\x7e]+$/u);
29
- const SourceSchema = GatewayMediaSourceReferenceSchema.refine(source => ["image/png", "image/jpeg", "image/webp"].includes(source.mediaType) && source.bytes <= LIMITS.imageBytes, "Hosted directing references must be PNG, JPEG, or WebP images at most 30 MiB.");
29
+ const SourceSchema = GatewayMediaSourceReferenceSchema.refine(source =>
30
+ (["image/png", "image/jpeg", "image/webp"].includes(source.mediaType) && source.bytes <= LIMITS.imageBytes)
31
+ || (["video/mp4", "video/quicktime"].includes(source.mediaType) && source.bytes <= LIMITS.videoBytes), "Hosted directing references must be PNG, JPEG, or WebP images at most 30 MiB, or MP4/QuickTime video at most 256 MiB.");
32
+ function sourceExtension(mediaType: string): string {
33
+ return mediaType === "image/jpeg" ? "jpg" : mediaType === "video/quicktime" ? "mov" : mediaType.slice(6);
34
+ }
30
35
  const EntrySchema = z.strictObject({
31
36
  source: SourceSchema,
32
- pathname: z.string().regex(/^slopcamera\/directing\/[a-f0-9]{32}\/[01]-[a-f0-9]{64}\.(?:png|jpg|webp)$/u),
37
+ pathname: z.string().regex(/^slopcamera\/directing\/[a-f0-9]{32}\/[0-7]-[a-f0-9]{64}\.(?:png|jpg|webp|mp4|mov)$/u),
33
38
  putStartedAt: TimeSchema,
34
39
  putCompletedAt: TimeSchema.optional(),
35
40
  etag: EtagSchema.optional(),
@@ -52,7 +57,7 @@ export const DirectingBlobReceiptSchema = z.strictObject({
52
57
  entries: z.array(EntrySchema).max(LIMITS.entries),
53
58
  }).superRefine((receipt, context) => {
54
59
  for (const [index, entry] of receipt.entries.entries()) {
55
- const extension = entry.source.mediaType === "image/jpeg" ? "jpg" : entry.source.mediaType.slice(6);
60
+ const extension = sourceExtension(entry.source.mediaType);
56
61
  if (entry.pathname !== `slopcamera/directing/${receipt.namespace}/${index}-${entry.source.sha256}.${extension}`) context.addIssue({ code: "custom", message: "A hosted object must bind this session's exact private namespace and source bytes." });
57
62
  }
58
63
  });
@@ -161,12 +166,12 @@ export async function createDirectingBlobSession(options: DirectingBlobOptions):
161
166
  const save = async () => { await fs.writeTextAtomicGuarded!(RECEIPT, `${canonicalJson(DirectingBlobReceiptSchema.parse(receipt))}\n`, lease.assertOwned); };
162
167
  return await execute(receipt, save, lease.assertOwned);
163
168
  });
164
- const api = async (receipt: DirectingBlobReceipt, pathname: string, method: "GET" | "PUT" | "POST", body: string | Uint8Array | undefined, headers: Record<string, string>, signal: AbortSignal, assertOwned: () => Promise<void>): Promise<{ status: number; data: Uint8Array }> => {
169
+ const api = async (receipt: DirectingBlobReceipt, pathname: string, method: "GET" | "PUT" | "POST", body: string | Uint8Array | undefined, headers: Record<string, string>, signal: AbortSignal, assertOwned: () => Promise<void>, timeoutMs: number = LIMITS.requestMs): Promise<{ status: number; data: Uint8Array }> => {
165
170
  const auth = credentials(options.environment);
166
171
  if (auth.storeId !== receipt.storeId) failure("Blob credentials don't match the retained store.", "authorization-required");
167
172
  await assertOwned();
168
173
  signal.throwIfAborted();
169
- const bounded = AbortSignal.any([signal, AbortSignal.timeout(LIMITS.requestMs)]);
174
+ const bounded = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
170
175
  const response = await request(`${API}${pathname}`, { method, redirect: "error", signal: bounded,
171
176
  headers: { authorization: `Bearer ${auth.token}`, "x-vercel-blob-store-id": auth.storeId, "x-api-version": "12", "x-api-blob-request-id": `${auth.storeId}:${randomUUID()}`, "x-api-blob-request-attempt": "0", ...headers },
172
177
  ...(body === undefined ? {} : { body: typeof body === "string" ? body : Buffer.from(body) }),
@@ -189,7 +194,7 @@ export async function createDirectingBlobSession(options: DirectingBlobOptions):
189
194
  };
190
195
  const verifyGet = async (receipt: DirectingBlobReceipt, entry: Entry, url: string, signal: AbortSignal): Promise<{ found: false } | { found: true; etag: string }> => {
191
196
  exactPrivateUrl(url, receipt.storeId, entry.pathname, true);
192
- const bounded = AbortSignal.any([signal, AbortSignal.timeout(LIMITS.requestMs)]);
197
+ const bounded = AbortSignal.any([signal, AbortSignal.timeout(LIMITS.uploadMs)]);
193
198
  const response = await request(url, { method: "GET", redirect: "error", signal: bounded, headers: { "cache-control": "no-cache" } });
194
199
  if (response.status === 404) { await response.body?.cancel(); return { found: false }; }
195
200
  if (response.status !== 200) { await response.body?.cancel(); failure("Private hosted reference could not be verified."); }
@@ -212,9 +217,9 @@ export async function createDirectingBlobSession(options: DirectingBlobOptions):
212
217
  inspect: read,
213
218
  resolveSourceUrl: async (input, data, signal) => {
214
219
  const source = SourceSchema.parse(input);
215
- if (!(data instanceof Uint8Array) || data.length !== source.bytes) failure("Reference hosting requires the exact retained image bytes.", "invalid-data");
220
+ if (!(data instanceof Uint8Array) || data.length !== source.bytes) failure("Reference hosting requires the exact retained reference bytes.", "invalid-data");
216
221
  const uploadBytes = new Uint8Array(data);
217
- if (createHash("sha256").update(uploadBytes).digest("hex") !== source.sha256 || !gatewayMediaBytesMatchType(uploadBytes, source.mediaType)) failure("Reference hosting requires the exact retained image bytes.", "invalid-data");
222
+ if (createHash("sha256").update(uploadBytes).digest("hex") !== source.sha256 || !gatewayMediaBytesMatchType(uploadBytes, source.mediaType)) failure("Reference hosting requires the exact retained reference bytes.", "invalid-data");
218
223
  await resolveVerifiedProjectMedia({ repositoryRoot: application.paths.repositoryRoot, path: source.path, expected: source, label: "Directing hosted reference" });
219
224
  const activeSignal = AbortSignal.any([options.signal, signal]);
220
225
  return await mutate(async (receipt, save, assertOwned) => {
@@ -222,14 +227,14 @@ export async function createDirectingBlobSession(options: DirectingBlobOptions):
222
227
  if (receipt.closedAt !== undefined) failure("This reference-hosting session is closed; use a new take for another generation.", "conflict");
223
228
  let entry = receipt.entries.find(value => sourceIdentity(value.source) === sourceIdentity(source));
224
229
  if (entry === undefined) {
225
- if (receipt.entries.length >= LIMITS.entries) failure("One directing take may host at most two exact image references.", "invalid-data");
226
- const extension = source.mediaType === "image/jpeg" ? "jpg" : source.mediaType.slice(6);
230
+ if (receipt.entries.length >= LIMITS.entries) failure("One directing take may host at most eight exact media references.", "invalid-data");
231
+ const extension = sourceExtension(source.mediaType);
227
232
  entry = { source, pathname: `slopcamera/directing/${receipt.namespace}/${receipt.entries.length}-${source.sha256}.${extension}`, putStartedAt: now(), cleanup: "pending", failures: [] };
228
233
  receipt.entries.push(entry);
229
234
  await save(); // Durable exact upload intent precedes the only PUT.
230
235
  try {
231
236
  const uploaded = await api(receipt, `/?${new URLSearchParams({ pathname: entry.pathname })}`, "PUT", uploadBytes,
232
- { "content-type": source.mediaType, "x-content-type": source.mediaType, "x-vercel-blob-access": "private", "x-add-random-suffix": "0", "x-allow-overwrite": "0", "x-cache-control-max-age": "60" }, activeSignal, assertOwned);
237
+ { "content-type": source.mediaType, "x-content-type": source.mediaType, "x-vercel-blob-access": "private", "x-add-random-suffix": "0", "x-allow-overwrite": "0", "x-cache-control-max-age": "60" }, activeSignal, assertOwned, LIMITS.uploadMs);
233
238
  if (uploaded.status < 200 || uploaded.status > 299) failure("Private reference upload did not complete.");
234
239
  const result = z.object({ url: z.string().max(2048), pathname: z.string().max(512), contentType: z.string().max(128), etag: EtagSchema }).parse(JSON.parse(new TextDecoder().decode(uploaded.data)) as unknown);
235
240
  exactPrivateUrl(result.url, receipt.storeId, entry.pathname, false);
@@ -20,7 +20,7 @@ import { withMutationLock } from "./mutation-lock";
20
20
  import { ensurePhysicalPrivateDirectoryWithin } from "./paths";
21
21
  import { resolveVerifiedProjectMedia } from "./project-media-integrity";
22
22
 
23
- const LIMITS = { videoBytes: 512 * 1024 * 1024, imageBytes: 30 * 1024 * 1024, probeBytes: 8 * 1024 * 1024, dimension: 4096, frames: 4000, durationUs: 60_000_000 } as const;
23
+ const LIMITS = { videoBytes: 512 * 1024 * 1024, imageBytes: 30 * 1024 * 1024, referenceBytes: 256 * 1024 * 1024, probeBytes: 8 * 1024 * 1024, dimension: 4096, frames: 4000, durationUs: 60_000_000 } as const;
24
24
  const INPUT_ARGUMENTS = ["-protocol_whitelist", "file", "-format_whitelist", "mov", "-enable_drefs", "0", "-use_absolute_path", "0", "-max_pixels", String(LIMITS.dimension ** 2), "-threads", "2"] as const;
25
25
  const artifactSchema = z.strictObject({ path: RepositoryRelativePathSchema, bytes: z.number().int().safe().positive(), sha256: Sha256Schema });
26
26
  const endpointSchema = DirectingEndpointSchema.refine(endpoint => endpoint.frameIndex < LIMITS.frames, "Directing endpoint exceeds the 4000-frame media bound.");
@@ -226,15 +226,25 @@ async function retainVideo(application: ApplicationContext, input: GatewayMediaS
226
226
  return GatewayMediaSourceReferenceSchema.parse({ ...artifact, mediaType: source.mediaType });
227
227
  }
228
228
 
229
- /** Imports exactly the caller-named still; never uploads it or reads neighboring files. */
229
+ /** Imports exactly the caller-named still or clip; never uploads it or reads neighboring files. */
230
230
  export async function importDirectingAnchor(application: ApplicationContext, path: string, signal: AbortSignal): Promise<GatewayMediaSourceReference> {
231
231
  await active(application, signal);
232
232
  const resolved = resolve(application.paths.repositoryRoot, path);
233
233
  if (await realpath(dirname(resolved)) !== dirname(resolved)) throw new CliError("unsafe-path", "Directing anchors require physical parent directories.");
234
- const bytes = await fileBytes(resolved, LIMITS.imageBytes, signal);
234
+ const bytes = await fileBytes(resolved, LIMITS.referenceBytes, signal);
235
+ const brand = bytes.length >= 12 && Buffer.from(bytes.subarray(4, 8)).toString("ascii") === "ftyp" ? Buffer.from(bytes.subarray(8, 12)).toString("ascii") : undefined;
236
+ if (brand === "qt " || brand !== undefined && /^(?:isom|iso2|iso5|iso6|mp41|mp42|avc1|dash|mmp4|M4V )$/u.test(brand)) {
237
+ const mediaType = brand === "qt " ? "video/quicktime" : "video/mp4";
238
+ const artifact = await publishBytes(application, bytes, mediaType === "video/quicktime" ? "mov" : "mp4", signal);
239
+ const provisional = GatewayMediaSourceReferenceSchema.parse({ ...artifact, mediaType });
240
+ const ffprobe = await capability(application, "ffprobe");
241
+ const probed = await probeVideo(application, provisional, ffprobe.command, signal);
242
+ return GatewayMediaSourceReferenceSchema.parse({ ...provisional, facts: { durationSeconds: probed.media.durationUs / 1_000_000, width: probed.stream.width, height: probed.stream.height } });
243
+ }
244
+ if (bytes.length > LIMITS.imageBytes) fail("Directing anchors require a single unrotated PNG, JPEG, or WebP image no larger than 30 MiB.");
235
245
  const metadata = await sharp(bytes, { limitInputPixels: LIMITS.dimension ** 2, failOn: "error", animated: true }).metadata();
236
246
  const extension = metadata.format === "jpeg" ? "jpg" : metadata.format;
237
- if (!extension || !["png", "jpg", "webp"].includes(extension) || metadata.pages !== undefined && metadata.pages !== 1 || !metadata.width || !metadata.height || metadata.width > LIMITS.dimension || metadata.height > LIMITS.dimension || metadata.orientation !== undefined && metadata.orientation !== 1) fail("Directing anchors require a single unrotated PNG, JPEG, or WebP image no larger than 4096 pixels per side.");
247
+ if (!extension || !["png", "jpg", "webp"].includes(extension) || metadata.pages !== undefined && metadata.pages !== 1 || !metadata.width || !metadata.height || metadata.width > LIMITS.dimension || metadata.height > LIMITS.dimension || metadata.orientation !== undefined && metadata.orientation !== 1) fail("Directing anchors require a single unrotated PNG, JPEG, or WebP image no larger than 4096 pixels per side, or self-contained MP4/QuickTime video.");
238
248
  await sharp(bytes, { limitInputPixels: LIMITS.dimension ** 2, failOn: "error" }).raw().toBuffer();
239
249
  const artifact = await publishBytes(application, bytes, extension, signal);
240
250
  return GatewayMediaSourceReferenceSchema.parse({ ...artifact, mediaType: metadata.format === "jpeg" ? "image/jpeg" : `image/${metadata.format}`, facts: { width: metadata.width, height: metadata.height } });
@@ -1,5 +1,5 @@
1
1
  import type { DirectingQuote, DirectingShot } from "../application/directing-contract";
2
- import type { GatewayMediaCatalogView, GatewayJsonValue } from "./gateway-media-catalog";
2
+ import { providerVideoResolution, type GatewayMediaCatalogView, type GatewayJsonValue } from "./gateway-media-catalog";
3
3
  import { CliError } from "./errors";
4
4
 
5
5
  /** Integer microdollars avoid floating point drift across small paid attempts. */
@@ -42,9 +42,13 @@ export function quoteDirectingShot(
42
42
  const model = catalog.snapshot.models.find(value => value.id === shot.model);
43
43
  if (model?.kind !== "video" || model.executionMode !== "video-model") unsupported("the selected model is not a batch video model.");
44
44
  const capability = model.capabilities;
45
- const operation = shot.lastFrame !== undefined ? "first-last-frame" : shot.firstFrame !== undefined ? "image-to-video" : "text-to-video";
45
+ const hasVideoReference = (shot.references ?? []).some(reference => reference.mediaType.startsWith("video/"));
46
+ const operations = (shot.references?.length ?? 0) > 0
47
+ ? ["reference-to-video", ...(hasVideoReference ? ["video-editing", "motion-control", "extend-video"] : [])]
48
+ : [shot.lastFrame !== undefined ? "first-last-frame" : shot.firstFrame !== undefined ? "image-to-video" : "text-to-video"];
49
+ const supportedOperations = capability?.["supported_operations"];
50
+ if (!Array.isArray(supportedOperations) || !operations.some(operation => supportedOperations.includes(operation))) unsupported(`the catalog does not confirm any of the shot's operations (${operations.join(", ")}).`);
46
51
  for (const [key, value] of [
47
- ["supported_operations", operation],
48
52
  ["supported_resolutions", shot.resolution],
49
53
  ["supported_aspect_ratios", shot.aspectRatio],
50
54
  ["supported_durations_seconds", shot.durationSeconds],
@@ -53,6 +57,9 @@ export function quoteDirectingShot(
53
57
  const supported = capability?.[key];
54
58
  if (!Array.isArray(supported) || !supported.includes(value)) unsupported(`the catalog does not confirm ${key}=${value}.`);
55
59
  }
60
+ if (providerVideoResolution(model.id, shot.resolution, shot.aspectRatio) === null) {
61
+ unsupported(`the provider cannot express resolution ${shot.resolution} at aspect ${shot.aspectRatio}.`);
62
+ }
56
63
  if (model.pricing.varies_by_provider === true) unsupported("provider-dependent pricing cannot establish a bounded reservation.");
57
64
  const rows = model.pricing.video_duration_pricing;
58
65
  if (!Array.isArray(rows) || rows.length === 0 || rows.length > 128) unsupported("duration pricing is unavailable.");
@@ -76,14 +83,25 @@ export function quoteDirectingShot(
76
83
 
77
84
  /** Determine transport from the same catalog used to price the shot. */
78
85
  export function directingInputTransport(catalog: GatewayMediaCatalogView, shot: DirectingShot): "none" | "inline" | "url" {
79
- if (shot.firstFrame === undefined && shot.lastFrame === undefined) return "none";
86
+ const kinds = new Set((shot.references ?? []).map(reference => reference.mediaType.split("/")[0]!));
87
+ if (shot.firstFrame !== undefined || shot.lastFrame !== undefined) kinds.add("image");
88
+ if (kinds.size === 0) return "none";
80
89
  const model = catalog.snapshot.models.find(value => value.id === shot.model);
81
90
  const inputLimits = record(model?.capabilities?.input_limits);
82
- const images = record(inputLimits?.image);
83
- const sources = images?.supported_sources;
84
- if (sources === undefined || sources === null) return "inline";
85
- if (!Array.isArray(sources) || sources.some(value => typeof value !== "string")) unsupported("invalid image transport capabilities.");
86
- if (sources.includes("base64") || sources.includes("buffer")) return "inline";
87
- if (sources.includes("url")) return "url";
88
- return unsupported("the model has no supported image transport.");
91
+ let needsUrl = false;
92
+ for (const kind of kinds) {
93
+ const sources = record(inputLimits?.[kind])?.supported_sources;
94
+ if (sources === undefined || sources === null) continue;
95
+ if (!Array.isArray(sources) || sources.some(value => typeof value !== "string")) unsupported("invalid input transport capabilities.");
96
+ if (sources.includes("base64") || sources.includes("buffer")) continue;
97
+ if (sources.includes("url")) { needsUrl = true; continue; }
98
+ unsupported(`the model has no supported ${kind} input transport.`);
99
+ }
100
+ if (needsUrl) {
101
+ for (const kind of kinds) {
102
+ const sources = record(inputLimits?.[kind])?.supported_sources;
103
+ if (Array.isArray(sources) && !sources.includes("url")) unsupported(`the model cannot receive hosted ${kind} references.`);
104
+ }
105
+ }
106
+ return needsUrl ? "url" : "inline";
89
107
  }
@@ -56,6 +56,8 @@ function snapshot(state: DirectingState) {
56
56
  budget: { maximumMicroUsd: state.budgetMicroUsd, reservedMicroUsd, remainingMicroUsd: state.budgetMicroUsd - reservedMicroUsd, notice: BUDGET_NOTICE },
57
57
  shots: currentRecipe(state).shots.map(shot => ({
58
58
  id: shot.id, prompt: shot.prompt, model: shot.model,
59
+ conditioning: shot.references === undefined ? shot.firstFrame === undefined ? "none" : "frames" : "references",
60
+ referenceCount: shot.references?.length ?? 0,
59
61
  selectedAttempt: Object.hasOwn(state.selections, shot.id) ? state.selections[shot.id] : null,
60
62
  selectionStatus: Object.hasOwn(active, shot.id) ? "accepted" : Object.hasOwn(state.selections, shot.id) ? "stale" : "unselected",
61
63
  })),
@@ -189,11 +191,12 @@ async function executeDirectingCommandBody(
189
191
  const dependencies = resolveDirectingShot(state, command.shot).dependencies;
190
192
  for (const dependency of dependencies) await finishEndpoint(dependency.attemptId);
191
193
  const resolved = resolveDirectingShot(state, command.shot), shot = resolved.shot;
192
- if ((resolved.firstFrame !== undefined || resolved.lastFrame !== undefined) && !command.allowCloudUpload) throw new CliError("authorization-required", "This take uploads its exact retained image references. Supply --allow-cloud-upload for this request.");
194
+ const hasLocalInputs = resolved.firstFrame !== undefined || resolved.lastFrame !== undefined || (shot.references?.length ?? 0) > 0;
195
+ if (hasLocalInputs && !command.allowCloudUpload) throw new CliError("authorization-required", "This take uploads its exact retained media references. Supply --allow-cloud-upload for this request.");
193
196
  const catalog = await adapters.catalog.get({ forceRefresh: true, freshness: "require-fresh", signal });
194
197
  const quote = quoteDirectingShot(catalog, shot, application.clock.now());
195
198
  const needsHosting = directingInputTransport(catalog, shot) === "url";
196
- if (needsHosting && (!command.allowReferenceHosting || adapters.createReferenceHosting === undefined)) throw new CliError("authorization-required", "This model requires image URLs. Configure a private Vercel Blob store and supply --allow-reference-hosting for expiring access to these exact reference frames; --allow-cloud-upload is also required.");
199
+ if (needsHosting && (!command.allowReferenceHosting || adapters.createReferenceHosting === undefined)) throw new CliError("authorization-required", "This model requires media URLs. Configure a private Vercel Blob store and supply --allow-reference-hosting for expiring access to these exact retained references; --allow-cloud-upload is also required.");
197
200
  if (quote.costMicroUsd > state.budgetMicroUsd - totalDirectingReservedMicroUsd(state)) throw new CliError("authorization-required", "This take exceeds the remaining directing budget. No paid request was dispatched.");
198
201
  const request = await prepareGatewayOperation(application, {
199
202
  signal,
@@ -207,6 +210,7 @@ async function executeDirectingCommandBody(
207
210
  { frameType: "first_frame" as const, source: resolved.firstFrame },
208
211
  ...(resolved.lastFrame === undefined ? [] : [{ frameType: "last_frame" as const, source: resolved.lastFrame }]),
209
212
  ] }),
213
+ ...(resolved.references === undefined ? {} : { references: resolved.references.map(reference => ({ ...reference })) }),
210
214
  } },
211
215
  });
212
216
  const requestId = directingRequestId({ id: state.id, attemptId: command.attempt, recipeSha256: state.activeRecipeSha256, shotSha256: resolved.shotSha256, request });