@playcraft/cli 0.1.60-beta → 0.1.62-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/commands/build.js +16 -7
  2. package/dist/project-skills/builtin-import.js +2 -2
  3. package/dist/skill-registry/messages.js +2 -2
  4. package/dist/skill-registry/platform-package.js +2 -4
  5. package/dist/utils/agent-api-client.js +22 -0
  6. package/dist/workspace-runtime/codex/app-server.js +24 -1
  7. package/dist/workspace-runtime/codex/config-toml.js +9 -5
  8. package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
  9. package/dist/workspace-runtime/codex/launcher-client.js +25 -13
  10. package/dist/workspace-runtime/codex/model-broker.js +13 -3
  11. package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
  12. package/dist/workspace-runtime/processes/skill-command.js +130 -0
  13. package/dist/workspace-runtime/serve.js +12 -1
  14. package/dist/workspace-runtime/server/accept-operation.js +10 -1
  15. package/dist/workspace-runtime/server/directory-access.js +11 -4
  16. package/dist/workspace-runtime/server/dispatch.js +70 -3
  17. package/dist/workspace-runtime/server/http.js +61 -3
  18. package/dist/workspace-runtime/server/operation-record.js +4 -1
  19. package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
  20. package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
  21. package/dist/workspace-runtime/workspaces/directories.js +4 -1
  22. package/dist/workspace-runtime/workspaces/empty-template.js +3 -1
  23. package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
  24. package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
  25. package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
  26. package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
  27. package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
  28. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-build.js +184 -0
  29. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-recovery.js +77 -0
  30. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-store.js +170 -0
  31. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-worker.js +85 -0
  32. package/dist/workspace-runtime/workspaces/procedural-3d/read-candidates.js +219 -0
  33. package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
  34. package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
  35. package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
  36. package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
  37. package/dist/workspace-runtime/workspaces/skills.js +15 -5
  38. package/package.json +4 -3
  39. package/project-template-v2/.playcraft/skills.lock.json +7 -0
  40. package/project-templates/empty/AGENTS.md +11 -1
