@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,174 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { mkdtemp, open, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, posix } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import { createBoundedJsonValueSnapshot } from "../../../../src/code/json-snapshot";
|
|
8
|
+
import { SPATIAL_SCENE_LIMITS, SpatialAssetIdSchema, SpatialDigestSchema } from "../../../../src/spatial-scene/contracts";
|
|
9
|
+
import { parseSpatialScene, spatialSceneSha256 } from "../../../../src/spatial-scene/identity";
|
|
10
|
+
import { SpatialRenderedAuditReportSchema } from "../../../../src/spatial-scene/audit-rendered";
|
|
11
|
+
import { ensurePhysicalPrivateDirectoryWithin } from "../../cli/paths";
|
|
12
|
+
import { canonicalJson } from "../../core/canonical-json";
|
|
13
|
+
import { exactCapabilityByName } from "../capability-binding";
|
|
14
|
+
import type { ApplicationContext } from "../context";
|
|
15
|
+
import { ApplicationError } from "../errors";
|
|
16
|
+
import { bindHtmlOverlayBrowserRuntime, HtmlOverlayBrowserRuntimeBindingSchema } from "../html-overlay-browser-runtime";
|
|
17
|
+
import type { OperationDefinition, OperationExecutionContext } from "../operation";
|
|
18
|
+
import {
|
|
19
|
+
auditSpatialSceneRenderedHost, planSpatialRenderedAudit, spatialRenderedAuditCapabilityNames,
|
|
20
|
+
SpatialRenderedAuditRequestSchema, type SpatialRenderedAuditDependencies,
|
|
21
|
+
} from "../spatial-rendered-audit";
|
|
22
|
+
import { MediaCapabilityBindingsSchema, bindExpectedMediaCapabilities } from "./media/capabilities";
|
|
23
|
+
import {
|
|
24
|
+
MediaArtifactReferenceSchema, MediaArtifactRequestSchema, bindRepositoryMedia, loadRepositoryMedia,
|
|
25
|
+
createMediaOperationWorkspace,
|
|
26
|
+
} from "./media/shared";
|
|
27
|
+
|
|
28
|
+
const MAXIMUM_ASSET_BYTES = 256 * 1024 * 1024;
|
|
29
|
+
const AssetBindingSchema = z.strictObject({ assetId: SpatialAssetIdSchema, artifact: MediaArtifactReferenceSchema });
|
|
30
|
+
const requestShape = z.strictObject({
|
|
31
|
+
source: MediaArtifactRequestSchema,
|
|
32
|
+
assets: z.array(AssetBindingSchema).max(SPATIAL_SCENE_LIMITS.assets).optional(),
|
|
33
|
+
request: SpatialRenderedAuditRequestSchema,
|
|
34
|
+
sceneSha256: SpatialDigestSchema.optional(),
|
|
35
|
+
capabilityBindings: MediaCapabilityBindingsSchema.optional(),
|
|
36
|
+
browserRuntime: HtmlOverlayBrowserRuntimeBindingSchema.optional(),
|
|
37
|
+
});
|
|
38
|
+
const captured = (input: unknown) => createBoundedJsonValueSnapshot(input, 16 * 1024 * 1024, "spatial rendered audit request", { maximumDepth: 48, maximumValues: 500_000 }).value;
|
|
39
|
+
export const SpatialRenderedAuditInputSchema = z.preprocess(captured, requestShape);
|
|
40
|
+
export const BoundSpatialRenderedAuditInputSchema = z.preprocess(captured, requestShape.extend({
|
|
41
|
+
source: MediaArtifactReferenceSchema,
|
|
42
|
+
assets: z.array(AssetBindingSchema).max(SPATIAL_SCENE_LIMITS.assets),
|
|
43
|
+
sceneSha256: SpatialDigestSchema,
|
|
44
|
+
capabilityBindings: MediaCapabilityBindingsSchema,
|
|
45
|
+
browserRuntime: HtmlOverlayBrowserRuntimeBindingSchema,
|
|
46
|
+
}));
|
|
47
|
+
/** The output is the report itself, matching scene.audit; nothing is published. */
|
|
48
|
+
export const SpatialRenderedAuditOutputSchema = SpatialRenderedAuditReportSchema;
|
|
49
|
+
export type SpatialRenderedAuditInput = z.infer<typeof SpatialRenderedAuditInputSchema>;
|
|
50
|
+
export type BoundSpatialRenderedAuditInput = z.infer<typeof BoundSpatialRenderedAuditInputSchema>;
|
|
51
|
+
export type SpatialRenderedAuditOutput = z.infer<typeof SpatialRenderedAuditOutputSchema>;
|
|
52
|
+
|
|
53
|
+
function sourceDocument(data: Uint8Array) {
|
|
54
|
+
try { return parseSpatialScene(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(data))); }
|
|
55
|
+
catch (error) { throw new ApplicationError("invalid-data", `Invalid spatial source: ${error instanceof Error ? error.message : String(error)}`); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The source grants access only to its bounded, root-contained declared
|
|
60
|
+
* closure. Capability and browser-runtime identity are bound at admission and
|
|
61
|
+
* re-verified inside the host service, exactly like scene.render.
|
|
62
|
+
*/
|
|
63
|
+
export async function bindSpatialRenderedAuditInput(
|
|
64
|
+
application: ApplicationContext,
|
|
65
|
+
input: unknown,
|
|
66
|
+
signal = new AbortController().signal,
|
|
67
|
+
bindRuntime: typeof bindHtmlOverlayBrowserRuntime = bindHtmlOverlayBrowserRuntime,
|
|
68
|
+
): Promise<BoundSpatialRenderedAuditInput> {
|
|
69
|
+
const parsed = SpatialRenderedAuditInputSchema.parse(input);
|
|
70
|
+
const source = await loadRepositoryMedia(application, parsed.source, signal, SPATIAL_SCENE_LIMITS.sourceBytes);
|
|
71
|
+
const scene = sourceDocument(source.data);
|
|
72
|
+
planSpatialRenderedAudit(scene, parsed.request);
|
|
73
|
+
const sceneSha256 = spatialSceneSha256(scene);
|
|
74
|
+
if (parsed.sceneSha256 !== undefined && parsed.sceneSha256 !== sceneSha256) throw new ApplicationError("conflict", "Scene source changed after planning.");
|
|
75
|
+
if (scene.assets.reduce((sum, asset) => sum + asset.payload.bytes, 0) > MAXIMUM_ASSET_BYTES) throw new ApplicationError("invalid-data", "Scene asset closure exceeds 256 MiB.");
|
|
76
|
+
const supplied = parsed.assets === undefined ? undefined : new Map(parsed.assets.map(item => [item.assetId, item.artifact]));
|
|
77
|
+
if (supplied !== undefined && (supplied.size !== parsed.assets!.length || supplied.size !== scene.assets.length || scene.assets.some(asset => !supplied.has(asset.assetId)))) throw new ApplicationError("invalid-data", "Asset bindings must name every declared asset exactly once.");
|
|
78
|
+
const assets: z.infer<typeof AssetBindingSchema>[] = [];
|
|
79
|
+
for (const asset of scene.assets) {
|
|
80
|
+
const request = supplied?.get(asset.assetId) ?? {
|
|
81
|
+
...asset.payload, path: posix.join(posix.dirname(source.artifact.path), asset.payload.path),
|
|
82
|
+
};
|
|
83
|
+
if (request.sha256 !== asset.payload.sha256 || request.bytes !== asset.payload.bytes) throw new ApplicationError("conflict", `Asset ${asset.assetId} does not match its source manifest.`);
|
|
84
|
+
const bound = await bindRepositoryMedia(application, request, signal, asset.payload.bytes);
|
|
85
|
+
assets.push({ assetId: asset.assetId, artifact: bound.artifact });
|
|
86
|
+
}
|
|
87
|
+
const capabilityBindings = await bindExpectedMediaCapabilities(application, spatialRenderedAuditCapabilityNames(scene), parsed.capabilityBindings);
|
|
88
|
+
const browserRuntime = await bindRuntime(exactCapabilityByName(capabilityBindings, "html-browser"), signal);
|
|
89
|
+
if (parsed.browserRuntime !== undefined && canonicalJson(parsed.browserRuntime) !== canonicalJson(browserRuntime)) throw new ApplicationError("conflict", "Browser runtime changed after rendered audit planning.");
|
|
90
|
+
return BoundSpatialRenderedAuditInputSchema.parse({ ...parsed, source: source.artifact, assets, sceneSha256, capabilityBindings, browserRuntime });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function writePrivateFile(path: string, bytes: Uint8Array): Promise<void> {
|
|
94
|
+
const handle = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
95
|
+
try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Trusted test ports are never part of a serialized audit request. */
|
|
99
|
+
export interface SpatialRenderedAuditOperationDependencies extends SpatialRenderedAuditDependencies {
|
|
100
|
+
readonly createWorkspace?: typeof createMediaOperationWorkspace;
|
|
101
|
+
readonly createDirectory?: typeof mkdtemp;
|
|
102
|
+
readonly removeDirectory?: (path: string) => Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function executeSpatialRenderedAudit(
|
|
106
|
+
context: OperationExecutionContext,
|
|
107
|
+
input: SpatialRenderedAuditInput,
|
|
108
|
+
dependencies: SpatialRenderedAuditOperationDependencies = {},
|
|
109
|
+
): Promise<SpatialRenderedAuditOutput> {
|
|
110
|
+
if (context.workflow !== undefined) BoundSpatialRenderedAuditInputSchema.parse(input);
|
|
111
|
+
const bound = await bindSpatialRenderedAuditInput(context.application, input, context.abortSignal, dependencies.bindBrowserRuntime);
|
|
112
|
+
const source = await loadRepositoryMedia(context.application, bound.source, context.abortSignal, SPATIAL_SCENE_LIMITS.sourceBytes);
|
|
113
|
+
const scene = sourceDocument(source.data);
|
|
114
|
+
const workspace = await (dependencies.createWorkspace ?? createMediaOperationWorkspace)(context);
|
|
115
|
+
let directory: string | undefined;
|
|
116
|
+
const cleanupErrors: unknown[] = [];
|
|
117
|
+
let completed: SpatialRenderedAuditOutput | undefined, failure: unknown, failed = false;
|
|
118
|
+
try {
|
|
119
|
+
directory = await (dependencies.createDirectory ?? mkdtemp)(join(workspace.path, "spatial-audit-source-"));
|
|
120
|
+
const assetRoot = await ensurePhysicalPrivateDirectoryWithin(directory, "assets");
|
|
121
|
+
const writtenPaths = new Map<string, string>();
|
|
122
|
+
for (const asset of scene.assets) {
|
|
123
|
+
const artifact = bound.assets.find(item => item.assetId === asset.assetId)!.artifact;
|
|
124
|
+
const loaded = await loadRepositoryMedia(context.application, artifact, context.abortSignal, asset.payload.bytes);
|
|
125
|
+
const previous = writtenPaths.get(asset.payload.path);
|
|
126
|
+
if (previous !== undefined) {
|
|
127
|
+
if (previous !== artifact.sha256) throw new ApplicationError("conflict", "Two scene assets declare different bytes at one path.");
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const parent = dirname(asset.payload.path);
|
|
131
|
+
if (parent !== ".") await ensurePhysicalPrivateDirectoryWithin(assetRoot, parent);
|
|
132
|
+
await writePrivateFile(join(assetRoot, asset.payload.path), loaded.data);
|
|
133
|
+
writtenPaths.set(asset.payload.path, artifact.sha256);
|
|
134
|
+
}
|
|
135
|
+
const report = await auditSpatialSceneRenderedHost(context, {
|
|
136
|
+
scene, assetRoot, request: bound.request,
|
|
137
|
+
capabilityBindings: bound.capabilityBindings, browserRuntime: bound.browserRuntime,
|
|
138
|
+
}, dependencies);
|
|
139
|
+
completed = SpatialRenderedAuditOutputSchema.parse(report);
|
|
140
|
+
} catch (error) { failure = error; failed = true; }
|
|
141
|
+
finally {
|
|
142
|
+
if (directory !== undefined) try { await (dependencies.removeDirectory ?? (async path => { await rm(path, { recursive: true, force: true }); }))(directory); } catch (error) { cleanupErrors.push(error); }
|
|
143
|
+
try { await workspace.dispose(); } catch (error) { cleanupErrors.push(error); }
|
|
144
|
+
}
|
|
145
|
+
if (failed || cleanupErrors.length > 0) {
|
|
146
|
+
const cause = cleanupErrors.length === 0 ? failure : new AggregateError([...(failed ? [failure] : []), ...cleanupErrors], "Rendered scene audit failed during execution or cleanup.");
|
|
147
|
+
const error = new ApplicationError(failure instanceof ApplicationError ? failure.code : "internal",
|
|
148
|
+
cause instanceof Error ? cause.message : "Rendered scene audit failed.",
|
|
149
|
+
{ cleanupErrors: cleanupErrors.map(error => error instanceof Error ? error.message : String(error)) });
|
|
150
|
+
error.cause = cause;
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
if (completed === undefined) throw new ApplicationError("internal", "Rendered scene audit completed without a report.");
|
|
154
|
+
return completed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const spatialRenderedAuditOperationDefinition = {
|
|
158
|
+
kind: "scene.render-audit", version: 1,
|
|
159
|
+
inputSchema: SpatialRenderedAuditInputSchema, inputSchemaId: "slopcamera.operation.scene.render-audit.input/v1",
|
|
160
|
+
outputSchema: SpatialRenderedAuditOutputSchema, outputSchemaId: "slopcamera.operation.scene.render-audit.output/v1",
|
|
161
|
+
lifecycle: { kind: "local-artifact", execute: executeSpatialRenderedAudit },
|
|
162
|
+
policy: {
|
|
163
|
+
cache: "none", cancellable: true, effect: "local-derived-write", maxDurationMs: 300_000,
|
|
164
|
+
maxFanOut: 0, maxInputBytes: 16 * 1024 * 1024, maxOutputBytes: 34 * 1024 * 1024,
|
|
165
|
+
preparation: ["local-media"], resources: [
|
|
166
|
+
{ amount: 1, resource: "cpu" }, { amount: 1, resource: "local-io" }, { amount: 1, resource: "browser" },
|
|
167
|
+
{ amount: 1, resource: "ffmpeg" },
|
|
168
|
+
], resume: "deterministic",
|
|
169
|
+
},
|
|
170
|
+
summarize: output => ({ kind: "scene.render-audit", fields: {
|
|
171
|
+
sceneSha256: output.sceneSha256, cameraId: output.cameraId, samples: output.timesUs.length,
|
|
172
|
+
entities: output.summary.entities.total, neverRendered: output.summary.entitiesNeverRendered.length, findings: output.findings.length,
|
|
173
|
+
} }),
|
|
174
|
+
} satisfies OperationDefinition<"scene.render-audit", SpatialRenderedAuditInput, SpatialRenderedAuditOutput>;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { mkdtemp, open, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, posix } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import { createBoundedJsonValueSnapshot } from "../../../../src/code/json-snapshot";
|
|
8
|
+
import { SPATIAL_SCENE_LIMITS, SpatialAssetIdSchema, SpatialDigestSchema } from "../../../../src/spatial-scene/contracts";
|
|
9
|
+
import { parseSpatialScene } from "../../../../src/spatial-scene/identity";
|
|
10
|
+
import { SPATIAL_REVIEW_LIMITS, SpatialReviewReportSchema } from "../../../../src/spatial-scene/review";
|
|
11
|
+
import { ensurePhysicalPrivateDirectoryWithin } from "../../cli/paths";
|
|
12
|
+
import { canonicalJson } from "../../core/canonical-json";
|
|
13
|
+
import { exactCapabilityByName } from "../capability-binding";
|
|
14
|
+
import type { ApplicationContext } from "../context";
|
|
15
|
+
import { ApplicationError } from "../errors";
|
|
16
|
+
import { bindHtmlOverlayBrowserRuntime, HtmlOverlayBrowserRuntimeBindingSchema } from "../html-overlay-browser-runtime";
|
|
17
|
+
import type { OperationDefinition, OperationExecutionContext } from "../operation";
|
|
18
|
+
import {
|
|
19
|
+
planSpatialReview, reviewSpatialSceneHost, spatialReviewCapabilityNames,
|
|
20
|
+
SpatialReviewRequestSchema, type SpatialReviewDependencies, type SpatialReviewGrant,
|
|
21
|
+
} from "../spatial-review";
|
|
22
|
+
import { MediaCapabilityBindingsSchema, bindExpectedMediaCapabilities } from "./media/capabilities";
|
|
23
|
+
import {
|
|
24
|
+
MediaArtifactReferenceSchema, MediaArtifactRequestSchema, bindRepositoryMedia, loadRepositoryMedia,
|
|
25
|
+
createMediaOperationWorkspace,
|
|
26
|
+
} from "./media/shared";
|
|
27
|
+
|
|
28
|
+
const MAXIMUM_ASSET_BYTES = 256 * 1024 * 1024;
|
|
29
|
+
const AssetBindingSchema = z.strictObject({ assetId: SpatialAssetIdSchema, artifact: MediaArtifactReferenceSchema });
|
|
30
|
+
const requestShape = z.strictObject({
|
|
31
|
+
source: MediaArtifactRequestSchema,
|
|
32
|
+
assets: z.array(AssetBindingSchema).max(SPATIAL_SCENE_LIMITS.assets).optional(),
|
|
33
|
+
request: SpatialReviewRequestSchema,
|
|
34
|
+
sceneSha256: SpatialDigestSchema.optional(),
|
|
35
|
+
capabilityBindings: MediaCapabilityBindingsSchema.optional(),
|
|
36
|
+
browserRuntime: HtmlOverlayBrowserRuntimeBindingSchema.optional(),
|
|
37
|
+
});
|
|
38
|
+
const captured = (input: unknown) => createBoundedJsonValueSnapshot(input, 16 * 1024 * 1024, "spatial review request", { maximumDepth: 48, maximumValues: 500_000 }).value;
|
|
39
|
+
export const SpatialReviewInputSchema = z.preprocess(captured, requestShape);
|
|
40
|
+
export const BoundSpatialReviewInputSchema = z.preprocess(captured, requestShape.extend({
|
|
41
|
+
source: MediaArtifactReferenceSchema,
|
|
42
|
+
assets: z.array(AssetBindingSchema).max(SPATIAL_SCENE_LIMITS.assets),
|
|
43
|
+
sceneSha256: SpatialDigestSchema,
|
|
44
|
+
capabilityBindings: MediaCapabilityBindingsSchema,
|
|
45
|
+
browserRuntime: HtmlOverlayBrowserRuntimeBindingSchema,
|
|
46
|
+
}));
|
|
47
|
+
/** The output is the advisory report itself, matching scene.render-audit; nothing is published. */
|
|
48
|
+
export const SpatialReviewOutputSchema = SpatialReviewReportSchema;
|
|
49
|
+
export type SpatialReviewInput = z.infer<typeof SpatialReviewInputSchema>;
|
|
50
|
+
export type BoundSpatialReviewInput = z.infer<typeof BoundSpatialReviewInputSchema>;
|
|
51
|
+
export type SpatialReviewOutput = z.infer<typeof SpatialReviewOutputSchema>;
|
|
52
|
+
|
|
53
|
+
function sourceDocument(data: Uint8Array) {
|
|
54
|
+
try { return parseSpatialScene(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(data))); }
|
|
55
|
+
catch (error) { throw new ApplicationError("invalid-data", `Invalid spatial source: ${error instanceof Error ? error.message : String(error)}`); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The source grants access only to its bounded, root-contained declared
|
|
60
|
+
* closure. Consent is checked against the exact planned upload before any
|
|
61
|
+
* capability probe, browser binding, render, or provider dispatch: without an
|
|
62
|
+
* invocation-scoped grant this fails closed with `authorization-required`.
|
|
63
|
+
* Capability and browser-runtime identity are bound at admission and
|
|
64
|
+
* re-verified inside the host service, exactly like scene.render-audit.
|
|
65
|
+
*/
|
|
66
|
+
export async function bindSpatialReviewInput(
|
|
67
|
+
application: ApplicationContext,
|
|
68
|
+
input: unknown,
|
|
69
|
+
signal = new AbortController().signal,
|
|
70
|
+
bindRuntime: typeof bindHtmlOverlayBrowserRuntime = bindHtmlOverlayBrowserRuntime,
|
|
71
|
+
): Promise<Readonly<{ bound: BoundSpatialReviewInput; grant: SpatialReviewGrant }>> {
|
|
72
|
+
const parsed = SpatialReviewInputSchema.parse(input);
|
|
73
|
+
const source = await loadRepositoryMedia(application, parsed.source, signal, SPATIAL_SCENE_LIMITS.sourceBytes);
|
|
74
|
+
const scene = sourceDocument(source.data);
|
|
75
|
+
const plan = planSpatialReview(scene, parsed.request);
|
|
76
|
+
const sceneSha256 = plan.sceneSha256;
|
|
77
|
+
if (parsed.sceneSha256 !== undefined && parsed.sceneSha256 !== sceneSha256) throw new ApplicationError("conflict", "Scene source changed after planning.");
|
|
78
|
+
const authorization = application.spatialReviewAuthorization;
|
|
79
|
+
const grant = authorization === undefined ? undefined : await authorization.authorize({
|
|
80
|
+
sceneSha256,
|
|
81
|
+
cameraId: plan.request.cameraId,
|
|
82
|
+
timesUs: plan.timesUs,
|
|
83
|
+
maximumFrames: plan.timesUs.length,
|
|
84
|
+
maximumFrameBytes: SPATIAL_REVIEW_LIMITS.pngBytes,
|
|
85
|
+
maximumUploadBytes: SPATIAL_REVIEW_LIMITS.uploadBytes,
|
|
86
|
+
});
|
|
87
|
+
if (grant === undefined) {
|
|
88
|
+
throw new ApplicationError("authorization-required", "Scene review uploads the selected bounded rendered beauty frames to a vision model and requires an explicit cloud-upload grant for this invocation.");
|
|
89
|
+
}
|
|
90
|
+
if (scene.assets.reduce((sum, asset) => sum + asset.payload.bytes, 0) > MAXIMUM_ASSET_BYTES) throw new ApplicationError("invalid-data", "Scene asset closure exceeds 256 MiB.");
|
|
91
|
+
const supplied = parsed.assets === undefined ? undefined : new Map(parsed.assets.map(item => [item.assetId, item.artifact]));
|
|
92
|
+
if (supplied !== undefined && (supplied.size !== parsed.assets!.length || supplied.size !== scene.assets.length || scene.assets.some(asset => !supplied.has(asset.assetId)))) throw new ApplicationError("invalid-data", "Asset bindings must name every declared asset exactly once.");
|
|
93
|
+
const assets: z.infer<typeof AssetBindingSchema>[] = [];
|
|
94
|
+
for (const asset of scene.assets) {
|
|
95
|
+
const request = supplied?.get(asset.assetId) ?? {
|
|
96
|
+
...asset.payload, path: posix.join(posix.dirname(source.artifact.path), asset.payload.path),
|
|
97
|
+
};
|
|
98
|
+
if (request.sha256 !== asset.payload.sha256 || request.bytes !== asset.payload.bytes) throw new ApplicationError("conflict", `Asset ${asset.assetId} does not match its source manifest.`);
|
|
99
|
+
const bound = await bindRepositoryMedia(application, request, signal, asset.payload.bytes);
|
|
100
|
+
assets.push({ assetId: asset.assetId, artifact: bound.artifact });
|
|
101
|
+
}
|
|
102
|
+
const capabilityBindings = await bindExpectedMediaCapabilities(application, spatialReviewCapabilityNames(scene), parsed.capabilityBindings);
|
|
103
|
+
const browserRuntime = await bindRuntime(exactCapabilityByName(capabilityBindings, "html-browser"), signal);
|
|
104
|
+
if (parsed.browserRuntime !== undefined && canonicalJson(parsed.browserRuntime) !== canonicalJson(browserRuntime)) throw new ApplicationError("conflict", "Browser runtime changed after review planning.");
|
|
105
|
+
return {
|
|
106
|
+
bound: BoundSpatialReviewInputSchema.parse({ ...parsed, source: source.artifact, assets, sceneSha256, capabilityBindings, browserRuntime }),
|
|
107
|
+
grant,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function writePrivateFile(path: string, bytes: Uint8Array): Promise<void> {
|
|
112
|
+
const handle = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
113
|
+
try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Trusted test ports are never part of a serialized review request. */
|
|
117
|
+
export interface SpatialReviewOperationDependencies extends SpatialReviewDependencies {
|
|
118
|
+
readonly createWorkspace?: typeof createMediaOperationWorkspace;
|
|
119
|
+
readonly createDirectory?: typeof mkdtemp;
|
|
120
|
+
readonly removeDirectory?: (path: string) => Promise<void>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function executeSpatialReview(
|
|
124
|
+
context: OperationExecutionContext,
|
|
125
|
+
input: SpatialReviewInput,
|
|
126
|
+
dependencies: SpatialReviewOperationDependencies = {},
|
|
127
|
+
): Promise<SpatialReviewOutput> {
|
|
128
|
+
if (context.workflow !== undefined) BoundSpatialReviewInputSchema.parse(input);
|
|
129
|
+
const { bound, grant } = await bindSpatialReviewInput(context.application, input, context.abortSignal, dependencies.bindBrowserRuntime);
|
|
130
|
+
const source = await loadRepositoryMedia(context.application, bound.source, context.abortSignal, SPATIAL_SCENE_LIMITS.sourceBytes);
|
|
131
|
+
const scene = sourceDocument(source.data);
|
|
132
|
+
const workspace = await (dependencies.createWorkspace ?? createMediaOperationWorkspace)(context);
|
|
133
|
+
let directory: string | undefined;
|
|
134
|
+
const cleanupErrors: unknown[] = [];
|
|
135
|
+
let completed: SpatialReviewOutput | undefined, failure: unknown, failed = false;
|
|
136
|
+
try {
|
|
137
|
+
directory = await (dependencies.createDirectory ?? mkdtemp)(join(workspace.path, "spatial-review-source-"));
|
|
138
|
+
const assetRoot = await ensurePhysicalPrivateDirectoryWithin(directory, "assets");
|
|
139
|
+
const writtenPaths = new Map<string, string>();
|
|
140
|
+
for (const asset of scene.assets) {
|
|
141
|
+
const artifact = bound.assets.find(item => item.assetId === asset.assetId)!.artifact;
|
|
142
|
+
const loaded = await loadRepositoryMedia(context.application, artifact, context.abortSignal, asset.payload.bytes);
|
|
143
|
+
const previous = writtenPaths.get(asset.payload.path);
|
|
144
|
+
if (previous !== undefined) {
|
|
145
|
+
if (previous !== artifact.sha256) throw new ApplicationError("conflict", "Two scene assets declare different bytes at one path.");
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const parent = dirname(asset.payload.path);
|
|
149
|
+
if (parent !== ".") await ensurePhysicalPrivateDirectoryWithin(assetRoot, parent);
|
|
150
|
+
await writePrivateFile(join(assetRoot, asset.payload.path), loaded.data);
|
|
151
|
+
writtenPaths.set(asset.payload.path, artifact.sha256);
|
|
152
|
+
}
|
|
153
|
+
const report = await reviewSpatialSceneHost(context, {
|
|
154
|
+
scene, assetRoot, request: bound.request, grant,
|
|
155
|
+
capabilityBindings: bound.capabilityBindings, browserRuntime: bound.browserRuntime,
|
|
156
|
+
}, dependencies);
|
|
157
|
+
completed = SpatialReviewOutputSchema.parse(report);
|
|
158
|
+
} catch (error) { failure = error; failed = true; }
|
|
159
|
+
finally {
|
|
160
|
+
if (directory !== undefined) try { await (dependencies.removeDirectory ?? (async path => { await rm(path, { recursive: true, force: true }); }))(directory); } catch (error) { cleanupErrors.push(error); }
|
|
161
|
+
try { await workspace.dispose(); } catch (error) { cleanupErrors.push(error); }
|
|
162
|
+
}
|
|
163
|
+
if (failed || cleanupErrors.length > 0) {
|
|
164
|
+
const cause = cleanupErrors.length === 0 ? failure : new AggregateError([...(failed ? [failure] : []), ...cleanupErrors], "Scene review failed during execution or cleanup.");
|
|
165
|
+
const error = new ApplicationError(failure instanceof ApplicationError ? failure.code : "internal",
|
|
166
|
+
cause instanceof Error ? cause.message : "Scene review failed.",
|
|
167
|
+
{ cleanupErrors: cleanupErrors.map(error => error instanceof Error ? error.message : String(error)) });
|
|
168
|
+
error.cause = cause;
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
if (completed === undefined) throw new ApplicationError("internal", "Scene review completed without a report.");
|
|
172
|
+
return completed;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const spatialReviewOperationDefinition = {
|
|
176
|
+
kind: "scene.review", version: 1,
|
|
177
|
+
inputSchema: SpatialReviewInputSchema, inputSchemaId: "slopcamera.operation.scene.review.input/v1",
|
|
178
|
+
outputSchema: SpatialReviewOutputSchema, outputSchemaId: "slopcamera.operation.scene.review.output/v1",
|
|
179
|
+
lifecycle: { kind: "paid-dispatch", execute: executeSpatialReview },
|
|
180
|
+
policy: {
|
|
181
|
+
cache: "exact-run", cancellable: true, effect: "paid-cloud", maxDurationMs: 600_000,
|
|
182
|
+
maxFanOut: 0, maxInputBytes: 16 * 1024 * 1024, maxOutputBytes: 4 * 1024 * 1024,
|
|
183
|
+
preparation: ["local-media"], resources: [
|
|
184
|
+
{ amount: 1, resource: "cpu" }, { amount: 1, resource: "local-io" }, { amount: 1, resource: "browser" },
|
|
185
|
+
{ amount: 1, resource: "ffmpeg" }, { amount: 1, resource: "network" }, { amount: 1, resource: "paid-call" },
|
|
186
|
+
], resume: "ambiguous-after-dispatch",
|
|
187
|
+
},
|
|
188
|
+
summarize: output => ({ kind: "scene.review", fields: {
|
|
189
|
+
sceneSha256: output.sceneSha256, cameraId: output.cameraId, frames: output.frames.length,
|
|
190
|
+
findings: output.findings.scene.length + output.findings.frames.reduce((sum, frame) => sum + frame.findings.length, 0),
|
|
191
|
+
model: output.model.requestedModel,
|
|
192
|
+
...(output.model.resolvedModel === null ? {} : { resolvedModel: output.model.resolvedModel }),
|
|
193
|
+
} }),
|
|
194
|
+
} satisfies OperationDefinition<"scene.review", SpatialReviewInput, SpatialReviewOutput>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { createBoundedJsonValueSnapshot } from "../../../../src/code/json-snapshot";
|
|
3
|
+
import { SpatialAuditReportSchema, auditSpatialScene } from "../../../../src/spatial-scene/audit";
|
|
3
4
|
import {
|
|
4
5
|
EvaluatedSpatialSceneSchema, SpatialAssetManifestSchema, SpatialCameraSchema,
|
|
5
6
|
SpatialDigestSchema, SpatialEntityIdSchema, SpatialGeneratorSchema,
|
|
@@ -17,7 +18,7 @@ export const SpatialSceneInspectionOutputSchema = z.strictObject({
|
|
|
17
18
|
entityId: SpatialEntityIdSchema, name: z.string(),
|
|
18
19
|
kind: z.enum(["group", "mesh", "image", "diagram", "video", "text", "light", "splat"]),
|
|
19
20
|
origin: SpatialOriginSchema, parentId: SpatialEntityIdSchema.nullable(), placement: SpatialPlacementSchema,
|
|
20
|
-
editableControls: z.array(z.enum(["color", "opacity", "transform"])),
|
|
21
|
+
editableControls: z.array(z.enum(["color", "opacity", "transform", "emissive", "instances", "castShadow", "receiveShadow", "spot", "shadow"])),
|
|
21
22
|
animatedProperties: z.array(z.string()), assetIds: z.array(z.string()),
|
|
22
23
|
bounds: z.discriminatedUnion("status", [
|
|
23
24
|
z.strictObject({ status: z.literal("authored-enclosure"), coordinateDomain: SpatialPlacementSchema,
|
|
@@ -44,12 +45,19 @@ export const SpatialEvaluateInputSchema = z.preprocess(capture, z.strictObject({
|
|
|
44
45
|
scene: SpatialSceneV1Schema, timeUs: SpatialTimeUsSchema, cameraId: z.string(),
|
|
45
46
|
cameraPoseOverride: SpatialPoseSchema.optional(),
|
|
46
47
|
}));
|
|
48
|
+
export const SpatialAuditInputSchema = z.preprocess(capture, z.strictObject({
|
|
49
|
+
scene: SpatialSceneV1Schema, cameraId: z.string(),
|
|
50
|
+
timesUs: z.array(SpatialTimeUsSchema).max(64).optional(),
|
|
51
|
+
assetBounds: z.record(z.string(), z.strictObject({ min: boundedVector, max: boundedVector })).optional(),
|
|
52
|
+
}));
|
|
47
53
|
export type SpatialInspectInput = z.infer<typeof SpatialInspectInputSchema>;
|
|
48
54
|
export type SpatialInspectOutput = z.infer<typeof SpatialSceneInspectionOutputSchema>;
|
|
49
55
|
export type SpatialPatchInput = z.infer<typeof SpatialPatchInputSchema>;
|
|
50
56
|
export type SpatialPatchOutput = z.infer<typeof SpatialPatchOutputSchema>;
|
|
51
57
|
export type SpatialEvaluateInput = z.infer<typeof SpatialEvaluateInputSchema>;
|
|
52
58
|
export type SpatialEvaluateOutput = z.infer<typeof EvaluatedSpatialSceneSchema>;
|
|
59
|
+
export type SpatialAuditInput = z.infer<typeof SpatialAuditInputSchema>;
|
|
60
|
+
export type SpatialAuditOutput = z.infer<typeof SpatialAuditReportSchema>;
|
|
53
61
|
|
|
54
62
|
const purePolicy = Object.freeze({
|
|
55
63
|
cache: "content-addressed", cancellable: true, effect: "pure", maxDurationMs: 10_000,
|
|
@@ -95,3 +103,19 @@ export const spatialEvaluateOperationDefinition = {
|
|
|
95
103
|
} },
|
|
96
104
|
summarize: output => ({ kind: "scene.evaluate", fields: { sceneSha256: output.sceneSha256, viewSha256: output.viewSha256, timeUs: output.timeUs } }),
|
|
97
105
|
} satisfies OperationDefinition<"scene.evaluate", SpatialEvaluateInput, SpatialEvaluateOutput>;
|
|
106
|
+
|
|
107
|
+
export const spatialAuditOperationDefinition = {
|
|
108
|
+
kind: "scene.audit", version: 1,
|
|
109
|
+
inputSchema: SpatialAuditInputSchema, inputSchemaId: "slopcamera.operation.scene.audit.input/v1",
|
|
110
|
+
outputSchema: SpatialAuditReportSchema, outputSchemaId: "slopcamera.operation.scene.audit.output/v1",
|
|
111
|
+
policy: purePolicy,
|
|
112
|
+
lifecycle: { kind: "pure", execute: (context, input) => {
|
|
113
|
+
throwIfAborted(context.abortSignal);
|
|
114
|
+
return Promise.resolve(SpatialAuditReportSchema.parse(auditSpatialScene(input.scene, {
|
|
115
|
+
cameraId: input.cameraId,
|
|
116
|
+
...(input.timesUs === undefined ? {} : { timesUs: input.timesUs }),
|
|
117
|
+
...(input.assetBounds === undefined ? {} : { assetBounds: input.assetBounds }),
|
|
118
|
+
})));
|
|
119
|
+
} },
|
|
120
|
+
summarize: output => ({ kind: "scene.audit", fields: { sceneSha256: output.sceneSha256, cameraId: output.cameraId, samples: output.timesUs.length, findings: output.findings.length } }),
|
|
121
|
+
} satisfies OperationDefinition<"scene.audit", SpatialAuditInput, SpatialAuditOutput>;
|
|
@@ -125,11 +125,11 @@ function eraseDefinition<Input, Output>(
|
|
|
125
125
|
describe: (): OperationDescription => ({
|
|
126
126
|
...discovery,
|
|
127
127
|
inputJsonSchema: {
|
|
128
|
-
...z.toJSONSchema(definition.inputSchema),
|
|
128
|
+
...z.toJSONSchema(definition.inputSchema, { io: "input" }),
|
|
129
129
|
$id: definition.inputSchemaId,
|
|
130
130
|
},
|
|
131
131
|
outputJsonSchema: {
|
|
132
|
-
...z.toJSONSchema(definition.outputSchema),
|
|
132
|
+
...z.toJSONSchema(definition.outputSchema, { io: "input" }),
|
|
133
133
|
$id: definition.outputSchemaId,
|
|
134
134
|
},
|
|
135
135
|
}),
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdtemp, open, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, extname, isAbsolute, join, relative } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { canonicalJson } from "../core/canonical-json";
|
|
8
|
+
import { createNodeSpatialDurability } from "../core/spatial-durability";
|
|
9
|
+
import { createNodeBundleFileSystem } from "../core/storage";
|
|
10
|
+
import { SpatialAssetFactsV1Schema, type SpatialAssetFactsV1, type SpatialPublishedArtifact } from "../contracts/spatial-asset";
|
|
11
|
+
import {
|
|
12
|
+
SpatialAssetIdSchema, SpatialAssetManifestSchema, SpatialEntitySchema,
|
|
13
|
+
type SpatialAssetManifest, type SpatialEntity, type SpatialPatchOperation,
|
|
14
|
+
} from "../../../src/spatial-scene/contracts";
|
|
15
|
+
import { evaluateSpatialGlb, parseSpatialGlb, spatialGlbBounds, SPATIAL_GLB_LIMITS } from "../../../src/spatial-scene/gltf";
|
|
16
|
+
import { spatialAssetManifestSha256 } from "../../../src/spatial-scene/identity";
|
|
17
|
+
import type { Bounds } from "../../../src/spatial-scene/math";
|
|
18
|
+
|
|
19
|
+
/** Bounded data half of one admission request; effect handles stay host-side. */
|
|
20
|
+
const requestSchema = z.strictObject({
|
|
21
|
+
filePath: z.string().min(1).max(4_096),
|
|
22
|
+
assetRoot: z.string().min(1).max(4_096),
|
|
23
|
+
assetId: SpatialAssetIdSchema.optional(),
|
|
24
|
+
metersPerUnit: z.number().finite().min(0.000001).max(1_000_000),
|
|
25
|
+
sourceUp: z.enum(["x", "y", "z"]),
|
|
26
|
+
});
|
|
27
|
+
export interface SpatialAssetAdmissionRequest {
|
|
28
|
+
readonly filePath: string;
|
|
29
|
+
readonly assetRoot: string;
|
|
30
|
+
readonly assetId?: string;
|
|
31
|
+
readonly metersPerUnit: number;
|
|
32
|
+
readonly sourceUp: "x" | "y" | "z";
|
|
33
|
+
readonly signal: AbortSignal;
|
|
34
|
+
readonly beforePublication?: () => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export interface SpatialAssetAdmissionResult {
|
|
37
|
+
readonly manifest: SpatialAssetManifest;
|
|
38
|
+
readonly factsManifest: SpatialAssetManifest;
|
|
39
|
+
readonly facts: SpatialAssetFactsV1;
|
|
40
|
+
readonly bounds: { readonly modelSpace: Bounds; readonly sceneSpace: Bounds };
|
|
41
|
+
readonly entity: SpatialEntity;
|
|
42
|
+
readonly artifacts: { readonly payload: SpatialPublishedArtifact; readonly facts: SpatialPublishedArtifact };
|
|
43
|
+
readonly operations: readonly SpatialPatchOperation[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Retained evidence survives cancellation and publication/cleanup uncertainty. */
|
|
47
|
+
export class SpatialAssetAdmissionError extends Error {
|
|
48
|
+
readonly published: readonly SpatialPublishedArtifact[];
|
|
49
|
+
readonly attempted: readonly SpatialPublishedArtifact[];
|
|
50
|
+
constructor(cause: unknown, published: readonly SpatialPublishedArtifact[], attempted: readonly SpatialPublishedArtifact[] = published) {
|
|
51
|
+
super("Scene asset admission did not complete; retain its exact source and publication evidence.", { cause });
|
|
52
|
+
this.name = "SpatialAssetAdmissionError";
|
|
53
|
+
this.published = Object.freeze([...published]);
|
|
54
|
+
this.attempted = Object.freeze([...attempted]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function physicalRoot(path: string): Promise<string> {
|
|
59
|
+
if (!isAbsolute(path) || await realpath(path) !== path || !(await lstat(path)).isDirectory()) {
|
|
60
|
+
throw new RangeError("Scene asset roots must be absolute physical directories without symlinks.");
|
|
61
|
+
}
|
|
62
|
+
return path;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Capture one explicit local file exactly once: a bounded physical regular
|
|
67
|
+
* file with no symlink components, a stable inode identity across the read,
|
|
68
|
+
* and a sha256 over precisely the captured bytes.
|
|
69
|
+
*/
|
|
70
|
+
async function readAdmittedAssetFile(path: string, signal: AbortSignal): Promise<{ readonly bytes: Uint8Array; readonly sha256: string }> {
|
|
71
|
+
signal.throwIfAborted();
|
|
72
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
73
|
+
try {
|
|
74
|
+
const before = await handle.stat();
|
|
75
|
+
if (!before.isFile() || before.size < 1 || before.size > SPATIAL_GLB_LIMITS.bytes || await realpath(path) !== path) {
|
|
76
|
+
throw new RangeError("Admitted asset must be a bounded physical regular file without symlink components.");
|
|
77
|
+
}
|
|
78
|
+
const bytes = new Uint8Array(before.size);
|
|
79
|
+
let offset = 0;
|
|
80
|
+
while (offset < bytes.length) {
|
|
81
|
+
signal.throwIfAborted();
|
|
82
|
+
const read = await handle.read(bytes, offset, Math.min(1024 * 1024, bytes.length - offset), offset);
|
|
83
|
+
if (read.bytesRead === 0) throw new RangeError("Admitted asset was truncated while it was being read.");
|
|
84
|
+
offset += read.bytesRead;
|
|
85
|
+
}
|
|
86
|
+
const after = await handle.stat(), leaf = await lstat(path);
|
|
87
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || before.mode !== after.mode
|
|
88
|
+
|| leaf.isSymbolicLink() || leaf.dev !== after.dev || leaf.ino !== after.ino || await realpath(path) !== path) {
|
|
89
|
+
throw new RangeError("Admitted asset changed while it was captured.");
|
|
90
|
+
}
|
|
91
|
+
signal.throwIfAborted();
|
|
92
|
+
return { bytes, sha256: createHash("sha256").update(bytes).digest("hex") };
|
|
93
|
+
} finally { await handle.close(); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isGlbPayload(path: string, bytes: Uint8Array): boolean {
|
|
97
|
+
return extname(path).toLowerCase() === ".glb"
|
|
98
|
+
|| (bytes.length >= 4 && bytes[0] === 0x67 && bytes[1] === 0x6c && bytes[2] === 0x54 && bytes[3] === 0x46);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function entityName(path: string): string {
|
|
102
|
+
const name = basename(path).replace(/\.[^.]*$/u, "").trim();
|
|
103
|
+
return name === "" ? "Admitted model" : name.slice(0, 256);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Admit one local GLB file as a scene asset. Exact source bytes are copied
|
|
108
|
+
* into the caller's content-addressed asset root, model- and scene-space
|
|
109
|
+
* bounds are derived from decoded vertex data, and the result is a subject
|
|
110
|
+
* `gltf` manifest, a sibling `metadata` facts manifest, a mesh entity, and a
|
|
111
|
+
* ready-to-apply patch fragment. Publication is atomic and no-replace:
|
|
112
|
+
* identical content-addressed bytes settle as "exists" so an interrupted
|
|
113
|
+
* admit retries deterministically, while different bytes conflict.
|
|
114
|
+
*/
|
|
115
|
+
export async function admitSpatialAssetFile(options: SpatialAssetAdmissionRequest): Promise<SpatialAssetAdmissionResult> {
|
|
116
|
+
const parsed = requestSchema.safeParse({
|
|
117
|
+
filePath: options.filePath, assetRoot: options.assetRoot, assetId: options.assetId,
|
|
118
|
+
metersPerUnit: options.metersPerUnit, sourceUp: options.sourceUp,
|
|
119
|
+
});
|
|
120
|
+
if (!parsed.success) throw new RangeError("Scene asset admission request is invalid.");
|
|
121
|
+
const request = parsed.data;
|
|
122
|
+
options.signal.throwIfAborted();
|
|
123
|
+
if (!isAbsolute(request.filePath)) throw new RangeError("Admitted asset paths must resolve to absolute local files.");
|
|
124
|
+
const root = await physicalRoot(request.assetRoot);
|
|
125
|
+
const { bytes, sha256 } = await readAdmittedAssetFile(request.filePath, options.signal);
|
|
126
|
+
if (!isGlbPayload(request.filePath, bytes)) {
|
|
127
|
+
throw new RangeError(`scene asset admit currently admits GLB 2.0 payloads: ${basename(request.filePath)} is not a .glb file and lacks the glTF magic.`);
|
|
128
|
+
}
|
|
129
|
+
const model = parseSpatialGlb(bytes);
|
|
130
|
+
const modelSpace = spatialGlbBounds(model);
|
|
131
|
+
const sceneSpace = evaluateSpatialGlb(model, { metersPerUnit: request.metersPerUnit, sourceUp: request.sourceUp, timeUs: 0 }).bounds;
|
|
132
|
+
const assetId = request.assetId ?? `asset_admitted_${sha256}`;
|
|
133
|
+
const entityId = `entity_admitted_${sha256}`;
|
|
134
|
+
const payload = { path: `assets/${sha256}.glb`, sha256, bytes: bytes.byteLength };
|
|
135
|
+
const manifest = SpatialAssetManifestSchema.parse({
|
|
136
|
+
assetId, payload,
|
|
137
|
+
interpretation: { kind: "gltf", format: "glb", metersPerUnit: request.metersPerUnit, sourceUp: request.sourceUp },
|
|
138
|
+
dependencies: [],
|
|
139
|
+
provenance: { source: "imported", description: `Admitted local GLB payload ${basename(request.filePath)}.` },
|
|
140
|
+
});
|
|
141
|
+
const facts = SpatialAssetFactsV1Schema.parse({
|
|
142
|
+
kind: "slopcamera.spatial-asset-facts", schemaVersion: 1,
|
|
143
|
+
subject: payload, subjectManifestSha256: spatialAssetManifestSha256(manifest),
|
|
144
|
+
profile: model.profile, nodeCount: model.nodeCount, clipDurationsSeconds: [...model.clipDurationsSeconds],
|
|
145
|
+
bounds: { modelSpace, sceneSpace },
|
|
146
|
+
materials: model.materialFacts.map(fact => ({ ...fact })),
|
|
147
|
+
...(model.rigFacts === undefined ? {} : { rig: model.rigFacts }),
|
|
148
|
+
});
|
|
149
|
+
const factsText = `${canonicalJson(facts)}\n`, factsSha256 = createHash("sha256").update(factsText).digest("hex");
|
|
150
|
+
const factsPayload = { path: `assets/${factsSha256}.json`, sha256: factsSha256, bytes: Buffer.byteLength(factsText) };
|
|
151
|
+
const factsManifest = SpatialAssetManifestSchema.parse({
|
|
152
|
+
assetId: `asset_facts_${factsSha256}`, payload: factsPayload,
|
|
153
|
+
interpretation: { kind: "metadata", format: "json", schema: "slopcamera.spatial-asset-facts" },
|
|
154
|
+
dependencies: [assetId],
|
|
155
|
+
provenance: { source: "derived", description: `Derived bounds and profile facts for ${assetId}.` },
|
|
156
|
+
});
|
|
157
|
+
if (factsManifest.assetId === assetId) throw new RangeError("Requested asset identity conflicts with the derived facts asset.");
|
|
158
|
+
const entity = SpatialEntitySchema.parse({
|
|
159
|
+
entityId, kind: "mesh", name: entityName(request.filePath), parentId: null,
|
|
160
|
+
transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] },
|
|
161
|
+
placement: { kind: "world" }, origin: { kind: "authored" }, visible: true,
|
|
162
|
+
geometry: { kind: "asset", assetId },
|
|
163
|
+
material: { kind: "standard", color: "#cccccc", opacity: 1, roughness: 1, metalness: 0 },
|
|
164
|
+
});
|
|
165
|
+
const fileSystem = createNodeBundleFileSystem(root), durability = createNodeSpatialDurability(root);
|
|
166
|
+
const published: SpatialPublishedArtifact[] = [];
|
|
167
|
+
const attempted: SpatialPublishedArtifact[] = [];
|
|
168
|
+
const fence = async () => { options.signal.throwIfAborted(); await options.beforePublication?.(); options.signal.throwIfAborted(); };
|
|
169
|
+
await fence();
|
|
170
|
+
const workspace = await mkdtemp(join(root, ".slopcamera-asset-admit-"));
|
|
171
|
+
let result: SpatialAssetAdmissionResult | undefined, failure: { error: unknown } | undefined;
|
|
172
|
+
try {
|
|
173
|
+
const staged = join(workspace, `${sha256}.glb`);
|
|
174
|
+
await writeFile(staged, bytes, { flag: "wx", mode: 0o600 });
|
|
175
|
+
attempted.push({ ...payload, disposition: "created" });
|
|
176
|
+
const payloadDisposition = await fileSystem.copyFileNoReplace!(relative(root, staged), payload.path, payload, fence);
|
|
177
|
+
published.push({ ...payload, disposition: payloadDisposition });
|
|
178
|
+
await durability.syncExactFile(payload.path, payload);
|
|
179
|
+
attempted.push({ ...factsPayload, disposition: "created" });
|
|
180
|
+
const factsDisposition = await fileSystem.writeTextNoReplace!(factsPayload.path, factsText, fence);
|
|
181
|
+
published.push({ ...factsPayload, disposition: factsDisposition });
|
|
182
|
+
await durability.syncExactFile(factsPayload.path, factsPayload);
|
|
183
|
+
const operations: SpatialPatchOperation[] = [
|
|
184
|
+
{ kind: "add-asset", asset: manifest },
|
|
185
|
+
{ kind: "add-asset", asset: factsManifest },
|
|
186
|
+
{ kind: "add-entity", entity },
|
|
187
|
+
];
|
|
188
|
+
result = {
|
|
189
|
+
manifest, factsManifest, facts, entity, operations,
|
|
190
|
+
bounds: { modelSpace, sceneSpace },
|
|
191
|
+
artifacts: { payload: published[0]!, facts: published[1]! },
|
|
192
|
+
};
|
|
193
|
+
} catch (error) { failure = { error }; }
|
|
194
|
+
finally {
|
|
195
|
+
try { await rm(workspace, { recursive: true, force: true }); }
|
|
196
|
+
catch (error) { failure = { error: failure === undefined ? error : new AggregateError([failure.error, error], "Scene asset admission and workspace cleanup failed.") }; }
|
|
197
|
+
}
|
|
198
|
+
if (failure !== undefined) throw new SpatialAssetAdmissionError(failure.error, published, attempted);
|
|
199
|
+
if (result === undefined) throw new SpatialAssetAdmissionError(new Error("Scene asset admission did not settle."), published, attempted);
|
|
200
|
+
return result;
|
|
201
|
+
}
|