@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.
- package/README.md +49 -25
- package/apps/desktop/application/directing-contract.ts +15 -3
- package/apps/desktop/application/directing-plan.ts +2 -1
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/args.ts +1 -1
- package/apps/desktop/cli/cinema-renderer.ts +9 -6
- package/apps/desktop/cli/commands.ts +8 -1
- package/apps/desktop/cli/directing-blob.ts +17 -12
- package/apps/desktop/cli/directing-media.ts +14 -4
- package/apps/desktop/cli/directing-quote.ts +29 -11
- package/apps/desktop/cli/directing-service.ts +6 -2
- package/apps/desktop/cli/gateway-media-catalog.ts +57 -0
- package/apps/desktop/cli/gateway-media-service.ts +12 -2
- package/apps/desktop/cli/help.ts +4 -4
- package/apps/desktop/cli/main.ts +1 -1
- package/apps/desktop/cli/project-renderer.ts +23 -9
- package/apps/desktop/cli/spatial-scene-service.ts +1 -1
- package/apps/desktop/dist/cli/main.js +254 -248
- package/apps/desktop/html-overlay/spatial-splat-runtime.ts +6 -0
- package/dist/cli.js +1 -1
- package/dist/code/index.js +4 -4
- package/dist/{index-gs48afpt.js → index-c8jpst4g.js} +2 -2
- package/dist/index-z1kd1x8j.js +3 -0
- package/dist/index.js +1 -1
- package/docs/README.md +6 -3
- package/docs/studio.md +63 -3
- package/package.json +13 -7
- package/skills/slopcamera/references/directing-video.md +3 -3
- package/skills/slopcamera/references/educational-video.md +1 -1
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/parametric-design.md +5 -1
- package/skills/slopcamera/references/scene-building.md +2 -0
- package/skills/slopcamera/references/workflows-sdk.md +1 -1
- package/src/spatial-scene/geometry.ts +88 -35
- package/src/spatial-scene/parametric.ts +42 -15
- package/src/version.ts +1 -1
- 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
|
|
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
|
|
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
|
+
[](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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
119
|
-
see the result. For a moving 3D subject, use [Directed scenes](docs/spatial-scenes.md).
|
|
120
|
-
For detailed native 3D
|
|
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
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|
package/apps/desktop/cli/args.ts
CHANGED
|
@@ -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 <
|
|
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
|
-
//
|
|
149
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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 =>
|
|
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}\/[
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
226
|
-
const extension = source.mediaType
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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 });
|