@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.
- package/README.md +60 -26
- 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 +62 -1
- package/apps/desktop/cli/capability-manifest.ts +4 -1
- package/apps/desktop/cli/cinema-renderer.ts +9 -6
- package/apps/desktop/cli/command-host-resources.ts +7 -0
- package/apps/desktop/cli/commands.ts +555 -2
- package/apps/desktop/cli/credits.ts +818 -0
- 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 +51 -8
- 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 +337 -257
- package/apps/desktop/html-overlay/spatial-splat-runtime.ts +6 -0
- package/dist/cli.js +14 -4
- package/dist/index-11cb717y.js +28 -0
- package/dist/{index-296qhpf2.js → index-qtn3s421.js} +1 -1
- package/dist/{index-0bbesn4m.js → index-sy1n4zfy.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/operations.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/README.md +7 -3
- package/docs/studio.md +63 -3
- package/package.json +13 -7
- package/schema/drawing.schema.json +27 -0
- package/skills/slopcamera/SKILL.md +1 -0
- 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/patent-drawings.md +53 -0
- package/skills/slopcamera/references/workflows-sdk.md +1 -1
- package/src/cli.ts +90 -2
- package/src/drawing-pdf.ts +210 -0
- package/src/drawing.ts +293 -0
- package/src/index.ts +19 -0
- package/src/render.ts +6 -3
- package/src/version.ts +1 -1
- 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:
|
|
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 });
|
|
@@ -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
|
-
...(
|
|
2999
|
+
...(wireResolution === undefined
|
|
2993
3000
|
? {}
|
|
2994
|
-
: { 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),
|
package/apps/desktop/cli/help.ts
CHANGED
|
@@ -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 <
|
|
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
|
|
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
|
|
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 [
|
|
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"];
|
package/apps/desktop/cli/main.ts
CHANGED
|
@@ -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
|
-
|
|
1321
|
-
|
|
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
|
|
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}':
|
|
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}':
|
|
1386
|
-
`[${positioned}]format=rgba,split=2[${
|
|
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)) }),
|