@hraness/slopcamera 3.3.3 → 3.3.5

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 (46) hide show
  1. package/README.md +60 -26
  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 +62 -1
  6. package/apps/desktop/cli/capability-manifest.ts +4 -1
  7. package/apps/desktop/cli/cinema-renderer.ts +9 -6
  8. package/apps/desktop/cli/command-host-resources.ts +7 -0
  9. package/apps/desktop/cli/commands.ts +555 -2
  10. package/apps/desktop/cli/credits.ts +818 -0
  11. package/apps/desktop/cli/directing-blob.ts +17 -12
  12. package/apps/desktop/cli/directing-media.ts +14 -4
  13. package/apps/desktop/cli/directing-quote.ts +29 -11
  14. package/apps/desktop/cli/directing-service.ts +6 -2
  15. package/apps/desktop/cli/gateway-media-catalog.ts +57 -0
  16. package/apps/desktop/cli/gateway-media-service.ts +12 -2
  17. package/apps/desktop/cli/help.ts +51 -8
  18. package/apps/desktop/cli/main.ts +1 -1
  19. package/apps/desktop/cli/project-renderer.ts +23 -9
  20. package/apps/desktop/cli/spatial-scene-service.ts +1 -1
  21. package/apps/desktop/dist/cli/main.js +337 -257
  22. package/apps/desktop/html-overlay/spatial-splat-runtime.ts +6 -0
  23. package/dist/cli.js +14 -4
  24. package/dist/index-11cb717y.js +28 -0
  25. package/dist/{index-296qhpf2.js → index-qtn3s421.js} +1 -1
  26. package/dist/{index-0bbesn4m.js → index-sy1n4zfy.js} +2 -2
  27. package/dist/index.js +1 -1
  28. package/dist/operations.js +1 -1
  29. package/dist/workflow.js +1 -1
  30. package/docs/README.md +7 -3
  31. package/docs/studio.md +63 -3
  32. package/package.json +13 -7
  33. package/schema/drawing.schema.json +27 -0
  34. package/skills/slopcamera/SKILL.md +1 -0
  35. package/skills/slopcamera/references/directing-video.md +3 -3
  36. package/skills/slopcamera/references/educational-video.md +1 -1
  37. package/skills/slopcamera/references/install.md +1 -1
  38. package/skills/slopcamera/references/patent-drawings.md +53 -0
  39. package/skills/slopcamera/references/workflows-sdk.md +1 -1
  40. package/src/cli.ts +90 -2
  41. package/src/drawing-pdf.ts +210 -0
  42. package/src/drawing.ts +293 -0
  43. package/src/index.ts +19 -0
  44. package/src/render.ts +6 -3
  45. package/src/version.ts +1 -1
  46. package/dist/index-3e1pcxb5.js +0 -7
@@ -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 });
@@ -920,3 +920,60 @@ export function createFileGatewayMediaCatalogSnapshotStore(
920
920
  },
921
921
  };
922
922
  }
