@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,124 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ApplicationContext } from "../application/context";
|
|
5
|
+
import { admitSpatialAssetFile, SpatialAssetAdmissionError } from "../application/spatial-asset-admission";
|
|
6
|
+
import { SpatialAssetAdmissionV1Schema } from "../contracts/spatial-asset";
|
|
7
|
+
import type { SpatialAssetCommand } from "./args";
|
|
8
|
+
import { CliError } from "./errors";
|
|
9
|
+
import { displayPath, ensurePhysicalPrivateDirectoryWithin, resolveSafePath } from "./paths";
|
|
10
|
+
import { publishSpatialSource } from "./spatial-scene-service";
|
|
11
|
+
|
|
12
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
13
|
+
const pathRelative = relative(root, candidate);
|
|
14
|
+
return pathRelative === "" || (!pathRelative.startsWith("..") && !isAbsolute(pathRelative));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isMissing(error: unknown): boolean {
|
|
18
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A source root must exist and be physical before bounding one file below it. */
|
|
22
|
+
async function physicalSourceRoot(path: string): Promise<string> {
|
|
23
|
+
let details;
|
|
24
|
+
try { details = await lstat(path); }
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (isMissing(error)) throw new CliError("not-found", `--source-root is not a directory: ${path}`);
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
30
|
+
throw new CliError("unsafe-path", `--source-root must be a physical directory without symlinks: ${path}`);
|
|
31
|
+
}
|
|
32
|
+
return await realpath(path);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Admit one explicit local GLB below --source-root (default: the project root),
|
|
37
|
+
* publish its exact bytes and derived facts beside --output, and write the
|
|
38
|
+
* deterministic admission document without replacing any existing path.
|
|
39
|
+
*/
|
|
40
|
+
export async function executeSpatialAssetCommand(
|
|
41
|
+
application: ApplicationContext,
|
|
42
|
+
command: SpatialAssetCommand,
|
|
43
|
+
adapter: Readonly<{ signal?: AbortSignal }>,
|
|
44
|
+
): Promise<unknown> {
|
|
45
|
+
const repositoryRoot = application.paths.repositoryRoot;
|
|
46
|
+
const signal = adapter.signal ?? new AbortController().signal;
|
|
47
|
+
const fence = async () => {
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
await application.hostResourceLease?.assertOwned();
|
|
50
|
+
signal.throwIfAborted();
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
signal.throwIfAborted();
|
|
54
|
+
const sourceRoot = await physicalSourceRoot(resolve(repositoryRoot, command.sourceRoot ?? "."));
|
|
55
|
+
const source = resolve(sourceRoot, command.file);
|
|
56
|
+
let physicalSource: string;
|
|
57
|
+
try { physicalSource = await realpath(source); }
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (isMissing(error)) throw new CliError("not-found", `Admitted asset does not exist: ${command.file}`);
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
if (!isWithin(sourceRoot, physicalSource)) {
|
|
63
|
+
throw new CliError("unsafe-path", `Admitted asset escapes --source-root: ${command.file}`);
|
|
64
|
+
}
|
|
65
|
+
if (physicalSource !== source) {
|
|
66
|
+
throw new CliError("unsafe-path", `Admitted asset must be a physical file without symlink components: ${command.file}`);
|
|
67
|
+
}
|
|
68
|
+
if (!(await lstat(physicalSource)).isFile()) {
|
|
69
|
+
throw new CliError("invalid-data", `Admitted asset is not a regular file: ${command.file}`);
|
|
70
|
+
}
|
|
71
|
+
const output = await resolveSafePath(repositoryRoot, command.output);
|
|
72
|
+
try {
|
|
73
|
+
await lstat(output);
|
|
74
|
+
throw new CliError("conflict", "Scene asset admission output already exists. Choose a new path to retain both revisions.");
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof CliError) throw error;
|
|
77
|
+
if (!isMissing(error)) throw error;
|
|
78
|
+
}
|
|
79
|
+
const outputParent = relative(repositoryRoot, dirname(output));
|
|
80
|
+
const assetRoot = outputParent === ""
|
|
81
|
+
? repositoryRoot
|
|
82
|
+
: await ensurePhysicalPrivateDirectoryWithin(repositoryRoot, outputParent);
|
|
83
|
+
const result = await admitSpatialAssetFile({
|
|
84
|
+
filePath: physicalSource, assetRoot,
|
|
85
|
+
...(command.assetId === undefined ? {} : { assetId: command.assetId }),
|
|
86
|
+
metersPerUnit: command.metersPerUnit, sourceUp: command.sourceUp,
|
|
87
|
+
signal, beforePublication: fence,
|
|
88
|
+
});
|
|
89
|
+
const document = SpatialAssetAdmissionV1Schema.parse({
|
|
90
|
+
kind: "slopcamera.spatial-asset-admission", schemaVersion: 1,
|
|
91
|
+
manifest: result.manifest, factsManifest: result.factsManifest, facts: result.facts,
|
|
92
|
+
bounds: result.bounds, entity: result.entity, artifacts: result.artifacts,
|
|
93
|
+
operations: result.operations,
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
await publishSpatialSource(output, document, fence);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
const failure = error instanceof CliError ? error : new CliError("conflict", "Scene asset admission output publication failed.");
|
|
99
|
+
throw new CliError(signal.aborted ? "cancelled" : failure.code, failure.message, {
|
|
100
|
+
...failure.details, published: [result.artifacts.payload, result.artifacts.facts], output: command.output,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
output: displayPath(repositoryRoot, output),
|
|
105
|
+
assetId: result.manifest.assetId,
|
|
106
|
+
factsAssetId: result.factsManifest.assetId,
|
|
107
|
+
entityId: result.entity.entityId,
|
|
108
|
+
bounds: result.bounds,
|
|
109
|
+
artifacts: result.artifacts,
|
|
110
|
+
document,
|
|
111
|
+
};
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error instanceof SpatialAssetAdmissionError) {
|
|
114
|
+
throw new CliError(signal.aborted ? "cancelled" : "conflict", error.message, {
|
|
115
|
+
published: error.published, attempted: error.attempted, output: command.output,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (signal.aborted && !(error instanceof CliError)) {
|
|
119
|
+
throw new CliError("cancelled", "Scene asset admission cancelled; retain its source and any publication evidence.");
|
|
120
|
+
}
|
|
121
|
+
if (error instanceof RangeError) throw new CliError("invalid-data", error.message);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { mkdir, open, realpath, unlink } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { createSha256HexHasher } from "../../../src/code/sha256";
|
|
7
|
+
import { SlopcameraCodeError } from "../../../src/code/errors";
|
|
8
|
+
import { compileSpatialDesign, editSpatialDesignParameters, inspectSpatialDesign } from "../../../src/spatial-scene/design";
|
|
9
|
+
import { createSpatialDesignStarter, listSpatialDesignTemplates } from "../../../src/spatial-scene/design-templates";
|
|
10
|
+
import { parseSpatialScene, parseSpatialValue, spatialSceneSha256, spatialValueSha256 } from "../../../src/spatial-scene/identity";
|
|
11
|
+
import type { SpatialSceneV1 } from "../../../src/spatial-scene/contracts";
|
|
12
|
+
import type { ApplicationContext } from "../application/context";
|
|
13
|
+
import { createNodeBundleFileSystem } from "../core/storage";
|
|
14
|
+
import type { SpatialDesignCommand } from "./args";
|
|
15
|
+
import { CliError } from "./errors";
|
|
16
|
+
import { ensurePhysicalPrivateDirectoryWithin, resolveSafePath } from "./paths";
|
|
17
|
+
import { publishSpatialSource, readSpatialJson } from "./spatial-scene-service";
|
|
18
|
+
|
|
19
|
+
const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
const MAX_GALLERY_BYTES = 128 * 1024 * 1024;
|
|
21
|
+
const ParametersSchema = z.record(z.string().min(1).max(64), z.number().finite()).refine(value => Object.keys(value).length <= 128, "At most 128 parameter updates are allowed.");
|
|
22
|
+
export const SpatialDesignVariantsSchema = z.strictObject({
|
|
23
|
+
kind: z.literal("slopcamera.spatial-design-variants"), schemaVersion: z.literal(1),
|
|
24
|
+
variants: z.array(z.strictObject({
|
|
25
|
+
id: z.string().min(1).max(48).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
|
|
26
|
+
label: z.string().min(1).max(120), parameters: ParametersSchema,
|
|
27
|
+
})).min(1).max(6),
|
|
28
|
+
}).superRefine((value, context) => {
|
|
29
|
+
if (new Set(value.variants.map(variant => variant.id)).size !== value.variants.length) context.addIssue({ code: "custom", message: "Variant IDs must be unique." });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
type Compilation = ReturnType<typeof compileSpatialDesign>;
|
|
33
|
+
type CapturedAsset = Readonly<{ path: string; bytes: Uint8Array }>;
|
|
34
|
+
|
|
35
|
+
function inspectionSummary(input: unknown): unknown {
|
|
36
|
+
const report = inspectSpatialDesign(input);
|
|
37
|
+
return { designId: report.design.designId, name: report.design.name, designSha256: report.designSha256,
|
|
38
|
+
parameters: report.design.parameters, values: report.values, valueOrder: report.valueOrder,
|
|
39
|
+
constraints: report.constraints, estimate: report.estimate,
|
|
40
|
+
stages: report.stages.map(({ spec: _spec, ...stage }) => stage) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function digest(bytes: Uint8Array): string {
|
|
44
|
+
const hasher = createSha256HexHasher(); hasher.update(bytes); return hasher.digestHex();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function captureAsset(sourceRoot: string, path: string, expected: { bytes: number; sha256: string }): Promise<CapturedAsset> {
|
|
48
|
+
const absolute = resolve(sourceRoot, path);
|
|
49
|
+
if (absolute !== await realpath(absolute)) throw new CliError("unsafe-path", "Design assets must have physical paths without symlinks.");
|
|
50
|
+
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
51
|
+
try {
|
|
52
|
+
const before = await handle.stat({ bigint: true });
|
|
53
|
+
if (!before.isFile() || before.size !== BigInt(expected.bytes) || expected.bytes > MAX_BUNDLE_BYTES) throw new CliError("invalid-data", `Design asset size differs: ${path}`);
|
|
54
|
+
const bytes = new Uint8Array(expected.bytes);
|
|
55
|
+
let offset = 0;
|
|
56
|
+
while (offset < bytes.length) {
|
|
57
|
+
const result = await handle.read(bytes, offset, Math.min(262144, bytes.length - offset), offset);
|
|
58
|
+
if (result.bytesRead === 0) throw new CliError("conflict", `Design asset changed: ${path}`);
|
|
59
|
+
offset += result.bytesRead;
|
|
60
|
+
}
|
|
61
|
+
const after = await handle.stat({ bigint: true });
|
|
62
|
+
if (before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || digest(bytes) !== expected.sha256) throw new CliError("conflict", `Design asset identity differs: ${path}`);
|
|
63
|
+
return { path, bytes };
|
|
64
|
+
} finally { await handle.close(); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Capture only the assets still referenced by the compiled scene, including base-scene dependencies. */
|
|
68
|
+
async function captureCompilation(compilation: Compilation, sourceRoot: string | undefined): Promise<readonly CapturedAsset[]> {
|
|
69
|
+
const emitted = new Map(compilation.outputs.flatMap(output => output.artifacts).map(artifact => [artifact.assetId, artifact]));
|
|
70
|
+
const seen = new Map<string, { path: string; sha256: string; bytes: number }>();
|
|
71
|
+
let total = 0;
|
|
72
|
+
const assets: CapturedAsset[] = [];
|
|
73
|
+
for (const asset of compilation.scene.assets) {
|
|
74
|
+
const { path, bytes, sha256 } = asset.payload;
|
|
75
|
+
const pathKey = path.normalize("NFC").toLowerCase();
|
|
76
|
+
if (["design.json", "scene.json", "receipt.json", "render.json", "gallery.json", "base.scene.json"].some(reserved => pathKey === reserved || pathKey.startsWith(`${reserved}/`))) throw new CliError("conflict", `Asset uses a reserved bundle path: ${path}`);
|
|
77
|
+
const prior = seen.get(pathKey);
|
|
78
|
+
if (prior !== undefined) {
|
|
79
|
+
if (prior.sha256 !== sha256 || prior.bytes !== bytes || prior.path !== path) throw new CliError("conflict", `Asset paths have conflicting identities: ${path}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const existing of seen.keys()) if (pathKey.startsWith(`${existing}/`) || existing.startsWith(`${pathKey}/`)) throw new CliError("conflict", `Asset file and directory paths collide: ${path}`);
|
|
83
|
+
seen.set(pathKey, { path, sha256, bytes });
|
|
84
|
+
total += bytes;
|
|
85
|
+
if (total > MAX_BUNDLE_BYTES) throw new CliError("invalid-data", "Design bundle exceeds 64 MiB.");
|
|
86
|
+
const generated = emitted.get(asset.assetId);
|
|
87
|
+
if (generated !== undefined) {
|
|
88
|
+
if (generated.path !== path || generated.bytes.byteLength !== bytes || digest(generated.bytes) !== sha256) throw new CliError("invalid-data", "Generated design asset identity differs.");
|
|
89
|
+
assets.push({ path, bytes: generated.bytes });
|
|
90
|
+
} else {
|
|
91
|
+
if (sourceRoot === undefined) throw new CliError("invalid-data", "A base scene with retained assets requires --scene.");
|
|
92
|
+
assets.push(await captureAsset(sourceRoot, path, asset.payload));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return assets;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function reserveDirectory(root: string, requested: string, fence: () => Promise<void>): Promise<string> {
|
|
99
|
+
const target = await resolveSafePath(root, requested);
|
|
100
|
+
if (target === root) throw new CliError("unsafe-path", "Design output must be a new directory below the workspace.");
|
|
101
|
+
const parent = relative(root, dirname(target));
|
|
102
|
+
if (parent !== "") await ensurePhysicalPrivateDirectoryWithin(root, parent);
|
|
103
|
+
await fence();
|
|
104
|
+
// The custody callback may yield; validate physical parents again afterwards.
|
|
105
|
+
await resolveSafePath(root, requested);
|
|
106
|
+
if (parent !== "") await ensurePhysicalPrivateDirectoryWithin(root, parent);
|
|
107
|
+
try { await mkdir(target, { mode: 0o700 }); }
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") throw new CliError("conflict", "Design output already exists. Choose a new directory to preserve all revisions.");
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
return target;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function publishBytes(root: string, asset: CapturedAsset, fence: () => Promise<void>): Promise<void> {
|
|
116
|
+
const folder = dirname(asset.path);
|
|
117
|
+
const directory = folder === "." ? root : await ensurePhysicalPrivateDirectoryWithin(root, folder);
|
|
118
|
+
const path = join(directory, basename(asset.path));
|
|
119
|
+
const temporary = join(directory, `.design-${randomUUID()}.tmp`);
|
|
120
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
121
|
+
try {
|
|
122
|
+
await handle.writeFile(asset.bytes); await handle.sync();
|
|
123
|
+
} finally { await handle.close(); }
|
|
124
|
+
try {
|
|
125
|
+
const fileSystem = createNodeBundleFileSystem(root);
|
|
126
|
+
// The storage boundary rechecks staging bytes and physical parents after
|
|
127
|
+
// the custody callback and publishes by a guarded no-replace hard link.
|
|
128
|
+
const result = await fileSystem.copyFileNoReplace!(relative(root, temporary), relative(root, path),
|
|
129
|
+
{ bytes: asset.bytes.byteLength, sha256: digest(asset.bytes) }, fence).catch((error: unknown) => {
|
|
130
|
+
if (error instanceof Error && !(error instanceof CliError) && !("code" in error)) throw new CliError("conflict", error.message);
|
|
131
|
+
throw error;
|
|
132
|
+
});
|
|
133
|
+
if (result !== "created") throw new CliError("conflict", "Design asset output already exists.");
|
|
134
|
+
} finally { await unlink(temporary); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function publishCompilation(root: string, compilation: Compilation, assets: readonly CapturedAsset[], fence: () => Promise<void>): Promise<unknown> {
|
|
138
|
+
for (const asset of assets) await publishBytes(root, asset, fence);
|
|
139
|
+
await publishSpatialSource(join(root, "design.json"), compilation.design, fence);
|
|
140
|
+
await publishSpatialSource(join(root, "scene.json"), compilation.scene, fence);
|
|
141
|
+
const cameraId = compilation.scene.cameras.find(camera => camera.cameraId === "camera_hero")?.cameraId ?? compilation.scene.cameras[0]?.cameraId;
|
|
142
|
+
if (cameraId !== undefined) await publishSpatialSource(join(root, "render.json"), { cameraId, selection: { kind: "frame", timeUs: 0 }, mode: { kind: "beauty" } }, fence);
|
|
143
|
+
const receipt = {
|
|
144
|
+
kind: "slopcamera.spatial-design-bundle", schemaVersion: 1,
|
|
145
|
+
compilation: compilation.receipt, compilationSha256: compilation.receiptSha256,
|
|
146
|
+
stages: compilation.outputs.map(output => ({ receipt: output.receipt, receiptSha256: output.receiptSha256 })),
|
|
147
|
+
sceneSha256: spatialSceneSha256(compilation.scene),
|
|
148
|
+
files: assets.map(asset => ({ path: asset.path, bytes: asset.bytes.byteLength, sha256: digest(asset.bytes) })),
|
|
149
|
+
};
|
|
150
|
+
await publishSpatialSource(join(root, "receipt.json"), receipt, fence);
|
|
151
|
+
return { directory: root, scene: join(root, "scene.json"), design: join(root, "design.json"), receipt: join(root, "receipt.json"),
|
|
152
|
+
receiptSha256: spatialValueSha256(receipt), sceneSha256: receipt.sceneSha256,
|
|
153
|
+
...(cameraId === undefined ? {} : { renderRequest: join(root, "render.json") }),
|
|
154
|
+
stages: compilation.outputs.length, assets: assets.length };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function executeDesign(application: ApplicationContext, command: SpatialDesignCommand, signal?: AbortSignal): Promise<unknown> {
|
|
158
|
+
const root = application.paths.repositoryRoot;
|
|
159
|
+
const fence = async () => { signal?.throwIfAborted(); await application.hostResourceLease?.assertOwned(); signal?.throwIfAborted(); };
|
|
160
|
+
await fence();
|
|
161
|
+
if (command.action === "catalog") return { templates: listSpatialDesignTemplates() };
|
|
162
|
+
if (command.action === "init") {
|
|
163
|
+
const starter = createSpatialDesignStarter(command.template);
|
|
164
|
+
inspectSpatialDesign(starter.design);
|
|
165
|
+
const directory = await reserveDirectory(root, command.directory, fence);
|
|
166
|
+
await publishSpatialSource(join(directory, "design.json"), starter.design, fence);
|
|
167
|
+
await publishSpatialSource(join(directory, "base.scene.json"), starter.scene, fence);
|
|
168
|
+
return { directory, design: join(directory, "design.json"), scene: join(directory, "base.scene.json"), template: command.template };
|
|
169
|
+
}
|
|
170
|
+
const design = await readSpatialJson(resolve(root, command.path));
|
|
171
|
+
if (command.action === "inspect") return inspectionSummary(design);
|
|
172
|
+
if (command.action === "set") {
|
|
173
|
+
const parameters = parseSpatialValue(ParametersSchema, await readSpatialJson(resolve(root, command.parameters), 65536), "design parameter updates");
|
|
174
|
+
const edited = editSpatialDesignParameters(design, parameters);
|
|
175
|
+
const output = await resolveSafePath(root, command.output);
|
|
176
|
+
const folder = relative(root, dirname(output));
|
|
177
|
+
if (folder !== "") await ensurePhysicalPrivateDirectoryWithin(root, folder);
|
|
178
|
+
await publishSpatialSource(output, edited, fence);
|
|
179
|
+
return { path: output, inspection: inspectionSummary(edited) };
|
|
180
|
+
}
|
|
181
|
+
const scenePath = command.scene === undefined ? undefined : resolve(root, command.scene);
|
|
182
|
+
const scene: SpatialSceneV1 | undefined = scenePath === undefined ? undefined : parseSpatialScene(await readSpatialJson(scenePath));
|
|
183
|
+
const options = scene === undefined ? {} : { scene };
|
|
184
|
+
const sourceRoot = scenePath === undefined ? undefined : await realpath(dirname(scenePath));
|
|
185
|
+
if (command.action === "compile") {
|
|
186
|
+
const compilation = compileSpatialDesign(design, options);
|
|
187
|
+
const assets = await captureCompilation(compilation, sourceRoot);
|
|
188
|
+
const directory = await reserveDirectory(root, command.outputDir, fence);
|
|
189
|
+
return await publishCompilation(directory, compilation, assets, fence);
|
|
190
|
+
}
|
|
191
|
+
const variants = parseSpatialValue(SpatialDesignVariantsSchema, await readSpatialJson(resolve(root, command.variants), 65536), "design variants");
|
|
192
|
+
// Inspect every candidate before emitting any geometry or publishing a directory.
|
|
193
|
+
const designs = variants.variants.map(variant => editSpatialDesignParameters(design, variant.parameters));
|
|
194
|
+
let estimatedBytes = 0;
|
|
195
|
+
for (const candidate of designs) {
|
|
196
|
+
const inspection = inspectSpatialDesign(candidate);
|
|
197
|
+
estimatedBytes += inspection.estimate.bytes;
|
|
198
|
+
if (estimatedBytes > MAX_GALLERY_BYTES) throw new CliError("invalid-data", "Design gallery exceeds 128 MiB of estimated output.");
|
|
199
|
+
}
|
|
200
|
+
const compiled = [];
|
|
201
|
+
let total = 0;
|
|
202
|
+
for (let index = 0; index < designs.length; index++) {
|
|
203
|
+
await fence();
|
|
204
|
+
const compilation = compileSpatialDesign(designs[index], options);
|
|
205
|
+
const assets = await captureCompilation(compilation, sourceRoot);
|
|
206
|
+
total += assets.reduce((sum, asset) => sum + asset.bytes.byteLength, 0);
|
|
207
|
+
if (total > MAX_GALLERY_BYTES) throw new CliError("invalid-data", "Design gallery exceeds 128 MiB.");
|
|
208
|
+
compiled.push({ variant: variants.variants[index]!, compilation, assets });
|
|
209
|
+
}
|
|
210
|
+
const directory = await reserveDirectory(root, command.outputDir, fence);
|
|
211
|
+
const rows = [];
|
|
212
|
+
for (const { variant, compilation, assets } of compiled) {
|
|
213
|
+
const child = await reserveDirectory(directory, variant.id, fence);
|
|
214
|
+
const bundle = await publishCompilation(child, compilation, assets, fence);
|
|
215
|
+
rows.push({ ...variant, bundle });
|
|
216
|
+
}
|
|
217
|
+
const gallery = { kind: "slopcamera.spatial-design-gallery", schemaVersion: 1, rendered: false, variants: rows };
|
|
218
|
+
await publishSpatialSource(join(directory, "gallery.json"), gallery, fence);
|
|
219
|
+
return { directory, gallery: join(directory, "gallery.json"), variants: rows, rendered: false,
|
|
220
|
+
sourceSha256: spatialValueSha256(design) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function executeSpatialDesignCommand(application: ApplicationContext, command: SpatialDesignCommand, signal?: AbortSignal): Promise<unknown> {
|
|
224
|
+
try { return await executeDesign(application, command, signal); }
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof CliError) throw error;
|
|
227
|
+
if (signal?.aborted) throw new CliError("cancelled", "Design command was cancelled; retain any partial bundle.");
|
|
228
|
+
if (error instanceof SlopcameraCodeError) throw new CliError(error.code, error.message, error.details);
|
|
229
|
+
if (error instanceof SyntaxError) throw new CliError("invalid-data", "Design input must be valid UTF-8 JSON.");
|
|
230
|
+
if (error instanceof Error && "code" in error) {
|
|
231
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") throw new CliError("not-found", "Design source, asset, or output parent does not exist.");
|
|
232
|
+
if (error.code === "ELOOP") throw new CliError("unsafe-path", "Design inputs and outputs must use physical paths without symlinks.");
|
|
233
|
+
if (error.code === "EEXIST") throw new CliError("conflict", "Design output already exists; choose a new path.");
|
|
234
|
+
}
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|