@@ -0,0 +1,219 @@
1
+ import { lstat, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { workspaceModelCandidateListSchema, workspaceModelCandidateSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { checkedFile, dependencyHash, digest, relativeFile } from './files.js';
6
+ import { ModelPreviewStore } from './model-preview-store.js';
7
+ const hash = z.string().regex(/^[a-f0-9]{64}$/);
8
+ const fileSchema = z.object({ path: z.string().refine(value => {
9
+ try {
10
+ relativeFile(value);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }), hash });
17
+ const candidateSchema = z.object({ id: z.string().uuid(), files: z.array(fileSchema).min(1).max(256),
18
+ sourcePaths: z.array(z.string()).min(1).max(128), sourceHash: hash, dependencyHash: hash,
19
+ pagePath: z.string(), specHash: hash, passId: z.string().min(1).max(128),
20
+ captures: z.array(fileSchema.extend({ view: z.string().min(1).max(64) })).max(16).optional(),
21
+ hostReview: z.object({ profileHash: hash }).optional(),
22
+ review: z.object({ decision: z.enum(['accept', 'revise']), sourceHash: hash }).optional(),
23
+ adoptedPath: z.string().optional() });
24
+ const runSchema = z.object({ schemaVersion: z.literal(1), runId: z.string().uuid(),
25
+ revision: z.number().int().nonnegative(), root: z.string(), artifactHash: hash,
26
+ referenceHash: hash, referencePath: z.string(), candidates: z.array(z.unknown()) });
27
+ const MAX_RUNS = 64;
28
+ const MAX_CANDIDATES = 128;
29
+ const MAX_BYTES = 256 * 1024 * 1024;
30
+ /** Read control-owned snapshots without invoking the Skill or changing a completed run. */
31
+ export async function readProceduralModelCandidates(root, stateDir) {
32
+ const result = { schemaVersion: 1, candidates: [], diagnostics: [],
33
+ truncated: false, scannedAt: new Date().toISOString() };
34
+ const controlRelative = `procedural-3d/${digest(root)}`;
35
+ const controlDirectory = join(stateDir, controlRelative);
36
+ const diagnose = (code, runId, candidateId) => {
37
+ if (result.diagnostics.length < 256)
38
+ result.diagnostics.push({ code, ...(runId ? { runId } : {}), ...(candidateId ? { candidateId } : {}) });
39
+ else
40
+ result.truncated = true;
41
+ };
42
+ let names;
43
+ try {
44
+ const info = await lstat(controlDirectory);
45
+ if (!info.isDirectory() || info.isSymbolicLink())
46
+ throw new Error('model_candidates_control_invalid');
47
+ names = (await readdir(controlDirectory)).filter(name => /^[a-f0-9-]{36}\.json$/i.test(name)).sort();
48
+ }
49
+ catch (error) {
50
+ if (error.code !== 'ENOENT')
51
+ diagnose('model_candidates_control_unavailable');
52
+ return result;
53
+ }
54
+ if (names.length > MAX_RUNS) {
55
+ result.truncated = true;
56
+ diagnose('model_candidates_run_limit');
57
+ }
58
+ let inspectedBytes = 0;
59
+ let inspectedCandidates = 0;
60
+ const seenCandidates = new Set();
61
+ const readBounded = async (base, path) => {
62
+ if (inspectedBytes >= MAX_BYTES)
63
+ throw new Error('model_candidates_byte_limit');
64
+ const bytes = await checkedFile(base, path, Math.min(16 * 1024 * 1024, MAX_BYTES - inspectedBytes));
65
+ inspectedBytes += bytes.length;
66
+ return bytes;
67
+ };
68
+ let currentDependencyHash;
69
+ try {
70
+ currentDependencyHash = await dependencyHash(root);
71
+ }
72
+ catch { /* Each candidate remains visible with a diagnostic. */ }
73
+ for (const name of names.slice(0, MAX_RUNS)) {
74
+ const runId = name.slice(0, -5);
75
+ if (!z.string().uuid().safeParse(runId).success) {
76
+ diagnose('model_candidates_run_invalid');
77
+ continue;
78
+ }
79
+ let run;
80
+ try {
81
+ run = runSchema.parse(JSON.parse((await checkedFile(stateDir, `${controlRelative}/${name}`, 4 * 1024 * 1024)).toString('utf8')));
82
+ if (run.root !== root || run.runId !== runId)
83
+ throw new Error('model_candidates_scope_mismatch');
84
+ }
85
+ catch {
86
+ diagnose('model_candidates_run_invalid', runId);
87
+ continue;
88
+ }
89
+ const jobRelative = `.playcraft/procedural-3d/runs/${runId}`;
90
+ const jobRoot = join(root, jobRelative);
91
+ let referenceVerified = false;
92
+ try {
93
+ referenceVerified = digest(await readBounded(jobRoot, run.referencePath)) === run.referenceHash;
94
+ }
95
+ catch { /* Reported against every affected candidate, without exposing filesystem errors. */ }
96
+ for (const raw of run.candidates) {
97
+ if (inspectedCandidates++ >= MAX_CANDIDATES) {
98
+ result.truncated = true;
99
+ diagnose('model_candidates_candidate_limit', runId);
100
+ break;
101
+ }
102
+ if (result.candidates.length >= MAX_CANDIDATES) {
103
+ result.truncated = true;
104
+ diagnose('model_candidates_candidate_limit', runId);
105
+ break;
106
+ }
107
+ const parsed = candidateSchema.safeParse(raw);
108
+ if (!parsed.success) {
109
+ diagnose('model_candidates_candidate_invalid', runId);
110
+ continue;
111
+ }
112
+ const candidate = parsed.data;
113
+ if (seenCandidates.has(`${runId}:${candidate.id}`)) {
114
+ diagnose('model_candidates_candidate_duplicate', runId, candidate.id);
115
+ continue;
116
+ }
117
+ seenCandidates.add(`${runId}:${candidate.id}`);
118
+ const candidatePrefix = `${jobRelative}/candidates/${candidate.id}`;
119
+ const source = new Set(candidate.sourcePaths);
120
+ try {
121
+ relativeFile(candidate.pagePath);
122
+ if (candidate.adoptedPath)
123
+ relativeFile(candidate.adoptedPath);
124
+ if (new Set(candidate.files.map(file => file.path)).size !== candidate.files.length ||
125
+ new Set(candidate.captures?.map(file => file.path)).size !== (candidate.captures?.length ?? 0) ||
126
+ candidate.sourcePaths.some(path => !candidate.files.some(file => file.path === path)) ||
127
+ !candidate.files.some(file => file.path === candidate.pagePath) ||
128
+ candidate.captures?.some(file => !file.path.startsWith(`candidates/${candidate.id}/captures/`)))
129
+ throw new Error('model_candidates_manifest_invalid');
130
+ }
131
+ catch {
132
+ diagnose('model_candidates_manifest_invalid', runId, candidate.id);
133
+ continue;
134
+ }
135
+ const item = {
136
+ runId, candidateId: candidate.id, revision: run.revision, passId: candidate.passId,
137
+ sourceHash: candidate.sourceHash, dependencyHash: candidate.dependencyHash,
138
+ referenceHash: run.referenceHash, skillArtifactHash: run.artifactHash,
139
+ files: candidate.files.map(file => ({ path: `${candidatePrefix}/source/${file.path}`, contentHash: file.hash, source: source.has(file.path) })),
140
+ captures: (candidate.captures ?? []).map(file => ({ path: `${jobRelative}/${file.path}`, contentHash: file.hash, view: file.view })),
141
+ preview: { kind: 'scene-preview', status: 'needs-build', entryPath: `${candidatePrefix}/source/${candidate.pagePath}`,
142
+ sourceHash: candidate.sourceHash, dependencyHash: candidate.dependencyHash,
143
+ ...(candidate.hostReview ? { profileHash: candidate.hostReview.profileHash } : {}), reason: 'candidate_scene_not_built' },
144
+ integrity: 'verified', ...(candidate.review ? { review: candidate.review } : {}),
145
+ ...(candidate.adoptedPath ? { adoptedPath: candidate.adoptedPath } : {}),
146
+ };
147
+ const invalidate = (code) => {
148
+ item.integrity = 'invalid';
149
+ if (item.preview.kind === 'scene-preview') {
150
+ item.preview.status = 'unavailable';
151
+ item.preview.reason = code;
152
+ }
153
+ diagnose(code, runId, candidate.id);
154
+ };
155
+ if (!workspaceModelCandidateSchema.safeParse(item).success) {
156
+ diagnose('model_candidates_manifest_invalid', runId, candidate.id);
157
+ continue;
158
+ }
159
+ if (!referenceVerified)
160
+ invalidate('model_candidates_reference_changed');
161
+ if (currentDependencyHash !== candidate.dependencyHash)
162
+ invalidate('model_candidates_dependencies_changed');
163
+ if (digest(JSON.stringify(candidate.files.map(({ path, hash }) => ({ path, hash })))) !== candidate.sourceHash)
164
+ invalidate('model_candidates_source_hash_changed');
165
+ if (!candidate.captures?.length)
166
+ invalidate('model_candidates_captures_missing');
167
+ if (candidate.review && candidate.review.sourceHash !== candidate.sourceHash)
168
+ invalidate('model_candidates_review_changed');
169
+ try {
170
+ if (digest(await readBounded(root, `${candidatePrefix}/spec.json`)) !== candidate.specHash)
171
+ invalidate('model_candidates_spec_changed');
172
+ }
173
+ catch {
174
+ invalidate('model_candidates_spec_unavailable');
175
+ }
176
+ for (const file of [...item.files, ...item.captures]) {
177
+ try {
178
+ const bytes = await readBounded(root, file.path);
179
+ file.size = bytes.length;
180
+ if (digest(bytes) !== file.contentHash)
181
+ throw new Error('model_candidates_file_changed');
182
+ }
183
+ catch (error) {
184
+ const limit = inspectedBytes >= MAX_BYTES || (error instanceof Error && error.message === 'model_candidates_byte_limit');
185
+ invalidate(limit ? 'model_candidates_byte_limit' : 'model_candidates_file_unavailable');
186
+ if (limit) {
187
+ result.truncated = true;
188
+ break;
189
+ }
190
+ }
191
+ }
192
+ result.candidates.push(item);
193
+ if (item.integrity === 'verified' && item.preview.kind === 'scene-preview') {
194
+ try {
195
+ const versions = await new ModelPreviewStore(stateDir, root).versions(item), preview = versions[0];
196
+ item.previewVersions = versions.map(version => ({ previewId: version.previewId, contentHash: version.contentHash,
197
+ createdAt: version.createdAt, ...(version.parameters ? { parameters: version.parameters } : {}),
198
+ ...(version.parameterHash ? { parameterHash: version.parameterHash } : {}) }));
199
+ if (preview)
200
+ Object.assign(item.preview, { status: 'ready', reason: 'candidate_scene_built',
201
+ previewId: preview.previewId, contentHash: preview.contentHash,
202
+ ...(preview.parameters ? { parameters: preview.parameters } : {}),
203
+ ...(preview.parameterHash ? { parameterHash: preview.parameterHash } : {}) });
204
+ }
205
+ catch {
206
+ invalidate('model_preview_receipt_invalid');
207
+ }
208
+ }
209
+ if (inspectedBytes >= MAX_BYTES)
210
+ break;
211
+ }
212
+ if (inspectedBytes >= MAX_BYTES || inspectedCandidates >= MAX_CANDIDATES) {
213
+ result.truncated = true;
214
+ diagnose(inspectedBytes >= MAX_BYTES ? 'model_candidates_byte_limit' : 'model_candidates_candidate_limit', runId);
215
+ break;
216
+ }
217
+ }
218
+ return workspaceModelCandidateListSchema.parse(result);
219
+ }
@@ -0,0 +1,159 @@
1
+ import { createRequire } from 'node:module';
2
+ import { dirname, join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { chromium } from 'playwright-core';
6
+ import sharp from 'sharp';
7
+ import { digest } from './files.js';
8
+ import { canonicalJson, reviewProfileHash, validateRenderState, } from './review-contracts.js';
9
+ /** Launched as the development user; Vite and Chromium die with the managed process group. */
10
+ async function main() {
11
+ let raw = '';
12
+ for await (const chunk of process.stdin) {
13
+ raw += chunk;
14
+ if (raw.length > 64 * 1024)
15
+ throw new Error('render_input_limit');
16
+ }
17
+ const input = JSON.parse(raw);
18
+ if (input.reviewProfile &&
19
+ reviewProfileHash(input.reviewProfile.profile) !== input.reviewProfile.hash)
20
+ throw new Error('render_profile_mismatch');
21
+ const project = createRequire(join(input.projectRoot, 'package.json'));
22
+ const vite = await import(pathToFileURL(project.resolve('vite')).href);
23
+ const three = join(dirname(project.resolve('three')), 'three.module.js');
24
+ const threeVersion = JSON.parse(await readFile(join(dirname(three), '../package.json'), 'utf8')).version;
25
+ const server = await vite.createServer({
26
+ configFile: false,
27
+ root: input.sourceRoot,
28
+ logLevel: 'silent',
29
+ resolve: { alias: [{ find: /^three$/, replacement: three }] },
30
+ server: {
31
+ host: '127.0.0.1',
32
+ port: 0,
33
+ strictPort: false,
34
+ fs: { strict: true, allow: [input.sourceRoot, dirname(three)] },
35
+ },
36
+ });
37
+ let browser;
38
+ try {
39
+ await server.listen();
40
+ const address = server.httpServer.address();
41
+ const origin = `http://127.0.0.1:${address.port}`;
42
+ browser = await chromium.launch({
43
+ executablePath: process.env.PLAYCRAFT_CHROMIUM_PATH || '/usr/bin/chromium',
44
+ headless: true,
45
+ args: [
46
+ '--disable-dev-shm-usage',
47
+ '--enable-unsafe-swiftshader',
48
+ '--use-gl=angle',
49
+ '--use-angle=swiftshader',
50
+ ],
51
+ });
52
+ const context = await browser.newContext({
53
+ viewport: { width: 768, height: 768 },
54
+ deviceScaleFactor: 1,
55
+ serviceWorkers: 'block',
56
+ });
57
+ const errors = [];
58
+ await context.route('**/*', (route) => {
59
+ const url = route.request().url();
60
+ if (url === `${origin}/favicon.ico`)
61
+ return route.fulfill({ status: 204 });
62
+ return url.startsWith(`${origin}/`) || /^(data|blob):/.test(url)
63
+ ? route.continue()
64
+ : route.abort('blockedbyclient');
65
+ });
66
+ const page = await context.newPage();
67
+ page.on('pageerror', (error) => errors.push(error.message));
68
+ page.on('console', (message) => {
69
+ if (message.type() === 'error')
70
+ errors.push(message.text());
71
+ });
72
+ page.on('requestfailed', (request) => errors.push(`${request.url()}: ${request.failure()?.errorText}`));
73
+ await page.goto(`${origin}/${input.pagePath.split('/').map(encodeURIComponent).join('/')}`, {
74
+ waitUntil: 'networkidle',
75
+ timeout: 30_000,
76
+ });
77
+ await page.waitForFunction('Boolean(window.__PLAYCRAFT_SCULPT__?.ready)', undefined, {
78
+ timeout: 30_000,
79
+ });
80
+ await page.evaluate('Promise.resolve(window.__PLAYCRAFT_SCULPT__.ready)');
81
+ const renderer = await page.evaluate(`(() => {const c=document.querySelector('canvas'); const g=c?.getContext('webgl2')||c?.getContext('webgl'); if(!g) throw new Error('webgl_unavailable'); const e=g.getExtension('WEBGL_debug_renderer_info'); return {renderer:g.getParameter(e?e.UNMASKED_RENDERER_WEBGL:g.RENDERER),vendor:g.getParameter(e?e.UNMASKED_VENDOR_WEBGL:g.VENDOR)};})()`);
82
+ await mkdir(input.outputRoot, { recursive: true });
83
+ const captures = [];
84
+ const states = {};
85
+ const captureState = async (view, stripped = false) => {
86
+ if (!input.reviewProfile)
87
+ return;
88
+ states[view] = validateRenderState(await page.evaluate('window.__PLAYCRAFT_SCULPT__.getRenderState()'), input.reviewProfile.profile, view, stripped);
89
+ };
90
+ for (const view of input.views) {
91
+ await page.evaluate(`Promise.resolve(window.__PLAYCRAFT_SCULPT__.setView(${JSON.stringify(view)})).then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))`);
92
+ await page.screenshot({
93
+ path: join(input.outputRoot, `${view}.png`),
94
+ animations: 'disabled',
95
+ });
96
+ await captureState(view);
97
+ captures.push({ view, file: `${view}.png` });
98
+ }
99
+ await page.evaluate(`Promise.resolve(window.__PLAYCRAFT_SCULPT__.setView('hero')).then(()=>window.__PLAYCRAFT_SCULPT__.setMapStripped(true)).then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))`);
100
+ await page.screenshot({
101
+ path: join(input.outputRoot, 'map-stripped.png'),
102
+ animations: 'disabled',
103
+ });
104
+ captures.push({ view: 'map-stripped', file: 'map-stripped.png' });
105
+ await captureState('map-stripped', true);
106
+ const reference = await sharp(input.referencePath)
107
+ .resize(768, 768, { fit: 'contain', background: '#e9e9e9' })
108
+ .png()
109
+ .toBuffer();
110
+ const hero = await sharp(join(input.outputRoot, `${input.views.includes('hero') ? 'hero' : input.views[0]}.png`))
111
+ .resize(768, 768)
112
+ .png()
113
+ .toBuffer();
114
+ await sharp({ create: { width: 1536, height: 768, channels: 3, background: '#e9e9e9' } })
115
+ .composite([
116
+ { input: reference, left: 0, top: 0 },
117
+ { input: hero, left: 768, top: 0 },
118
+ ])
119
+ .png()
120
+ .toFile(join(input.outputRoot, 'comparison.png'));
121
+ captures.push({ view: 'comparison', file: 'comparison.png' });
122
+ let renderReceipt;
123
+ if (input.reviewProfile) {
124
+ const bytes = canonicalJson({
125
+ schemaVersion: 1,
126
+ reviewProfileHash: input.reviewProfile.hash,
127
+ profile: input.reviewProfile.profile,
128
+ views: states,
129
+ });
130
+ await writeFile(join(input.outputRoot, 'render-state.json'), bytes, { flag: 'wx' });
131
+ renderReceipt = { file: 'render-state.json', hash: digest(bytes) };
132
+ }
133
+ await page.evaluate('window.__PLAYCRAFT_SCULPT__.dispose?.()');
134
+ if (errors.length)
135
+ throw new Error(`render_errors: ${errors.join('\n').slice(0, 4000)}`);
136
+ return {
137
+ captures,
138
+ renderer,
139
+ browser: browser.version(),
140
+ node: process.version,
141
+ three: threeVersion,
142
+ vite: vite.version,
143
+ viewport: { width: 768, height: 768, deviceScaleFactor: 1 },
144
+ ready: true,
145
+ ...(renderReceipt ? { renderReceipt } : {}),
146
+ errors,
147
+ };
148
+ }
149
+ finally {
150
+ await browser?.close();
151
+ await server.close();
152
+ }
153
+ }
154
+ main()
155
+ .then((result) => process.stdout.write(JSON.stringify({ ok: true, result })))
156
+ .catch((error) => {
157
+ process.stderr.write(String(error));
158
+ process.exitCode = 1;
159
+ });
@@ -0,0 +1,257 @@
1
+ import { z } from 'zod';
2
+ import { digest, relativeFile } from './files.js';
3
+ export const REVIEW_DIRECTORY = '__playcraft-review';
4
+ export const REVIEW_PAGE = `${REVIEW_DIRECTORY}/index.html`;
5
+ export const REVIEW_VIEWS = ['front', 'right', 'back', 'left', 'hero'];
6
+ const finite = z.number().finite();
7
+ const coordinate = finite.min(-100000).max(100000);
8
+ const vector = z.tuple([coordinate, coordinate, coordinate]);
9
+ const projectedVector = z.tuple([finite, finite, finite]);
10
+ const matrix = z.array(finite).length(16);
11
+ export const reviewProfileSchema = z
12
+ .object({
13
+ schemaVersion: z.literal(1),
14
+ fovDegrees: finite.min(1).max(120),
15
+ near: finite.min(0.0001).max(1000),
16
+ far: finite.max(100000),
17
+ target: vector,
18
+ views: z
19
+ .object({ front: vector, right: vector, back: vector, left: vector, hero: vector })
20
+ .strict(),
21
+ background: z
22
+ .string()
23
+ .regex(/^#[a-fA-F0-9]{6}$/)
24
+ .transform((value) => value.toLowerCase()),
25
+ keyLightPosition: vector,
26
+ referenceCameraStatus: z.literal('estimated'),
27
+ })
28
+ .strict()
29
+ .superRefine((profile, context) => {
30
+ if (profile.far < profile.near + 0.0001)
31
+ context.addIssue({ code: z.ZodIssueCode.custom, message: 'invalid camera range' });
32
+ for (const position of Object.values(profile.views)) {
33
+ const distance = Math.hypot(...position.map((value, index) => value - profile.target[index]));
34
+ if (distance <= profile.near || distance >= profile.far)
35
+ context.addIssue({
36
+ code: z.ZodIssueCode.custom,
37
+ message: 'invalid camera target distance',
38
+ });
39
+ }
40
+ });
41
+ /** A bounded canonical JSON (JavaScript Object Notation, JavaScript对象表示法) encoding. */
42
+ export function canonicalJson(value) {
43
+ let nodes = 0;
44
+ const normalize = (input, depth = 0) => {
45
+ if (++nodes > 4096 || depth > 12)
46
+ throw new Error('procedural_skill_invalid_review_json');
47
+ if (input === null || typeof input === 'string' || typeof input === 'boolean')
48
+ return input;
49
+ if (typeof input === 'number' && Number.isFinite(input))
50
+ return input;
51
+ if (Array.isArray(input))
52
+ return input.map((item) => normalize(item, depth + 1));
53
+ if (input && typeof input === 'object' && Object.getPrototypeOf(input) === Object.prototype)
54
+ return Object.fromEntries(Object.keys(input)
55
+ .sort()
56
+ .map((key) => [key, normalize(input[key], depth + 1)]));
57
+ throw new Error('procedural_skill_invalid_review_json');
58
+ };
59
+ const encoded = JSON.stringify(normalize(value));
60
+ if (Buffer.byteLength(encoded) > 64 * 1024)
61
+ throw new Error('procedural_skill_invalid_review_json');
62
+ return encoded;
63
+ }
64
+ export const reviewProfileHash = (profile) => digest(canonicalJson(profile));
65
+ export const captureV2Schema = z
66
+ .object({
67
+ files: z.array(z.string()).min(1).max(252),
68
+ sourcePaths: z.array(z.string()).min(1).max(128),
69
+ views: z.array(z.enum(REVIEW_VIEWS)).min(3).max(5).default(['front', 'right', 'hero']),
70
+ review: z
71
+ .object({
72
+ factoryPath: z.string(),
73
+ exportName: z.string().regex(/^[A-Za-z_$][A-Za-z0-9_$]{0,79}$/),
74
+ parameters: z.record(z.unknown()).optional(),
75
+ profile: reviewProfileSchema,
76
+ })
77
+ .strict(),
78
+ })
79
+ .strict()
80
+ .superRefine((input, context) => {
81
+ const invalid = () => context.addIssue({ code: z.ZodIssueCode.custom, message: 'invalid controlled review input' });
82
+ if (new Set(input.views).size !== input.views.length || !input.views.includes('hero'))
83
+ invalid();
84
+ if (!input.sourcePaths.includes(input.review.factoryPath) ||
85
+ !/\.(?:[cm]?[jt]sx?)$/.test(input.review.factoryPath))
86
+ invalid();
87
+ if (input.files.some((path) => path.toLowerCase() === REVIEW_DIRECTORY ||
88
+ path.toLowerCase().startsWith(`${REVIEW_DIRECTORY}/`)))
89
+ invalid();
90
+ try {
91
+ relativeFile(input.review.factoryPath);
92
+ if (input.review.parameters &&
93
+ Buffer.byteLength(canonicalJson(input.review.parameters)) > 16 * 1024)
94
+ invalid();
95
+ }
96
+ catch {
97
+ invalid();
98
+ }
99
+ });
100
+ const renderStateSchema = z
101
+ .object({
102
+ schemaVersion: z.literal(1),
103
+ view: z.enum(REVIEW_VIEWS),
104
+ mapStripped: z.boolean(),
105
+ profile: reviewProfileSchema,
106
+ viewport: z
107
+ .object({ width: z.literal(768), height: z.literal(768), deviceScaleFactor: z.literal(1) })
108
+ .strict(),
109
+ camera: z
110
+ .object({
111
+ position: vector,
112
+ target: vector,
113
+ fovDegrees: finite,
114
+ near: finite,
115
+ far: finite,
116
+ projectionMatrix: matrix,
117
+ matrixWorld: matrix,
118
+ matrixWorldInverse: matrix,
119
+ })
120
+ .strict(),
121
+ modelBounds: z.object({ min: vector, max: vector }).strict(),
122
+ projectedBounds: z
123
+ .object({ min: projectedVector, max: projectedVector, conservative: z.literal(true) })
124
+ .strict()
125
+ .nullable(),
126
+ framing: z.enum(['contained', 'bounds-overflow', 'unknown']),
127
+ })
128
+ .strict();
129
+ const close = (a, b) => Math.abs(a - b) <= 1e-7 * Math.max(1, Math.abs(a), Math.abs(b));
130
+ const same = (a, b) => a.length === b.length && a.every((value, index) => close(value, b[index]));
131
+ const cross = (a, b) => [
132
+ a[1] * b[2] - a[2] * b[1],
133
+ a[2] * b[0] - a[0] * b[2],
134
+ a[0] * b[1] - a[1] * b[0],
135
+ ];
136
+ const norm = (a) => {
137
+ const length = Math.hypot(...a);
138
+ return a.map((value) => value / length);
139
+ };
140
+ const dot = (a, b) => a.reduce((sum, value, index) => sum + value * b[index], 0);
141
+ /** Independently check the actual helper state against the fixed camera, never fit model bounds. */
142
+ export function validateRenderState(value, profile, view, stripped = false) {
143
+ const state = renderStateSchema.parse(value);
144
+ const expectedView = stripped ? 'hero' : view;
145
+ const position = profile.views[expectedView];
146
+ const fail = () => {
147
+ throw new Error('procedural_skill_review_state_mismatch');
148
+ };
149
+ if (!position ||
150
+ state.view !== expectedView ||
151
+ state.mapStripped !== stripped ||
152
+ canonicalJson(state.profile) !== canonicalJson(profile) ||
153
+ !same(state.camera.position, position) ||
154
+ !same(state.camera.target, profile.target) ||
155
+ !close(state.camera.fovDegrees, profile.fovDegrees) ||
156
+ !close(state.camera.near, profile.near) ||
157
+ !close(state.camera.far, profile.far))
158
+ fail();
159
+ let zAxis = norm(position.map((value, index) => value - profile.target[index]));
160
+ if (Math.hypot(...cross([0, 1, 0], zAxis)) === 0) {
161
+ zAxis[2] += 0.0001;
162
+ zAxis = norm(zAxis);
163
+ }
164
+ const xAxis = norm(cross([0, 1, 0], zAxis)), yAxis = cross(zAxis, xAxis);
165
+ const world = [...xAxis, 0, ...yAxis, 0, ...zAxis, 0, ...position, 1];
166
+ const inverse = [
167
+ xAxis[0],
168
+ yAxis[0],
169
+ zAxis[0],
170
+ 0,
171
+ xAxis[1],
172
+ yAxis[1],
173
+ zAxis[1],
174
+ 0,
175
+ xAxis[2],
176
+ yAxis[2],
177
+ zAxis[2],
178
+ 0,
179
+ -dot(xAxis, position),
180
+ -dot(yAxis, position),
181
+ -dot(zAxis, position),
182
+ 1,
183
+ ];
184
+ const scale = 1 / Math.tan((profile.fovDegrees * Math.PI) / 360);
185
+ const projection = [
186
+ scale,
187
+ 0,
188
+ 0,
189
+ 0,
190
+ 0,
191
+ scale,
192
+ 0,
193
+ 0,
194
+ 0,
195
+ 0,
196
+ -(profile.far + profile.near) / (profile.far - profile.near),
197
+ -1,
198
+ 0,
199
+ 0,
200
+ (-2 * profile.far * profile.near) / (profile.far - profile.near),
201
+ 0,
202
+ ];
203
+ if (!same(state.camera.matrixWorld, world) ||
204
+ !same(state.camera.matrixWorldInverse, inverse) ||
205
+ !same(state.camera.projectionMatrix, projection))
206
+ fail();
207
+ if (state.modelBounds.min.some((entry, index) => entry > state.modelBounds.max[index]))
208
+ fail();
209
+ const multiply = (m, p) => [0, 1, 2, 3].map((row) => p.reduce((sum, entry, column) => sum + m[column * 4 + row] * entry, 0));
210
+ const points = [];
211
+ for (const x of [state.modelBounds.min[0], state.modelBounds.max[0]])
212
+ for (const y of [state.modelBounds.min[1], state.modelBounds.max[1]])
213
+ for (const z of [state.modelBounds.min[2], state.modelBounds.max[2]]) {
214
+ const clip = multiply(projection, multiply(inverse, [x, y, z, 1]));
215
+ if (!clip.every(Number.isFinite) || clip[3] <= 1e-9) {
216
+ if (state.projectedBounds !== null || state.framing !== 'unknown')
217
+ fail();
218
+ return state;
219
+ }
220
+ points.push(clip.slice(0, 3).map((entry) => entry / clip[3]));
221
+ }
222
+ const min = [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis])));
223
+ const max = [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis])));
224
+ const framing = min.every((value) => value >= -1) && max.every((value) => value <= 1)
225
+ ? 'contained'
226
+ : 'bounds-overflow';
227
+ if (!state.projectedBounds ||
228
+ !same(state.projectedBounds.min, min) ||
229
+ !same(state.projectedBounds.max, max) ||
230
+ state.framing !== framing)
231
+ fail();
232
+ return state;
233
+ }
234
+ export function validateRenderReceipt(value, profile, views) {
235
+ const receipt = z
236
+ .object({
237
+ schemaVersion: z.literal(1),
238
+ reviewProfileHash: z.string().regex(/^[a-f0-9]{64}$/),
239
+ profile: reviewProfileSchema,
240
+ views: z.record(z.unknown()),
241
+ })
242
+ .strict()
243
+ .parse(value);
244
+ const expected = [...views, 'map-stripped'];
245
+ if (receipt.reviewProfileHash !== reviewProfileHash(profile) ||
246
+ canonicalJson(receipt.profile) !== canonicalJson(profile) ||
247
+ Object.keys(receipt.views).length !== expected.length ||
248
+ expected.some((view) => !Object.hasOwn(receipt.views, view)))
249
+ throw new Error('procedural_skill_review_receipt_mismatch');
250
+ return {
251
+ ...receipt,
252
+ views: Object.fromEntries(expected.map((view) => [
253
+ view,
254
+ validateRenderState(receipt.views[view], profile, view, view === 'map-stripped'),
255
+ ])),
256
+ };
257
+ }
@@ -0,0 +1,26 @@
1
+ import { canonicalJson, REVIEW_DIRECTORY, REVIEW_PAGE, } from './review-contracts.js';
2
+ const literal = (value) => JSON.stringify(value)
3
+ .replace(/</g, '\\u003c')
4
+ .replace(/\u2028/g, '\\u2028')
5
+ .replace(/\u2029/g, '\\u2029');
6
+ /** Only locked helpers and declarative factory data enter the host-owned review page. */
7
+ export function createHostReviewFiles(review, helpers) {
8
+ const factory = `/${review.factoryPath.split('/').map(encodeURIComponent).join('/')}`;
9
+ const profile = canonicalJson(review.profile);
10
+ const page = `<!doctype html><html><head><meta charset="utf-8"><base href="/"><style>html,body{margin:0;width:768px;height:768px;overflow:hidden}</style></head><body><script type="module">
11
+ import { createReviewScene } from '/${REVIEW_DIRECTORY}/review-scene.mjs';
12
+ import * as factory from ${literal(factory)};
13
+ const createModel = factory[${literal(review.exportName)}];
14
+ if (typeof createModel !== 'function') throw new Error('review_factory_missing');
15
+ const parameters = JSON.parse(${literal(canonicalJson(review.parameters ?? {}))});
16
+ const profile = JSON.parse(${literal(profile)});
17
+ const api = createReviewScene(() => createModel(parameters), { profile });
18
+ Object.defineProperty(window, '__PLAYCRAFT_SCULPT__', { value: api, writable: false, configurable: false });
19
+ </script></body></html>`;
20
+ return [
21
+ { path: REVIEW_PAGE, bytes: Buffer.from(page) },
22
+ { path: `${REVIEW_DIRECTORY}/review-scene.mjs`, bytes: helpers.scene },
23
+ { path: `${REVIEW_DIRECTORY}/review-profile.mjs`, bytes: helpers.profile },
24
+ { path: `${REVIEW_DIRECTORY}/profile.json`, bytes: Buffer.from(profile) },
25
+ ];
26
+ }