923
+
924
+ /**
925
+ * Alibaba Wan video models advertise normalized catalog resolutions such as
926
+ * "720p", but the provider API accepts only explicit pixel sizes bound to
927
+ * the requested aspect ratio ("1280x720"). This adapter translates the
928
+ * catalog value through Wan's documented size table so local qualification
929
+ * and duration pricing stay anchored to the catalog row while paid dispatch
930
+ * sends the provider representation. The `1280x720` (16:9) cell and the
931
+ * `480x832` enumeration hint are verified against the live provider; the
932
+ * remaining cells mirror Wan's published size enum.
933
+ */
934
+ const WAN_RESOLUTION_PIXEL_SIZES: Readonly<
935
+ Record<string, Readonly<Record<string, string>>>
936
+ > = {
937
+ "1080p": {
938
+ "16:9": "1920x1080",
939
+ "9:16": "1080x1920",
940
+ "1:1": "1440x1440",
941
+ "3:4": "1248x1632",
942
+ "4:3": "1632x1248",
943
+ },
944
+ "480p": {
945
+ "16:9": "832x480",
946
+ "9:16": "480x832",
947
+ "1:1": "624x624",
948
+ },
949
+ "720p": {
950
+ "16:9": "1280x720",
951
+ "9:16": "720x1280",
952
+ "1:1": "960x960",
953
+ "3:4": "832x1088",
954
+ "4:3": "1088x832",
955
+ },
956
+ };
957
+
958
+ /**
959
+ * Resolve the resolution value a provider expects on the wire.
960
+ * Returns `undefined` when the request carries no resolution, `null` when a
961
+ * Wan catalog resolution cannot be mapped for the requested aspect ratio,
962
+ * and the wire-ready value otherwise.
963
+ */
964
+ export function providerVideoResolution(
965
+ modelId: string,
966
+ resolution: string | undefined,
967
+ aspectRatio: string | undefined,
968
+ ): string | null | undefined {
969
+ if (
970
+ resolution === undefined
971
+ || !modelId.startsWith("alibaba/wan-")
972
+ || !/^[1-9]\d{2,4}p$/iu.test(resolution)
973
+ ) {
974
+ return resolution;
975
+ }
976
+ // Wan produces 16:9 landscape output when a request omits the aspect ratio.
977
+ return WAN_RESOLUTION_PIXEL_SIZES[resolution.toLocaleLowerCase("en-US")]
978
+ ?.[aspectRatio ?? "16:9"] ?? null;
979
+ }
@@ -16,6 +16,7 @@ import {
16
16
  } from "./gateway-media-artifacts";
