@hraness/slopcamera 3.2.8 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -9
- package/apps/desktop/application/context.ts +6 -0
- package/apps/desktop/application/default-registry.ts +26 -0
- package/apps/desktop/application/operation.ts +14 -1
- package/apps/desktop/application/operations/index.ts +4 -0
- package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
- package/apps/desktop/application/operations/spatial-direction.ts +222 -0
- package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
- package/apps/desktop/application/operations/spatial-review.ts +194 -0
- package/apps/desktop/application/operations/spatial-scene.ts +25 -1
- package/apps/desktop/application/registry.ts +2 -2
- package/apps/desktop/application/spatial-asset-admission.ts +201 -0
- package/apps/desktop/application/spatial-assets.ts +160 -31
- package/apps/desktop/application/spatial-render.ts +217 -20
- package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
- package/apps/desktop/application/spatial-review.ts +361 -0
- package/apps/desktop/application/spatial-spz.ts +48 -3
- package/apps/desktop/application/spatial-world-import.ts +26 -5
- package/apps/desktop/application/spatial-world-metadata.ts +105 -0
- package/apps/desktop/cli/args.ts +514 -11
- package/apps/desktop/cli/capability-manifest.ts +374 -0
- package/apps/desktop/cli/cinema-renderer.ts +608 -0
- package/apps/desktop/cli/command-host-resources.ts +29 -1
- package/apps/desktop/cli/commands.ts +1188 -4
- package/apps/desktop/cli/gateway-review-provider.ts +334 -0
- package/apps/desktop/cli/help.ts +172 -6
- package/apps/desktop/cli/main.ts +3 -3
- package/apps/desktop/cli/portable-surface.ts +6 -1
- package/apps/desktop/cli/spatial-asset-service.ts +124 -0
- package/apps/desktop/cli/spatial-design-service.ts +237 -0
- package/apps/desktop/cli/spatial-generate-service.ts +370 -0
- package/apps/desktop/cli/spatial-scene-service.ts +268 -4
- package/apps/desktop/cli/workflow-code.ts +1 -1
- package/apps/desktop/code/semantic-builder.ts +24 -0
- package/apps/desktop/contracts/cinema.ts +736 -0
- package/apps/desktop/contracts/index.ts +1 -0
- package/apps/desktop/contracts/spatial-asset.ts +16 -0
- package/apps/desktop/contracts/spatial-world.ts +26 -3
- package/apps/desktop/core/cinema-plan.ts +1320 -0
- package/apps/desktop/core/index.ts +1 -0
- package/apps/desktop/core/storage.ts +75 -0
- package/apps/desktop/dist/cli/main.js +725 -273
- package/apps/desktop/html-overlay/spatial.ts +619 -57
- package/apps/desktop/workflows/cinematic-world.ts +127 -0
- package/apps/desktop/workflows/index.ts +6 -0
- package/dist/cli.js +25 -429
- package/dist/code/advanced.js +1 -269
- package/dist/code/index.js +16 -2239
- package/dist/generate.js +1 -23
- package/dist/host-resources.js +1 -25
- package/dist/index-0bbesn4m.js +35 -0
- package/dist/index-296qhpf2.js +3 -0
- package/dist/index-3cxf2c58.js +3 -0
- package/dist/index-77fjfg9f.js +3 -0
- package/dist/index-80a36bc0.js +3 -0
- package/dist/index-a3vvs3rf.js +3 -0
- package/dist/index-b3geqczn.js +5 -0
- package/dist/index-cgpkyjqa.js +3 -0
- package/dist/index-f0ptv8dm.js +7 -0
- package/dist/index-grk47tgs.js +3 -0
- package/dist/index-qry58nj2.js +10 -0
- package/dist/index.js +1 -226
- package/dist/operations.js +1 -28
- package/dist/skill-install-gx7afxsd.js +2 -0
- package/dist/vectorize/worker.js +1 -141
- package/dist/workflow.js +1 -15
- package/docs/README.md +5 -1
- package/package.json +7 -5
- package/skills/slopcamera/SKILL.md +6 -1
- package/skills/slopcamera/references/directed-scenes.md +22 -2
- package/skills/slopcamera/references/gateway-media.md +15 -1
- package/skills/slopcamera/references/image-galleries.md +166 -0
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/parametric-design.md +14 -0
- package/skills/slopcamera/references/scene-building.md +32 -0
- package/skills/slopcamera/references/social-collage-banners.md +204 -0
- package/skills/slopcamera/references/workflows-sdk.md +3 -3
- package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
- package/src/capability-manifest.ts +361 -0
- package/src/cli.ts +222 -4
- package/src/code/canonical-json.ts +9 -0
- package/src/generate.ts +2 -2
- package/src/icon.ts +1111 -0
- package/src/image-gallery.ts +1049 -0
- package/src/index.ts +11 -0
- package/src/mcp/boundary.ts +11 -11
- package/src/mcp/index.ts +3 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1642 -130
- package/src/mcp/types.ts +19 -0
- package/src/operations.ts +412 -0
- package/src/portable-capability-manifest.ts +67 -0
- package/src/scene-gallery.ts +184 -0
- package/src/spatial-scene/asset-admission.ts +183 -0
- package/src/spatial-scene/audit-rendered.ts +692 -0
- package/src/spatial-scene/audit.ts +617 -0
- package/src/spatial-scene/behavior-audit.ts +252 -0
- package/src/spatial-scene/behavior-bake.ts +233 -0
- package/src/spatial-scene/behavior-fns.ts +585 -0
- package/src/spatial-scene/behavior-gallery.ts +109 -0
- package/src/spatial-scene/behavior-stdlib.ts +323 -0
- package/src/spatial-scene/behavior-trace.ts +325 -0
- package/src/spatial-scene/behavior.ts +618 -0
- package/src/spatial-scene/build.ts +632 -0
- package/src/spatial-scene/camera-rig.ts +125 -0
- package/src/spatial-scene/camera-track.ts +4 -2
- package/src/spatial-scene/character.ts +124 -0
- package/src/spatial-scene/contracts.ts +75 -7
- package/src/spatial-scene/design-templates.ts +351 -0
- package/src/spatial-scene/design.ts +327 -0
- package/src/spatial-scene/direction-compile.ts +584 -0
- package/src/spatial-scene/direction.ts +122 -0
- package/src/spatial-scene/effects.ts +149 -0
- package/src/spatial-scene/evaluate.ts +109 -29
- package/src/spatial-scene/gallery.ts +277 -0
- package/src/spatial-scene/generate.ts +239 -0
- package/src/spatial-scene/geometry-native.ts +165 -0
- package/src/spatial-scene/geometry.ts +1216 -0
- package/src/spatial-scene/gltf.ts +600 -77
- package/src/spatial-scene/identity.ts +55 -2
- package/src/spatial-scene/index.ts +36 -0
- package/src/spatial-scene/inspect.ts +35 -6
- package/src/spatial-scene/material-lighting.ts +609 -0
- package/src/spatial-scene/math.ts +21 -4
- package/src/spatial-scene/motion-evidence.ts +88 -0
- package/src/spatial-scene/parametric.ts +765 -0
- package/src/spatial-scene/particle-preparation.ts +183 -0
- package/src/spatial-scene/particle.ts +195 -0
- package/src/spatial-scene/patch.ts +109 -15
- package/src/spatial-scene/performance.ts +1613 -0
- package/src/spatial-scene/probe.ts +137 -0
- package/src/spatial-scene/recipe-pack.ts +86 -0
- package/src/spatial-scene/render-effects.ts +240 -0
- package/src/spatial-scene/review.ts +639 -0
- package/src/spatial-scene/simulation-bake.ts +206 -0
- package/src/spatial-scene/simulation.ts +187 -0
- package/src/spatial-scene/solve.ts +366 -0
- package/src/spatial-scene/temporal-audit.ts +354 -0
- package/src/version.ts +1 -1
- package/dist/index-42zsesc1.js +0 -2257
- package/dist/index-7131pg1c.js +0 -1820
- package/dist/index-7308egqr.js +0 -48
- package/dist/index-8txs6fkn.js +0 -712
- package/dist/index-fava6pge.js +0 -192
- package/dist/index-h1k0fnjq.js +0 -2363
- package/dist/index-r7gdhmsp.js +0 -424
- package/dist/index-sh6xbav6.js +0 -1219
- package/dist/index-z1w83f81.js +0 -4
- package/dist/index-zfnddgay.js +0 -2029
- package/dist/skill-install-dh0ntqba.js +0 -10
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CinemaRenderInvocationSchema,
|
|
6
|
+
resolveProjectRenderEncoderRecipe,
|
|
7
|
+
type CinemaMediaSlice,
|
|
8
|
+
type CinemaRenderInvocation,
|
|
9
|
+
type CinemaRenderPlanV1,
|
|
10
|
+
type CinemaResolvedShot,
|
|
11
|
+
type CinemaResolvedTransition,
|
|
12
|
+
type ProjectRenderEncoderRecipe,
|
|
13
|
+
type ProjectRenderTier,
|
|
14
|
+
} from "../contracts";
|
|
15
|
+
import { CliError } from "./errors";
|
|
16
|
+
import { materializeFilterScript } from "./filter-script";
|
|
17
|
+
import { buildColorGradeFilter } from "./media-effects-service";
|
|
18
|
+
import {
|
|
19
|
+
resolveVerifiedProjectMedia,
|
|
20
|
+
verifyPhysicalProjectMedia,
|
|
21
|
+
type ExpectedProjectMediaIntegrity,
|
|
22
|
+
} from "./project-media-integrity";
|
|
23
|
+
|
|
24
|
+
function seconds(microseconds: number): string {
|
|
25
|
+
return (microseconds / 1_000_000).toFixed(6).replace(/0+$/u, "").replace(/\.$/u, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function decimal(value: number): string {
|
|
29
|
+
return Number.isInteger(value)
|
|
30
|
+
? String(value)
|
|
31
|
+
: value.toFixed(10).replace(/0+$/u, "").replace(/\.$/u, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
35
|
+
const pathFromRoot = relative(root, candidate);
|
|
36
|
+
return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function inputSpecifier(index: number, streamIndex: number): string {
|
|
40
|
+
return `${index}:${streamIndex}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function atempo(rate: number): string {
|
|
44
|
+
if (!Number.isFinite(rate) || rate <= 0) {
|
|
45
|
+
throw new CliError("unsupported-plan", "Audio tempo rate must be positive.");
|
|
46
|
+
}
|
|
47
|
+
const filters: string[] = [];
|
|
48
|
+
let remaining = rate;
|
|
49
|
+
while (remaining > 100) {
|
|
50
|
+
filters.push("atempo=100");
|
|
51
|
+
remaining /= 100;
|
|
52
|
+
}
|
|
53
|
+
while (remaining < 0.5) {
|
|
54
|
+
filters.push("atempo=0.5");
|
|
55
|
+
remaining /= 0.5;
|
|
56
|
+
}
|
|
57
|
+
filters.push(`atempo=${decimal(remaining)}`);
|
|
58
|
+
return filters.join(",");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function delayToOutput(startUs: number): string[] {
|
|
62
|
+
// amix consumes each input from its first sample; shifting timestamps alone
|
|
63
|
+
// does not place that input in the program. Insert silence at the output rate.
|
|
64
|
+
const samples = (BigInt(startUs) * 48_000n + 500_000n) / 1_000_000n;
|
|
65
|
+
return ["asetpts=PTS-STARTPTS", ...(samples === 0n ? [] : [`adelay=${samples}S:all=1`])];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const WIPE_XFADE = Object.freeze({
|
|
69
|
+
down: "wipedown",
|
|
70
|
+
left: "wipeleft",
|
|
71
|
+
right: "wiperight",
|
|
72
|
+
up: "wipeup",
|
|
73
|
+
} as const);
|
|
74
|
+
|
|
75
|
+
const WHIP_PAN_XFADE = Object.freeze({
|
|
76
|
+
down: "slidedown",
|
|
77
|
+
left: "slideleft",
|
|
78
|
+
right: "slideright",
|
|
79
|
+
up: "slideup",
|
|
80
|
+
} as const);
|
|
81
|
+
|
|
82
|
+
export interface CinemaRenderBuildOptions {
|
|
83
|
+
readonly dryRun?: boolean;
|
|
84
|
+
readonly ffmpeg: string;
|
|
85
|
+
readonly outputPath: string;
|
|
86
|
+
readonly projectDirectory: string;
|
|
87
|
+
readonly repositoryRoot: string;
|
|
88
|
+
/** Encoder recipe tier; animatics use "preview". */
|
|
89
|
+
readonly tier?: ProjectRenderTier;
|
|
90
|
+
/** Host-owned private root for materialized filter graphs. */
|
|
91
|
+
readonly workspaceDirectory?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CinemaRenderPinnedInput extends ExpectedProjectMediaIntegrity {
|
|
95
|
+
readonly label: string;
|
|
96
|
+
readonly path: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface BuiltCinemaRenderInvocation {
|
|
100
|
+
readonly argv: readonly [string, ...string[]];
|
|
101
|
+
readonly invocation: CinemaRenderInvocation;
|
|
102
|
+
readonly pinnedInputs: readonly CinemaRenderPinnedInput[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function reverifyCinemaRenderInputs(
|
|
106
|
+
inputs: readonly CinemaRenderPinnedInput[],
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
await Promise.all(inputs.map(async input => {
|
|
109
|
+
await verifyPhysicalProjectMedia(input.path, input, input.label);
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cinemaEncoderArguments(recipe: ProjectRenderEncoderRecipe): readonly string[] {
|
|
114
|
+
return [
|
|
115
|
+
"-threads:v", String(recipe.video.threads),
|
|
116
|
+
"-preset", recipe.video.preset,
|
|
117
|
+
"-crf", String(recipe.video.crf),
|
|
118
|
+
"-b:a", recipe.audio.bitrate,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cinemaThreadArguments(recipe: ProjectRenderEncoderRecipe): readonly string[] {
|
|
123
|
+
return [
|
|
124
|
+
"-filter_threads", String(recipe.filterThreads),
|
|
125
|
+
"-filter_complex_threads", String(recipe.filterComplexThreads),
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface GraphContext {
|
|
130
|
+
readonly filters: string[];
|
|
131
|
+
readonly inputIndex: ReadonlyMap<string, number>;
|
|
132
|
+
readonly rate: string;
|
|
133
|
+
serial: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function label(context: GraphContext, prefix: string): string {
|
|
137
|
+
return `${prefix}_${context.serial++}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sliceChain(slice: CinemaMediaSlice, inputIndex: ReadonlyMap<string, number>): string {
|
|
141
|
+
const input = inputIndex.get(slice.path)!;
|
|
142
|
+
const inputDurationUs = slice.fileRange.endUs - slice.fileRange.startUs;
|
|
143
|
+
const outputDurationUs = slice.outputRange.endUs - slice.outputRange.startUs;
|
|
144
|
+
return [
|
|
145
|
+
`[${inputSpecifier(input, slice.streamIndex)}]`,
|
|
146
|
+
`trim=start=${seconds(slice.fileRange.startUs)}:end=${seconds(slice.fileRange.endUs)}`,
|
|
147
|
+
"settb=AVTB",
|
|
148
|
+
// Source clocks may be coarse; cinema offsets stay in integer microseconds.
|
|
149
|
+
`setpts=(PTS-STARTPTS)*${decimal(outputDurationUs / inputDurationUs)}+${seconds(slice.outputRange.startUs)}/TB`,
|
|
150
|
+
"format=rgba",
|
|
151
|
+
].join(",");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Concatenate the declared slices of one window into a cinema-clock stream. */
|
|
155
|
+
function windowChain(
|
|
156
|
+
context: GraphContext,
|
|
157
|
+
slices: readonly CinemaMediaSlice[],
|
|
158
|
+
fit: CinemaResolvedShot["fit"],
|
|
159
|
+
gradeFilter: string | undefined,
|
|
160
|
+
outputWidth: number,
|
|
161
|
+
outputHeight: number,
|
|
162
|
+
prefix: string,
|
|
163
|
+
): string {
|
|
164
|
+
const parts: string[] = [];
|
|
165
|
+
const members: string[] = [];
|
|
166
|
+
for (const slice of slices) {
|
|
167
|
+
const member = label(context, `${prefix}_part`);
|
|
168
|
+
parts.push(`${sliceChain(slice, context.inputIndex)}[${member}]`);
|
|
169
|
+
members.push(member);
|
|
170
|
+
}
|
|
171
|
+
const geometry = fit === "cover"
|
|
172
|
+
? `scale=w=${outputWidth}:h=${outputHeight}:force_original_aspect_ratio=increase,crop=${outputWidth}:${outputHeight}`
|
|
173
|
+
: `scale=w=${outputWidth}:h=${outputHeight}:force_original_aspect_ratio=decrease,pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:color=black@0`;
|
|
174
|
+
const joined = label(context, prefix);
|
|
175
|
+
parts.push(
|
|
176
|
+
`${members.map(member => `[${member}]`).join("")}concat=n=${members.length}:v=1:a=0,${geometry},format=rgba${gradeFilter === undefined ? "" : `,${gradeFilter}`}[${joined}]`,
|
|
177
|
+
);
|
|
178
|
+
context.filters.push(...parts);
|
|
179
|
+
return joined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A placeholder shot is an explicit deterministic color source on the cinema clock. */
|
|
183
|
+
function placeholderChain(
|
|
184
|
+
context: GraphContext,
|
|
185
|
+
shot: CinemaResolvedShot,
|
|
186
|
+
startUs: number,
|
|
187
|
+
endUs: number,
|
|
188
|
+
outputWidth: number,
|
|
189
|
+
outputHeight: number,
|
|
190
|
+
): string {
|
|
191
|
+
const color = shot.placeholder!.color;
|
|
192
|
+
const joined = label(context, "shot_placeholder");
|
|
193
|
+
context.filters.push(
|
|
194
|
+
`color=c=${color}:s=${outputWidth}x${outputHeight}:r=${context.rate}:d=${seconds(endUs - startUs)},format=rgba,setpts=PTS+${seconds(startUs)}/TB[${joined}]`,
|
|
195
|
+
);
|
|
196
|
+
return joined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Compile one bounded transition into an owned filter recipe. Every branch is
|
|
201
|
+
* a typed composition of FFmpeg primitives; caller text never reaches a filter.
|
|
202
|
+
*/
|
|
203
|
+
function transitionRecipe(transition: CinemaResolvedTransition): readonly string[] {
|
|
204
|
+
switch (transition.kind) {
|
|
205
|
+
case "dissolve":
|
|
206
|
+
return [`xfade=transition=fade:duration=${seconds(transition.durationUs)}:offset=0`];
|
|
207
|
+
case "wipe":
|
|
208
|
+
return [`xfade=transition=${WIPE_XFADE[transition.direction]}:duration=${seconds(transition.durationUs)}:offset=0`];
|
|
209
|
+
case "whip-pan":
|
|
210
|
+
return [
|
|
211
|
+
`xfade=transition=${WHIP_PAN_XFADE[transition.direction]}:duration=${seconds(transition.durationUs)}:offset=0`,
|
|
212
|
+
`gblur=sigma=${decimal(transition.blurSigma)}`,
|
|
213
|
+
];
|
|
214
|
+
default:
|
|
215
|
+
throw new CliError("unsupported-plan", `Transition ${transition.kind} has no direct recipe.`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Emit the transition window stream covering [startUs, endUs). Multi-stage
|
|
221
|
+
* recipes (dip-to-color, light-flash) concat owned sub-transitions; a retained
|
|
222
|
+
* spatial clip streams directly; cut and match-cut produce no stream.
|
|
223
|
+
*/
|
|
224
|
+
function transitionWindowChain(
|
|
225
|
+
context: GraphContext,
|
|
226
|
+
transition: CinemaResolvedTransition,
|
|
227
|
+
before: CinemaResolvedShot,
|
|
228
|
+
after: CinemaResolvedShot,
|
|
229
|
+
gradeOf: (shot: CinemaResolvedShot) => string | undefined,
|
|
230
|
+
outputWidth: number,
|
|
231
|
+
outputHeight: number,
|
|
232
|
+
): string | null {
|
|
233
|
+
if (transition.kind === "cut" || transition.kind === "match-cut") return null;
|
|
234
|
+
const startUs = transition.outputRange.startUs;
|
|
235
|
+
const endUs = transition.outputRange.endUs;
|
|
236
|
+
const durationUs = transition.durationUs;
|
|
237
|
+
|
|
238
|
+
const windowOf = (
|
|
239
|
+
shot: CinemaResolvedShot,
|
|
240
|
+
slices: readonly CinemaMediaSlice[],
|
|
241
|
+
prefix: string,
|
|
242
|
+
limitUs?: number,
|
|
243
|
+
): string => {
|
|
244
|
+
const grade = gradeOf(shot);
|
|
245
|
+
let stream: string;
|
|
246
|
+
if (slices.length === 0) {
|
|
247
|
+
if (shot.placeholder === undefined) {
|
|
248
|
+
throw new CliError(
|
|
249
|
+
"invalid-data",
|
|
250
|
+
`Shot ${shot.shotId} has no media covering a required transition window.`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
stream = placeholderChain(context, shot, startUs, endUs, outputWidth, outputHeight);
|
|
254
|
+
} else {
|
|
255
|
+
stream = windowChain(context, slices, shot.fit, grade, outputWidth, outputHeight, prefix);
|
|
256
|
+
}
|
|
257
|
+
const zeroed = label(context, `${prefix}_zero`);
|
|
258
|
+
context.filters.push(
|
|
259
|
+
`[${stream}]setpts=PTS-STARTPTS,fps=${context.rate}${limitUs === undefined ? "" : `,trim=end=${seconds(limitUs)},setpts=PTS-STARTPTS`}[${zeroed}]`,
|
|
260
|
+
);
|
|
261
|
+
return zeroed;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const joinSegments = (segments: readonly string[], prefix: string): string => {
|
|
265
|
+
if (segments.length === 1) return segments[0]!;
|
|
266
|
+
const joined = label(context, prefix);
|
|
267
|
+
context.filters.push(
|
|
268
|
+
`${segments.map(segment => `[${segment}]`).join("")}concat=n=${segments.length}:v=1:a=0[${joined}]`,
|
|
269
|
+
);
|
|
270
|
+
return joined;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const xfade = (first: string, second: string, durationText: string, name: string, prefix: string): string => {
|
|
274
|
+
const joined = label(context, prefix);
|
|
275
|
+
context.filters.push(`[${first}][${second}]xfade=transition=${name}:duration=${durationText}:offset=0[${joined}]`);
|
|
276
|
+
return joined;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const colorSource = (color: string, durationText: string, prefix: string): string => {
|
|
280
|
+
const stream = label(context, prefix);
|
|
281
|
+
context.filters.push(
|
|
282
|
+
`color=c=${color}:s=${outputWidth}x${outputHeight}:r=${context.rate}:d=${durationText},format=rgba[${stream}]`,
|
|
283
|
+
);
|
|
284
|
+
return stream;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
let body: string;
|
|
288
|
+
if (transition.kind === "spatial") {
|
|
289
|
+
const clipIndex = context.inputIndex.get(transition.clip.path)!;
|
|
290
|
+
const stream = label(context, "transition_spatial");
|
|
291
|
+
context.filters.push(
|
|
292
|
+
`[${clipIndex}:v:0]trim=start=0:end=${seconds(durationUs)},settb=AVTB,setpts=PTS-STARTPTS,fps=${context.rate},scale=w=${outputWidth}:h=${outputHeight}:force_original_aspect_ratio=decrease,pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:color=black@0,format=rgba[${stream}]`,
|
|
293
|
+
);
|
|
294
|
+
body = stream;
|
|
295
|
+
} else {
|
|
296
|
+
const a = windowOf(before, before.video.outHandle, "transition_out");
|
|
297
|
+
if (transition.kind === "dip-to-color") {
|
|
298
|
+
const firstHalfUs = Math.floor(durationUs / 2);
|
|
299
|
+
const secondHalfUs = durationUs - firstHalfUs;
|
|
300
|
+
const b = windowOf(after, after.video.inHandle, "transition_in", secondHalfUs);
|
|
301
|
+
const first = xfade(
|
|
302
|
+
a,
|
|
303
|
+
colorSource(transition.color, seconds(firstHalfUs), "dip_color_a"),
|
|
304
|
+
seconds(firstHalfUs),
|
|
305
|
+
"fade",
|
|
306
|
+
"dip_a",
|
|
307
|
+
);
|
|
308
|
+
const second = xfade(
|
|
309
|
+
colorSource(transition.color, seconds(secondHalfUs), "dip_color_b"),
|
|
310
|
+
b,
|
|
311
|
+
seconds(secondHalfUs),
|
|
312
|
+
"fade",
|
|
313
|
+
"dip_b",
|
|
314
|
+
);
|
|
315
|
+
body = joinSegments([first, second], "dip_joined");
|
|
316
|
+
} else if (transition.kind === "light-flash") {
|
|
317
|
+
const rampUs = Math.floor((durationUs - transition.peakHoldUs) / 2);
|
|
318
|
+
const tailUs = durationUs - transition.peakHoldUs - rampUs;
|
|
319
|
+
const b = windowOf(after, after.video.inHandle, "transition_in", tailUs);
|
|
320
|
+
const segments = [
|
|
321
|
+
xfade(a, colorSource("white", seconds(rampUs), "flash_color_a"), seconds(rampUs), "fade", "flash_a"),
|
|
322
|
+
...(transition.peakHoldUs === 0
|
|
323
|
+
? []
|
|
324
|
+
: [colorSource("white", seconds(transition.peakHoldUs), "flash_hold")]),
|
|
325
|
+
xfade(colorSource("white", seconds(tailUs), "flash_color_b"), b, seconds(tailUs), "fade", "flash_b"),
|
|
326
|
+
];
|
|
327
|
+
body = joinSegments(segments, "flash_joined");
|
|
328
|
+
} else {
|
|
329
|
+
const b = windowOf(after, after.video.inHandle, "transition_in");
|
|
330
|
+
const recipe = transitionRecipe(transition);
|
|
331
|
+
const blended = label(context, "transition_blend");
|
|
332
|
+
context.filters.push(`[${a}][${b}]${recipe.join(",")}[${blended}]`);
|
|
333
|
+
body = blended;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const positioned = label(context, "transition_window");
|
|
337
|
+
context.filters.push(`[${body}]setpts=PTS+${seconds(startUs)}/TB[${positioned}]`);
|
|
338
|
+
return positioned;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Build a deterministic FFmpeg invocation for a compiled cinema render plan.
|
|
343
|
+
* Pure planning stays in `core/cinema-plan.ts`; this builder owns argv, graph
|
|
344
|
+
* materialization, and media re-verification only.
|
|
345
|
+
*/
|
|
346
|
+
export async function buildCinemaFfmpegInvocation(
|
|
347
|
+
plan: CinemaRenderPlanV1,
|
|
348
|
+
options: CinemaRenderBuildOptions,
|
|
349
|
+
): Promise<BuiltCinemaRenderInvocation> {
|
|
350
|
+
if (plan.output.durationUs <= 0) {
|
|
351
|
+
throw new CliError("unsupported-plan", "A zero-duration cinema sequence cannot render.");
|
|
352
|
+
}
|
|
353
|
+
const recipe = resolveProjectRenderEncoderRecipe(options.tier ?? "preview");
|
|
354
|
+
const emittedRate = decimal(plan.output.frameRate);
|
|
355
|
+
const projectRoot = await realpath(options.projectDirectory);
|
|
356
|
+
const output = resolve(options.outputPath);
|
|
357
|
+
if (!isWithin(projectRoot, output)) {
|
|
358
|
+
throw new CliError("unsafe-path", "Cinema render output must remain in its project directory.");
|
|
359
|
+
}
|
|
360
|
+
const outputRelative = relative(projectRoot, output);
|
|
361
|
+
if (!outputRelative.startsWith("renders/") || isAbsolute(outputRelative)) {
|
|
362
|
+
throw new CliError("unsafe-path", "Cinema render output must remain under renders/.");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const mediaIntegrity = new Map<string, ExpectedProjectMediaIntegrity>();
|
|
366
|
+
const register = (path: string, expected: ExpectedProjectMediaIntegrity): void => {
|
|
367
|
+
const prior = mediaIntegrity.get(path);
|
|
368
|
+
if (prior !== undefined && (prior.bytes !== expected.bytes || prior.sha256 !== expected.sha256)) {
|
|
369
|
+
throw new CliError("invalid-data", `Cinema slices disagree about media integrity for ${path}.`);
|
|
370
|
+
}
|
|
371
|
+
mediaIntegrity.set(path, expected);
|
|
372
|
+
};
|
|
373
|
+
for (const shot of plan.shots) {
|
|
374
|
+
for (const slice of [
|
|
375
|
+
...shot.video.inHandle,
|
|
376
|
+
...shot.video.body,
|
|
377
|
+
...shot.video.outHandle,
|
|
378
|
+
...(shot.audio === undefined ? [] : [
|
|
379
|
+
...shot.audio.media.inHandle,
|
|
380
|
+
...shot.audio.media.body,
|
|
381
|
+
...shot.audio.media.outHandle,
|
|
382
|
+
]),
|
|
383
|
+
]) {
|
|
384
|
+
register(slice.path, { bytes: slice.bytes, sha256: slice.sha256 });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const cue of plan.audioCues) register(cue.path, { bytes: cue.bytes, sha256: cue.sha256 });
|
|
388
|
+
for (const transition of plan.transitions) {
|
|
389
|
+
if (transition.kind === "spatial") {
|
|
390
|
+
register(transition.clip.path, { bytes: transition.clip.bytes, sha256: transition.clip.sha256 });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const inputIndex = new Map<string, number>();
|
|
395
|
+
const inputArguments: string[] = [];
|
|
396
|
+
const pinnedInputs: CinemaRenderPinnedInput[] = [];
|
|
397
|
+
for (const [path, expected] of mediaIntegrity) {
|
|
398
|
+
inputIndex.set(path, inputIndex.size);
|
|
399
|
+
const spatialOutput = /^spatial\/outputs\/[a-f0-9]{64}\.[a-z0-9]+$/u.test(path);
|
|
400
|
+
const physical = await resolveVerifiedProjectMedia({
|
|
401
|
+
expected,
|
|
402
|
+
label: `Cinema media ${path}`,
|
|
403
|
+
path,
|
|
404
|
+
repositoryRoot: spatialOutput ? projectRoot : options.repositoryRoot,
|
|
405
|
+
});
|
|
406
|
+
inputArguments.push("-threads", String(recipe.decoderThreads), "-i", physical);
|
|
407
|
+
pinnedInputs.push({ ...expected, label: `Cinema media ${path}`, path: physical });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const context: GraphContext = {
|
|
411
|
+
filters: [
|
|
412
|
+
`color=c=${plan.output.background}:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:r=${emittedRate}:d=${seconds(plan.output.durationUs)},format=rgba[canvas_0]`,
|
|
413
|
+
],
|
|
414
|
+
inputIndex,
|
|
415
|
+
rate: emittedRate,
|
|
416
|
+
serial: 0,
|
|
417
|
+
};
|
|
418
|
+
const width = plan.output.pixelWidth;
|
|
419
|
+
const height = plan.output.pixelHeight;
|
|
420
|
+
const gradeByShot = new Map<string, string>();
|
|
421
|
+
for (const look of plan.looks) {
|
|
422
|
+
gradeByShot.set(look.lookId, buildColorGradeFilter({
|
|
423
|
+
grade: look.grade,
|
|
424
|
+
kind: "slopcamera.color-grade-transform",
|
|
425
|
+
outputProfile: "preserve",
|
|
426
|
+
schemaVersion: 1,
|
|
427
|
+
videoStreamIndex: 0,
|
|
428
|
+
}).filter);
|
|
429
|
+
}
|
|
430
|
+
const gradeOf = (shot: CinemaResolvedShot): string | undefined => (
|
|
431
|
+
shot.lookId === undefined ? undefined : gradeByShot.get(shot.lookId)
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
let currentVideo = "canvas_0";
|
|
435
|
+
const overlay = (stream: string, startUs: number, endUs: number): void => {
|
|
436
|
+
const next = label(context, "canvas");
|
|
437
|
+
context.filters.push(
|
|
438
|
+
`[${currentVideo}][${stream}]overlay=x=0:y=0:eof_action=repeat:repeatlast=1:enable='gte(t,${seconds(startUs)})*lt(t,${seconds(endUs)})'[${next}]`,
|
|
439
|
+
);
|
|
440
|
+
currentVideo = next;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
for (const [index, shot] of plan.shots.entries()) {
|
|
444
|
+
const body = shot.placeholder === undefined
|
|
445
|
+
? windowChain(context, shot.video.body, shot.fit, gradeOf(shot), width, height, "shot_body")
|
|
446
|
+
: placeholderChain(
|
|
447
|
+
context,
|
|
448
|
+
shot,
|
|
449
|
+
shot.outputRange.startUs,
|
|
450
|
+
shot.outputRange.endUs,
|
|
451
|
+
width,
|
|
452
|
+
height,
|
|
453
|
+
);
|
|
454
|
+
overlay(body, shot.outputRange.startUs, shot.outputRange.endUs);
|
|
455
|
+
|
|
456
|
+
const transition = plan.transitions[index];
|
|
457
|
+
if (transition !== undefined) {
|
|
458
|
+
const segment = transitionWindowChain(
|
|
459
|
+
context,
|
|
460
|
+
transition,
|
|
461
|
+
shot,
|
|
462
|
+
plan.shots[index + 1]!,
|
|
463
|
+
gradeOf,
|
|
464
|
+
width,
|
|
465
|
+
height,
|
|
466
|
+
);
|
|
467
|
+
if (segment !== null && "outputRange" in transition) {
|
|
468
|
+
overlay(segment, transition.outputRange.startUs, transition.outputRange.endUs);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
context.filters.push(`[${currentVideo}]format=yuv420p[video_out]`);
|
|
473
|
+
|
|
474
|
+
const audioLabels: string[] = [];
|
|
475
|
+
for (const shot of plan.shots) {
|
|
476
|
+
if (shot.audio === undefined) continue;
|
|
477
|
+
const slices = [
|
|
478
|
+
...shot.audio.media.inHandle,
|
|
479
|
+
...shot.audio.media.body,
|
|
480
|
+
...shot.audio.media.outHandle,
|
|
481
|
+
];
|
|
482
|
+
if (slices.length === 0) continue;
|
|
483
|
+
const parts: string[] = [];
|
|
484
|
+
const members: string[] = [];
|
|
485
|
+
for (const slice of slices) {
|
|
486
|
+
const member = label(context, "shot_audio_part");
|
|
487
|
+
const input = inputIndex.get(slice.path)!;
|
|
488
|
+
const inputDurationUs = slice.fileRange.endUs - slice.fileRange.startUs;
|
|
489
|
+
const outputDurationUs = slice.outputRange.endUs - slice.outputRange.startUs;
|
|
490
|
+
parts.push(
|
|
491
|
+
`[${inputSpecifier(input, slice.streamIndex)}]atrim=start=${seconds(slice.fileRange.startUs)}:end=${seconds(slice.fileRange.endUs)},asetpts=PTS-STARTPTS,aresample=48000,${atempo(inputDurationUs / outputDurationUs)},atrim=duration=${seconds(outputDurationUs)}[${member}]`,
|
|
492
|
+
);
|
|
493
|
+
members.push(member);
|
|
494
|
+
}
|
|
495
|
+
const joined = label(context, "shot_audio");
|
|
496
|
+
parts.push(
|
|
497
|
+
`${members.map(member => `[${member}]`).join("")}concat=n=${members.length}:v=0:a=1[${joined}]`,
|
|
498
|
+
);
|
|
499
|
+
// Handle regions crossfade inside each adjoining transition window; bodies
|
|
500
|
+
// stay at the placement gain. The extended stream starts at the pre-roll.
|
|
501
|
+
const extendedStartUs = shot.outputRange.startUs - shot.audio.media.inHandle.reduce(
|
|
502
|
+
(total, slice) => total + (slice.outputRange.endUs - slice.outputRange.startUs),
|
|
503
|
+
0,
|
|
504
|
+
);
|
|
505
|
+
const inHandleUs = shot.audio.media.inHandle.reduce(
|
|
506
|
+
(total, slice) => total + (slice.outputRange.endUs - slice.outputRange.startUs),
|
|
507
|
+
0,
|
|
508
|
+
);
|
|
509
|
+
const outHandleUs = shot.audio.media.outHandle.reduce(
|
|
510
|
+
(total, slice) => total + (slice.outputRange.endUs - slice.outputRange.startUs),
|
|
511
|
+
0,
|
|
512
|
+
);
|
|
513
|
+
const bodyEndUs = inHandleUs + (shot.outputRange.endUs - shot.outputRange.startUs);
|
|
514
|
+
const shaped = label(context, "shot_audio_shaped");
|
|
515
|
+
parts.push(
|
|
516
|
+
`[${joined}]asetpts=PTS-STARTPTS${inHandleUs === 0 ? "" : `,afade=t=in:st=0:d=${seconds(inHandleUs)}`}${outHandleUs === 0 ? "" : `,afade=t=out:st=${seconds(bodyEndUs)}:d=${seconds(outHandleUs)}`},volume=${decimal(shot.audio.gainDb)}dB,aformat=channel_layouts=stereo${shot.audio.pan === 0 ? "" : `,stereotools=balance_out=${decimal(shot.audio.pan)}`}[${shaped}]`,
|
|
517
|
+
);
|
|
518
|
+
const delayed = label(context, "shot_audio_out");
|
|
519
|
+
parts.push(`[${shaped}]${delayToOutput(extendedStartUs).join(",")}[${delayed}]`);
|
|
520
|
+
context.filters.push(...parts);
|
|
521
|
+
audioLabels.push(delayed);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const cueById = new Map<string, CinemaRenderPlanV1["audioCues"][number][]>();
|
|
525
|
+
for (const cue of plan.audioCues) {
|
|
526
|
+
const group = cueById.get(cue.cueId) ?? [];
|
|
527
|
+
group.push(cue);
|
|
528
|
+
cueById.set(cue.cueId, group);
|
|
529
|
+
}
|
|
530
|
+
for (const [cueId, slices] of [...cueById.entries()].sort((left, right) => (
|
|
531
|
+
left[0].localeCompare(right[0])
|
|
532
|
+
))) {
|
|
533
|
+
const ordered = [...slices].sort((left, right) => left.outputRange.startUs - right.outputRange.startUs);
|
|
534
|
+
const parts: string[] = [];
|
|
535
|
+
const members: string[] = [];
|
|
536
|
+
for (const slice of ordered) {
|
|
537
|
+
const member = label(context, "cue_part");
|
|
538
|
+
const input = inputIndex.get(slice.path)!;
|
|
539
|
+
parts.push(
|
|
540
|
+
`[${inputSpecifier(input, slice.streamIndex)}]atrim=start=${seconds(slice.fileRange.startUs)}:end=${seconds(slice.fileRange.endUs)},asetpts=PTS-STARTPTS,aresample=48000,atrim=duration=${seconds(slice.outputRange.endUs - slice.outputRange.startUs)}[${member}]`,
|
|
541
|
+
);
|
|
542
|
+
members.push(member);
|
|
543
|
+
}
|
|
544
|
+
const first = ordered[0]!;
|
|
545
|
+
const cueDurationUs = ordered.reduce(
|
|
546
|
+
(total, slice) => total + (slice.outputRange.endUs - slice.outputRange.startUs),
|
|
547
|
+
0,
|
|
548
|
+
);
|
|
549
|
+
const joined = label(context, `cue_${cueId.replaceAll(/[^a-zA-Z0-9_]/g, "_")}`);
|
|
550
|
+
const fades = [
|
|
551
|
+
first.fadeInUs === 0 ? "" : `,afade=t=in:st=0:d=${seconds(first.fadeInUs)}`,
|
|
552
|
+
first.fadeOutUs === 0
|
|
553
|
+
? ""
|
|
554
|
+
: `,afade=t=out:st=${seconds(cueDurationUs - first.fadeOutUs)}:d=${seconds(first.fadeOutUs)}`,
|
|
555
|
+
].join("");
|
|
556
|
+
parts.push(
|
|
557
|
+
`${members.map(member => `[${member}]`).join("")}concat=n=${members.length}:v=0:a=1,asetpts=PTS-STARTPTS${fades},volume=${decimal(first.gainDb)}dB,aformat=channel_layouts=stereo[${joined}]`,
|
|
558
|
+
);
|
|
559
|
+
const delayed = label(context, "cue_out");
|
|
560
|
+
parts.push(`[${joined}]${delayToOutput(first.outputRange.startUs).join(",")}[${delayed}]`);
|
|
561
|
+
context.filters.push(...parts);
|
|
562
|
+
audioLabels.push(delayed);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
context.filters.push(`anullsrc=r=48000:cl=stereo:d=${seconds(plan.output.durationUs)}[silence]`);
|
|
566
|
+
context.filters.push(
|
|
567
|
+
`[silence]${audioLabels.map(item => `[${item}]`).join("")}amix=inputs=${audioLabels.length + 1}:duration=longest:dropout_transition=0:normalize=0,atrim=duration=${seconds(plan.output.durationUs)}[audio_out]`,
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const filterGraph = await materializeFilterScript({
|
|
571
|
+
graph: context.filters.join(";"),
|
|
572
|
+
relativeDirectory: options.workspaceDirectory === undefined
|
|
573
|
+
? "renders/.filter-graphs"
|
|
574
|
+
: "filter-graphs",
|
|
575
|
+
root: options.workspaceDirectory ?? projectRoot,
|
|
576
|
+
});
|
|
577
|
+
const arguments_: string[] = [
|
|
578
|
+
"-hide_banner", "-nostdin", "-y",
|
|
579
|
+
...cinemaThreadArguments(recipe),
|
|
580
|
+
...inputArguments,
|
|
581
|
+
"-filter_complex_script", filterGraph.path,
|
|
582
|
+
"-map", "[video_out]",
|
|
583
|
+
"-map", "[audio_out]",
|
|
584
|
+
"-c:v", recipe.video.codec,
|
|
585
|
+
...cinemaEncoderArguments(recipe),
|
|
586
|
+
"-pix_fmt", recipe.video.pixelFormat,
|
|
587
|
+
"-c:a", recipe.audio.codec,
|
|
588
|
+
"-t", seconds(plan.output.durationUs),
|
|
589
|
+
"-movflags", recipe.container.movflags,
|
|
590
|
+
output,
|
|
591
|
+
];
|
|
592
|
+
const invocation = CinemaRenderInvocationSchema.parse({
|
|
593
|
+
arguments: arguments_,
|
|
594
|
+
executable: "ffmpeg",
|
|
595
|
+
filterGraph: {
|
|
596
|
+
bytes: filterGraph.bytes,
|
|
597
|
+
path: filterGraph.repositoryPath,
|
|
598
|
+
sha256: filterGraph.sha256,
|
|
599
|
+
},
|
|
600
|
+
outputPath: outputRelative,
|
|
601
|
+
renderPlanSha256: plan.planSha256,
|
|
602
|
+
});
|
|
603
|
+
return {
|
|
604
|
+
argv: [options.ffmpeg, ...arguments_],
|
|
605
|
+
invocation,
|
|
606
|
+
pinnedInputs,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
@@ -168,6 +168,16 @@ export function commandHostResourceClaims(
|
|
|
168
168
|
"project-render",
|
|
169
169
|
])
|
|
170
170
|
: claims(coordinator, ["cpu", "local-io"]);
|
|
171
|
+
case "project-cinema":
|
|
172
|
+
return command.action === "animatic" || command.action === "run"
|
|
173
|
+
? claims(coordinator, [
|
|
174
|
+
"cpu",
|
|
175
|
+
"ffmpeg",
|
|
176
|
+
"local-io",
|
|
177
|
+
"output-publication",
|
|
178
|
+
"project-render",
|
|
179
|
+
])
|
|
180
|
+
: claims(coordinator, ["cpu", "local-io"]);
|
|
171
181
|
case "analyze-faces":
|
|
172
182
|
return claims(coordinator, ["cpu", "local-io", "vision"]);
|
|
173
183
|
case "analyze-zooms":
|
|
@@ -185,11 +195,18 @@ export function commandHostResourceClaims(
|
|
|
185
195
|
? claims(coordinator, ["cpu", "local-io", "ffmpeg", "output-publication"])
|
|
186
196
|
: claims(coordinator, ["cpu", "local-io"]);
|
|
187
197
|
case "spatial-world":
|
|
198
|
+
case "spatial-asset":
|
|
199
|
+
case "spatial-design":
|
|
188
200
|
return claims(coordinator, ["cpu", "local-io"]);
|
|
189
201
|
case "spatial-scene":
|
|
190
202
|
return command.action === "render"
|
|
191
203
|
? claims(coordinator, ["cpu", "local-io", "browser", "ffmpeg", "output-publication"])
|
|
192
|
-
:
|
|
204
|
+
: command.action === "render-audit"
|
|
205
|
+
? claims(coordinator, ["cpu", "local-io", "browser", "ffmpeg"])
|
|
206
|
+
: command.action === "review"
|
|
207
|
+
// Local render plus one bounded paid dispatch in a single claim.
|
|
208
|
+
? claims(coordinator, ["cpu", "local-io", "browser", "ffmpeg", "network", "paid-call"])
|
|
209
|
+
: claims(coordinator, ["cpu", "local-io"]);
|
|
193
210
|
case "spatial-project":
|
|
194
211
|
return command.action === "prepare-render"
|
|
195
212
|
? claims(coordinator, ["cpu", "local-io", "browser", "ffmpeg", "output-publication", "project-render"])
|
|
@@ -208,7 +225,17 @@ export function commandHostResourceClaims(
|
|
|
208
225
|
// Local media inspection acquires its complete CPU/FFmpeg/I/O vector
|
|
209
226
|
// only while validating bytes. Paid provider waits must not pin render
|
|
210
227
|
// capacity across agents for their potentially long network lifetime.
|
|
228
|
+
// A gallery command is one admitted paid batch; it bounds its own
|
|
229
|
+
// in-flight candidate concurrency internally.
|
|
230
|
+
return claims(coordinator, ["network", "paid-call"]);
|
|
231
|
+
case "ai-image-gallery":
|
|
232
|
+
// Probe previews admit the qualified browser render vector only for the
|
|
233
|
+
// settled-candidate render phase so paid waits never pin it.
|
|
211
234
|
return claims(coordinator, ["network", "paid-call"]);
|
|
235
|
+
case "ai-scene-gallery":
|
|
236
|
+
// Scene-variant galleries do no paid work: every variant render is a
|
|
237
|
+
// local qualified browser still plus content-addressed publication.
|
|
238
|
+
return claims(coordinator, ["cpu", "local-io", "browser", "ffmpeg", "output-publication"]);
|
|
212
239
|
case "ai-models-list":
|
|
213
240
|
case "ai-models-show":
|
|
214
241
|
return claims(coordinator, ["network"]);
|
|
@@ -225,6 +252,7 @@ export function commandHostResourceClaims(
|
|
|
225
252
|
case "fillers-apply":
|
|
226
253
|
case "fillers-list":
|
|
227
254
|
case "help":
|
|
255
|
+
case "capabilities":
|
|
228
256
|
case "inspect":
|
|
229
257
|
case "menubar":
|
|
230
258
|
case "operations-list":
|