17
17
  import {
18
18
  GATEWAY_MEDIA_CATALOG_URL,
19
+ providerVideoResolution,
19
20
  type GatewayJsonValue,
20
21
  type GatewayMediaCatalogView,
21
22
  type GatewayMediaKind,
@@ -2930,6 +2931,12 @@ export function createGatewayMediaService(options: Readonly<{
2930
2931
  execution.signal,
2931
2932
  );
2932
2933
  assertVideoCapabilities(catalog.model, request);
2934
+ const wireResolution = providerVideoResolution(
2935
+ catalog.model.id,
2936
+ request.resolution,
2937
+ request.aspectRatio,
2938
+ );
2939
+ if (wireResolution === null) unsupportedModelSetting();
2933
2940
  const dispatchAt = now();
2934
2941
  const credential = await options.loadCredential(dispatchAt);
2935
2942
  const timeout = combineAbortSignals(
@@ -2989,9 +2996,9 @@ export function createGatewayMediaService(options: Readonly<{
2989
2996
  ...(request.providerOptions === undefined
2990
2997
  ? {}
2991
2998
  : { providerOptions: request.providerOptions }),
2992
- ...(request.resolution === undefined
2999
+ ...(wireResolution === undefined
2993
3000
  ? {}
2994
- : { resolution: request.resolution }),
3001
+ : { resolution: wireResolution }),
2995
3002
  ...(request.seed === undefined ? {} : { seed: request.seed }),
2996
3003
  });
2997
3004
  return parseGeneratedFiles(sdkResult, "videos", ["video/"]);
@@ -3025,6 +3032,9 @@ export function createGatewayMediaService(options: Readonly<{
3025
3032
  generateAudio: optionalSummaryValue(request.generateAudio),
3026
3033
  maxVideosPerCall: optionalSummaryValue(request.maxVideosPerCall),
3027
3034
  n: optionalSummaryValue(request.n),
3035
+ providerResolution: optionalSummaryValue(
3036
+ wireResolution === request.resolution ? undefined : wireResolution,
3037
+ ),
3028
3038
  resolution: optionalSummaryValue(request.resolution),
3029
3039
  seed: optionalSummaryValue(request.seed),
3030
3040
  }), request.prompt, request.providerOptions),
@@ -11,6 +11,8 @@ Commands:
11
11
  capabilities Discover the exact static capability modules and qualification status
12
12
  operations list|show Discover host-owned typed operations and policies
13
13
  diagram init|check|render Create, validate, or render portable diagram sources
14
+ diagram sheets init|check|render
15
+ Create and render black-and-white drawing sheets
14
16
  scene init|check|inspect|diff|patch|evaluate|audit|render-audit|solve|review|camera-track|generate|plan|render|direction|effects|behavior|temporal-audit
15
17
  Author and inspect editable directed 3D scene sources
16
18
  scene design catalog|init|inspect|set|compile|gallery
@@ -29,6 +31,8 @@ Commands:
29
31
  doctor Check local render and asset capabilities
30
32
  ai models|image|video|speech|transcribe
31
33
  Discover and run Vercel AI Gateway media models
34
+ credits status|topup|wait|forget
35
+ Buy and inspect hosted generation credits
32
36
  media audio|color Apply local non-destructive audio and video effects
33
37
  menubar [--background] Run the prebuilt menu-bar companion
34
38
  menubar install|uninstall|status Manage its per-user LaunchAgent
@@ -91,7 +95,7 @@ For local SDK workflows, code run, workflows run and runs resume accept
91
95
  The fixed local operation is slopcamera.studio.run; its SDK surface is studio.run().`,
92
96
  direct: `Usage:
93
97
  slopcamera direct init <recipe.json> [--json]
94
- slopcamera direct anchor --input <image> [--json]
98
+ slopcamera direct anchor --input <media> [--json]
95
99
  slopcamera direct plan <recipe.json> [--json]
96
100
  slopcamera direct start <recipe.json> --budget-usd <USD> [--json]
97
101
  slopcamera direct inspect <direct-id> [--json]
@@ -105,13 +109,13 @@ The fixed local operation is slopcamera.studio.run; its SDK surface is studio.ru
105
109
  slopcamera direct assemble <direct-id> [--json]
106
110
 
107
111
  Init writes a new recipe without overwriting. Edit its ID, shots, prompts and settings.
108
- Anchor imports one explicit local image and returns a hash-bound source reference.
112
+ Anchor imports one explicit local image or video and returns a hash-bound source reference.
109
113
  Plan checks live Gateway video capabilities and duration pricing without a paid call.
110
114
  Start retains one budget for the direct ID; starting again cannot reset it.
111
- Generate dispatches at most one new take and requires a unique take_<id>. Local images,
115
+ Generate dispatches at most one new take and requires a unique take_<id>. Local media,
112
116
  including a predecessor's endpoint, require --allow-cloud-upload on that invocation.
113
117
  URL-only models require --allow-reference-hosting and a private Vercel Blob store.
114
- Only exact reference images receive short-lived signed read URLs. Signed URLs and
118
+ Only exact shot references receive short-lived signed read URLs. Signed URLs and
115
119
  credentials are not saved. Cleanup deletes those exact objects; inspect cleanup
116
120
  receipts and use direct cleanup after interruptions. Blob charges are separate
117
121
  from the model catalog estimate and require room in your overall spending limit.
@@ -250,11 +254,19 @@ reconciliation and never automatically overwrites a later revision.`,
250
254
  slopcamera diagram check <diagram.json> [--config <file>] [--strict]
251
255
  slopcamera diagram render <diagram.json> [--out-dir <directory>]
252
256
  [--config <file>] [--scale <number>]
257
+ slopcamera diagram sheets init <file.drawing.json> [--json]
258
+ slopcamera diagram sheets check <file.drawing.json> [--json]
259
+ slopcamera diagram sheets render <file.drawing.json> [--out-dir <directory>] [--json]
253
260
 
254
261
  These commands delegate to the canonical @hraness/slopcamera parser. Init never overwrites.
255
262
  Check parses and lints without writing; --strict exits 2 on findings. Render replaces the same five
256
263
  portable derivatives: editable .tldr plus light/dark SVG and PNG. The registered Slopcamera diagram
257
- operations separately publish equivalent derivatives by content hash for workflow composition.`,
264
+ operations separately publish equivalent derivatives by content hash for workflow composition.
265
+
266
+ Sheets use a separate .drawing.json format with explicit US Letter or A4 page geometry, black
267
+ line art and outlined uppercase labels. Init never overwrites; check is read-only; render writes
268
+ one vector PDF, one SVG per sheet and a drawing receipt. Geometry checks do not certify legal
269
+ compliance. Drawing sheets are exposed through this CLI and the root SDK, not code/MCP operations.`,
258
270
  image: `Usage:
259
271
  slopcamera image vectorize <raster-path> --output <file.svg> [--json]
260
272
  [--duotone '<#primary,#secondary>'] [--alpha-cutoff <n>] [--timeout-ms <n>]
@@ -368,7 +380,7 @@ run journal.`,
368
380
  [--size <width>x<height>] [--aspect-ratio <width>:<height>] [--seed <n>]
369
381
  [--max-output-tokens <n>] [--temperature <n>] [--stop <text> ...]
370
382
  [--provider-options <json-file>] [--timeout <time>]
371
- [--allow-cloud-upload] [--json]
383
+ [--allow-cloud-upload] [--hosted] [--json]
372
384
  slopcamera ai image gallery <subject> --model <id> --output-dir <directory>
373
385
  [--kind <image|texture|skybox|backdrop|sprite>] [--count <n>]
374
386
  [--vary <axis[=v1,v2][;axis...]>] [--candidates <json-file>]
@@ -396,6 +408,15 @@ Set AI_GATEWAY_API_KEY in the process environment, or run through a linked Verce
396
408
  \`vercel env run -- slopcamera …\` so VERCEL_OIDC_TOKEN is injected. Slopcamera never persists,
397
409
  prints, or accepts either credential through argv.
398
410
 
411
+ Alternatively, prepaid hosted generation bills Hraness Credits instead of your own Gateway
412
+ credential: run \`slopcamera credits topup\` to buy a pack, \`slopcamera credits wait\` to install
413
+ the device token, then \`slopcamera ai image generate --hosted --model <id> --prompt <text>\`.
414
+ Hosted mode admits one prompt-only image per call: --image, --mask, --count, --size,
415
+ --aspect-ratio, --seed, --temperature, --stop, --max-output-tokens, --provider-options and
416
+ --allow-cloud-upload are rejected because the hosted operation currently exposes no input media
417
+ or provider knobs. When no Gateway credential is configured but a credits device token exists,
418
+ ai image generate routes to the hosted service automatically.
419
+
399
420
  The media-model catalog is fetched live from Vercel AI Gateway and cached with a deterministic
400
421
  revision. Every image, video, speech, and batch-transcription model of the matching operation is
401
422
  executable; streaming-only transcription models remain discoverable and are labeled as such.
@@ -532,6 +553,25 @@ All mutations support --json and return the resulting plan hash.`,
532
553
  Options: --kind <kind[,kind]> (repeatable) --from <time> --to <time>
533
554
  --around <time> --limit <1..10000> --json | --jsonl`,
534
555
  inspect: `Usage: slopcamera inspect <recording> [--fields <csv>] [--json]`,
556
+ credits: `Usage:
557
+ slopcamera credits status [--json]
558
+ slopcamera credits topup [--pack <id>] [--email <address>] [--json]
559
+ slopcamera credits wait [--timeout <time>] [--json]
560
+ slopcamera credits forget [--json]
561
+
562
+ Prepaid credits power hosted Slopcamera generation at api.slopcamera.com — an alternative to
563
+ running your own Vercel AI Gateway credential. \`topup\` opens a hosted checkout page and prints
564
+ its URL; the page renders the current pack list and prices from the live rate card. \`wait\`
565
+ polls the pending claim until payment lands, then stores the returned device token with 0600
566
+ permissions below the CLI state root. \`status\` reads the wallet balance and held amount.
567
+ \`forget\` removes the locally stored token and pending claim; the wallet and its balance are
568
+ unaffected, but a removed token cannot be re-read, so a new top-up issues a fresh token.
569
+
570
+ Set SLOPCAMERA_CREDITS_TOKEN to supply the device token from the environment instead of local
571
+ state — useful for agents in ephemeral environments. Tokens and claim secrets never appear in
572
+ argv, receipts, or logs. When a configured wallet runs out, paid calls fail with the top-up URL;
573
+ \`credits topup\` on a configured wallet reuses its bound claim so one wallet keeps one identity.`,
574
+
535
575
  media: `Usage:
536
576
  slopcamera media audio <media-path> [effects] [--audio-stream <index>]
537
577
  [--output <relative-path>] [--json]
@@ -633,7 +673,7 @@ export function commandHelp(topic: readonly string[]): string {
633
673
 
634
674
  export function completions(words: readonly string[]): readonly string[] {
635
675
  const topLevel = [
636
- "capabilities", "operations", "diagram", "direct", "studio", "image", "html", "workflows", "code", "runs", "doctor", "ai", "media", "menubar", "support", "outputs", "recordings", "projects", "project", "scene", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
676
+ "capabilities", "operations", "diagram", "direct", "studio", "image", "html", "workflows", "code", "runs", "doctor", "ai", "credits", "media", "menubar", "support", "outputs", "recordings", "projects", "project", "scene", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
637
677
  ];
638
678
  if (words.length <= 1) return topLevel;
639
679
  const command = words[0];
@@ -642,7 +682,9 @@ export function completions(words: readonly string[]): readonly string[] {
642
682
  if (command === "direct") return ["init", "anchor", "plan", "start", "inspect", "revise", "generate", "resume", "review", "assemble", "cleanup"];
643
683
  if (command === "studio") return words[2] === "assets" || words[1] === "assets" ? ["search", "describe", "plan", "import"] : ["init", "bundle", "plan", "probe", "run", "encode", "asset", "assemble", "inspect", "reconcile", "assets"];
644
684
  if (command === "operations") return ["list", "show"];
645
- if (command === "diagram") return ["check", "render"];
685
+ if (command === "diagram") return words[1] === "sheets"
686
+ ? ["init", "check", "render"]
687
+ : ["init", "check", "render", "sheets"];
646
688
  if (command === "image") return ["vectorize", "generate", "gallery", "icon"];
647
689
  if (command === "workflows") return ["list", "show", "plan", "run"];
648
690
  if (command === "code") return ["init", "check", "plan", "run"];
@@ -664,6 +706,7 @@ export function completions(words: readonly string[]): readonly string[] {
664
706
  if (command === "align") return ["analyze", "apply"];
665
707
  if (command === "fillers") return ["list", "apply"];
666
708
  if (command === "ai") return ["models", "provider-options", "image", "video", "speech", "transcribe"];
709
+ if (command === "credits") return ["status", "topup", "wait", "forget"];
667
710
  if (command === "media") return ["audio", "color"];
668
711
  if (command === "render") return ["plan", "run"];
669
712
  if (command === "assets") return ["emoji"];
@@ -55,7 +55,7 @@ export async function main(
55
55
  }
56
56
  const paths = await resolveRepositoryPaths(processIo.cwd(), processIo.env);
57
57
  if (earlyCommand.kind === "html-render" || earlyCommand.kind === "spatial-world" || earlyCommand.kind === "spatial-asset" || earlyCommand.kind === "spatial-design" || earlyCommand.kind === "directing" || earlyCommand.kind === "studio"
58
- || earlyCommand.kind === "spatial-scene" && (earlyCommand.action === "camera-track" || earlyCommand.action === "render-audit" || earlyCommand.action === "review")) {
58
+ || earlyCommand.kind === "spatial-scene" && (earlyCommand.action === "render" || earlyCommand.action === "camera-track" || earlyCommand.action === "render-audit" || earlyCommand.action === "review")) {
59
59
  const controller = new AbortController();
60
60
  const cancel = () => controller.abort(new CliError("cancelled", "Command interrupted."));
61
61
  process.on("SIGINT", cancel);
@@ -959,6 +959,7 @@ function overlayVideoChain(
959
959
  } else {
960
960
  filters.push(
961
961
  `trim=duration=${seconds(sliceDurationUs)}`,
962
+ "settb=AVTB",
962
963
  `setpts=PTS-STARTPTS+${seconds(resolved.outputRange.startUs)}/TB`,
963
964
  );
964
965
  }
@@ -1246,7 +1247,7 @@ export async function buildProjectFfmpegInvocation(
1246
1247
  if (overlayInputIndex.has(overlay.overlayId)) continue;
1247
1248
  overlayInputIndex.set(overlay.overlayId, mediaInputIndex.size + overlayInputIndex.size);
1248
1249
  if (overlay.source.kind === "image" || overlay.source.kind === "svg" || overlay.source.kind === "emoji") {
1249
- inputArguments.push("-loop", "1");
1250
+ inputArguments.push("-loop", "1", "-framerate", emittedRate);
1250
1251
  }
1251
1252
  const prepared = await prepareProjectOverlayMedia(overlay, options);
1252
1253
  preparedOverlayMedia.set(overlay.overlayId, prepared);
@@ -1309,6 +1310,7 @@ export async function buildProjectFfmpegInvocation(
1309
1310
  } else {
1310
1311
  const transparent = `video_blend_canvas_${serial++}`;
1311
1312
  const positioned = `video_blend_positioned_${serial++}`;
1313
+ const layerColorSource = `video_blend_color_source_${serial++}`;
1312
1314
  const layerColor = `video_blend_color_${serial++}`;
1313
1315
  const layerAlphaSource = `video_blend_alpha_source_${serial++}`;
1314
1316
  const layerMask = `video_blend_mask_${serial++}`;
@@ -1317,10 +1319,13 @@ export async function buildProjectFfmpegInvocation(
1317
1319
  const blended = `video_blend_result_${serial++}`;
1318
1320
  filters.push(
1319
1321
  `color=c=black@0:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:r=${emittedRate}:d=${seconds(plan.output.durationUs)},format=rgba[${transparent}]`,
1320
- `[${transparent}][${label}]overlay=x=${transform.x}:y=${transform.y}:eof_action=repeat:repeatlast=1:enable='${enable}'[${positioned}]`,
1321
- `[${positioned}]format=rgba,split=2[${layerColor}][${layerAlphaSource}]`,
1322
+ // Preserve the positioned layer's alpha and blend color channels in RGB.
1323
+ // YUV blend arithmetic changes even uncovered pixels (neutral chroma is 128).
1324
+ `[${transparent}][${label}]overlay=x=${transform.x}:y=${transform.y}:eof_action=repeat:repeatlast=1:enable='${enable}':format=auto[${positioned}]`,
1325
+ `[${positioned}]format=rgba,split=2[${layerColorSource}][${layerAlphaSource}]`,
1326
+ `[${layerColorSource}]format=gbrp[${layerColor}]`,
1322
1327
  `[${layerAlphaSource}]alphaextract[${layerMask}]`,
1323
- `[${currentVideo}]split=2[${baseBlend}][${baseMerge}]`,
1328
+ `[${currentVideo}]format=gbrp,split=2[${baseBlend}][${baseMerge}]`,
1324
1329
  `[${baseBlend}][${layerColor}]blend=all_mode=${slice.presentation.blendMode}[${blended}]`,
1325
1330
  `[${baseMerge}][${blended}][${layerMask}]maskedmerge[${next}]`,
1326
1331
  );
@@ -1365,15 +1370,23 @@ export async function buildProjectFfmpegInvocation(
1365
1370
  const x = overlayPositionExpression(overlay, "x", bounds.startUs, visibleFullEndUs, animations.entranceUs, animations.exitUs);
1366
1371
  const y = overlayPositionExpression(overlay, "y", bounds.startUs, visibleFullEndUs, animations.entranceUs, animations.exitUs);
1367
1372
  const next = `canvas_${serial++}`;
1368
- const enable = `between(t,${seconds(resolvedOverlay.outputRange.startUs)},${seconds(chain.visibleEndUs)})`;
1373
+ const staticSource = source.kind === "image" || source.kind === "svg" || source.kind === "emoji";
1374
+ // A still's last decoded PTS can precede the final output frame, especially
1375
+ // after cuts or speed edits. Hold it only inside its half-open interval;
1376
+ // animated sources retain their explicit hide/freeze/loop behavior.
1377
+ const enable = staticSource
1378
+ ? `gte(t,${seconds(resolvedOverlay.outputRange.startUs)})*lt(t,${seconds(chain.visibleEndUs)})`
1379
+ : `between(t,${seconds(resolvedOverlay.outputRange.startUs)},${seconds(chain.visibleEndUs)})`;
1380
+ const terminal = staticSource ? "eof_action=repeat:repeatlast=1" : "eof_action=pass:repeatlast=0";
1369
1381
  const blendMode = overlay.blendMode ?? "normal";
1370
1382
  if (blendMode === "normal") {
1371
1383
  filters.push(
1372
- `[${currentVideo}][${label}]overlay=x='${x}':y='${y}':eof_action=pass:repeatlast=0:enable='${enable}'[${next}]`,
1384
+ `[${currentVideo}][${label}]overlay=x='${x}':y='${y}':${terminal}:enable='${enable}'[${next}]`,
1373
1385
  );
1374
1386
  } else {
1375
1387
  const transparent = `graphic_blend_canvas_${serial++}`;
1376
1388
  const positioned = `graphic_blend_positioned_${serial++}`;
1389
+ const layerColorSource = `graphic_blend_color_source_${serial++}`;
1377
1390
  const layerColor = `graphic_blend_color_${serial++}`;
1378
1391
  const layerAlphaSource = `graphic_blend_alpha_source_${serial++}`;
1379
1392
  const layerMask = `graphic_blend_mask_${serial++}`;
@@ -1382,10 +1395,11 @@ export async function buildProjectFfmpegInvocation(
1382
1395
  const blended = `graphic_blend_result_${serial++}`;
1383
1396
  filters.push(
1384
1397
  `color=c=black@0:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:r=${emittedRate}:d=${seconds(plan.output.durationUs)},format=rgba[${transparent}]`,
1385
- `[${transparent}][${label}]overlay=x='${x}':y='${y}':eof_action=pass:repeatlast=0:enable='${enable}'[${positioned}]`,
1386
- `[${positioned}]format=rgba,split=2[${layerColor}][${layerAlphaSource}]`,
1398
+ `[${transparent}][${label}]overlay=x='${x}':y='${y}':${terminal}:enable='${enable}':format=auto[${positioned}]`,
1399
+ `[${positioned}]format=rgba,split=2[${layerColorSource}][${layerAlphaSource}]`,
1400
+ `[${layerColorSource}]format=gbrp[${layerColor}]`,
1387
1401
  `[${layerAlphaSource}]alphaextract[${layerMask}]`,
1388
- `[${currentVideo}]split=2[${baseBlend}][${baseMerge}]`,
1402
+ `[${currentVideo}]format=gbrp,split=2[${baseBlend}][${baseMerge}]`,
1389
1403
  `[${baseBlend}][${layerColor}]blend=all_mode=${blendMode}[${blended}]`,
1390
1404
  `[${baseMerge}][${blended}][${layerMask}]maskedmerge[${next}]`,
1391
1405
  );
@@ -337,7 +337,7 @@ export async function executeSpatialSceneCommand(application: ApplicationContext
337
337
  if (command.action === "plan" || command.action === "render") {
338
338
  const request = bindSpatialCliExecutionProfile(await readSpatialJson(resolve(application.paths.repositoryRoot, command.request)), command.executionProfile);
339
339
  if (command.action === "plan") return planSpatialRender(scene, request);
340
- const result = await createApplicationOperationRegistry().execute({ application, abortSignal: new AbortController().signal }, {
340
+ const result = await createApplicationOperationRegistry().execute({ application, abortSignal: signal ?? new AbortController().signal }, {
341
341
  kind: "scene.render", version: 1, input: {
342
342
  source: { path: relative(application.paths.repositoryRoot, sourcePath) }, request,
343
343
  ...(command.assets === undefined ? {} : { assets: await readSpatialJson(resolve(application.paths.repositoryRoot, command.assets)) }),