@playcraft/cli 0.1.63 → 0.1.65
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 +24 -31
- package/SKILLS_REGISTRY.md +1 -1
- package/dist/commands/3d.js +41 -33
- package/dist/commands/build.js +70 -5
- package/dist/commands/create.js +4 -4
- package/dist/commands/inspect.js +4 -6
- package/dist/index.js +27 -19
- package/dist/model-adaptation/node-inspection.js +173 -0
- package/dist/model-adaptation/project-template.js +194 -0
- package/dist/model-adaptation/project.js +76 -0
- package/dist/model-adaptation/source.js +49 -0
- package/dist/model-optimization/optimize.js +281 -0
- package/dist/model-preparation/bindings.js +35 -0
- package/dist/model-preparation/commands.js +28 -0
- package/dist/model-preparation/geometry.js +241 -0
- package/dist/model-preparation/glb.js +176 -0
- package/dist/model-preparation/pipeline-engine.js +157 -0
- package/dist/model-preparation/pipeline-files.js +108 -0
- package/dist/model-preparation/pipeline-worker.js +32 -0
- package/dist/model-preparation/split-rigid.js +302 -0
- package/dist/model-preparation/vertex-repairs.js +63 -0
- package/dist/workspace-runtime/batch/deliver.js +2 -2
- package/dist/workspace-runtime/codex/app-server.js +11 -1
- package/dist/workspace-runtime/codex/delivery-tools.js +38 -5
- package/dist/workspace-runtime/codex/model-broker.js +19 -1
- package/dist/workspace-runtime/previews/publish.js +2 -2
- package/dist/workspace-runtime/serve.js +5 -0
- package/dist/workspace-runtime/server/directory-access.js +1 -1
- package/dist/workspace-runtime/server/dispatch.js +27 -47
- package/dist/workspace-runtime/server/http.js +12 -0
- package/dist/workspace-runtime/workspaces/build-isolation.js +75 -0
- package/dist/workspace-runtime/workspaces/model-build-inputs.js +27 -0
- package/dist/workspace-runtime/workspaces/model-preparation-store.js +134 -0
- package/dist/workspace-runtime/workspaces/model-preparation.js +357 -0
- package/dist/workspace-runtime/workspaces/platform-export.js +1 -0
- package/package.json +6 -4
- package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
- package/project-templates/empty/AGENTS.md +1 -1
- package/project-templates/phaser/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
- package/project-templates/threejs/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
- package/dist/workspace-runtime/workspaces/delivery-build.js +0 -440
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { readModelSourceDocument } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { add, cross, dot, f32, length, mul, unit } from './geometry.js';
|
|
4
|
+
export const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
5
|
+
const identity = () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
6
|
+
function matrixMultiply(a, b) {
|
|
7
|
+
return Array.from({ length: 16 }, (_, i) => { const r = i % 4, c = Math.floor(i / 4); return [0, 1, 2, 3].reduce((sum, k) => sum + a[k * 4 + r] * b[c * 4 + k], 0); });
|
|
8
|
+
}
|
|
9
|
+
function nodeMatrix(node) {
|
|
10
|
+
if (node.matrix) {
|
|
11
|
+
if (!Array.isArray(node.matrix) || node.matrix.length !== 16 || !node.matrix.every(Number.isFinite))
|
|
12
|
+
throw Error('model_preparation_transform_invalid');
|
|
13
|
+
return node.matrix;
|
|
14
|
+
}
|
|
15
|
+
const t = node.translation ?? [0, 0, 0], s = node.scale ?? [1, 1, 1], q = node.rotation ?? [0, 0, 0, 1];
|
|
16
|
+
if (t.length !== 3 || s.length !== 3 || q.length !== 4 || ![...t, ...s, ...q].every(Number.isFinite) || Math.abs(Math.hypot(...q) - 1) > 1e-4)
|
|
17
|
+
throw Error('model_preparation_transform_invalid');
|
|
18
|
+
const [x, y, z, w] = q;
|
|
19
|
+
return [(1 - 2 * (y * y + z * z)) * s[0], 2 * (x * y + z * w) * s[0], 2 * (x * z - y * w) * s[0], 0,
|
|
20
|
+
2 * (x * y - z * w) * s[1], (1 - 2 * (x * x + z * z)) * s[1], 2 * (y * z + x * w) * s[1], 0,
|
|
21
|
+
2 * (x * z + y * w) * s[2], 2 * (y * z - x * w) * s[2], (1 - 2 * (x * x + y * y)) * s[2], 0,
|
|
22
|
+
t[0], t[1], t[2], 1];
|
|
23
|
+
}
|
|
24
|
+
export function readRigidGlb(bytes) {
|
|
25
|
+
const parsed = readModelSourceDocument(bytes), d = parsed.document, binary = parsed.binary;
|
|
26
|
+
if (!binary || parsed.extraChunks?.length || d.asset?.version !== '2.0' || d.buffers?.length !== 1 || d.buffers.some((b) => b.uri) ||
|
|
27
|
+
d.animations?.length || d.skins?.length || d.meshes?.length !== 1 || !Array.isArray(d.nodes) || d.nodes.length > 256)
|
|
28
|
+
throw Error('model_preparation_single_rigid_glb_required');
|
|
29
|
+
const primitive = d.meshes[0].primitives?.[0];
|
|
30
|
+
if (d.meshes[0].primitives?.length !== 1 || !primitive || primitive.targets || primitive.extensions || (primitive.mode ?? 4) !== 4 ||
|
|
31
|
+
Object.keys(primitive.attributes ?? {}).some(key => !['POSITION', 'NORMAL', 'TEXCOORD_0'].includes(key)))
|
|
32
|
+
throw Error('model_preparation_attributes_unsupported');
|
|
33
|
+
let world;
|
|
34
|
+
const visited = new Set();
|
|
35
|
+
const walk = (id, parent) => {
|
|
36
|
+
if (!Number.isSafeInteger(id) || visited.has(id) || !d.nodes[id])
|
|
37
|
+
throw Error('model_preparation_node_graph_invalid');
|
|
38
|
+
visited.add(id);
|
|
39
|
+
const node = d.nodes[id];
|
|
40
|
+
if (node.skin !== undefined || node.extensions)
|
|
41
|
+
throw Error('model_preparation_node_unsupported');
|
|
42
|
+
const matrix = matrixMultiply(parent, nodeMatrix(node));
|
|
43
|
+
if (node.mesh !== undefined) {
|
|
44
|
+
if (world || node.mesh !== 0)
|
|
45
|
+
throw Error('model_preparation_single_instance_required');
|
|
46
|
+
world = matrix;
|
|
47
|
+
}
|
|
48
|
+
for (const child of node.children ?? [])
|
|
49
|
+
walk(child, matrix);
|
|
50
|
+
};
|
|
51
|
+
for (const node of d.scenes?.[d.scene ?? 0]?.nodes ?? [])
|
|
52
|
+
walk(node, identity());
|
|
53
|
+
if (!world)
|
|
54
|
+
throw Error('model_preparation_empty_scene');
|
|
55
|
+
const m = world, axes = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]], sizes = axes.map(length);
|
|
56
|
+
const determinant = dot(axes[0], cross(axes[1], axes[2]));
|
|
57
|
+
if (!m.every(Number.isFinite) || Math.abs(m[3]) + Math.abs(m[7]) + Math.abs(m[11]) + Math.abs(m[15] - 1) > 1e-9 || determinant <= 0 ||
|
|
58
|
+
Math.min(...sizes) < 1e-12 || Math.max(...sizes) / Math.min(...sizes) > 1.00001 ||
|
|
59
|
+
Math.abs(dot(axes[0], axes[1])) + Math.abs(dot(axes[1], axes[2])) + Math.abs(dot(axes[2], axes[0])) > sizes[0] ** 2 * 1e-5)
|
|
60
|
+
throw Error('model_preparation_positive_rigid_transform_required');
|
|
61
|
+
function accessor(id, components, allowed) {
|
|
62
|
+
const a = d.accessors?.[id], v = d.bufferViews?.[a?.bufferView], bytesPer = a?.componentType === 5126 || a?.componentType === 5125 ? 4 : 2;
|
|
63
|
+
if (!a || a.sparse || a.normalized || !allowed.includes(a.componentType) || a.type !== (components === 3 ? 'VEC3' : components === 2 ? 'VEC2' : 'SCALAR') ||
|
|
64
|
+
!Number.isSafeInteger(a.count) || a.count < 1 || a.count > 600000 || !v || v.buffer !== 0 || v.extensions)
|
|
65
|
+
throw Error('model_preparation_accessor_unsupported');
|
|
66
|
+
const start = (v.byteOffset ?? 0) + (a.byteOffset ?? 0), stride = v.byteStride ?? components * bytesPer;
|
|
67
|
+
if (![start, stride, v.byteLength].every(Number.isSafeInteger) || start < 0 || stride < components * bytesPer ||
|
|
68
|
+
start + (a.count - 1) * stride + components * bytesPer > (v.byteOffset ?? 0) + v.byteLength || (v.byteOffset ?? 0) + v.byteLength > binary.length)
|
|
69
|
+
throw Error('model_preparation_accessor_bound');
|
|
70
|
+
const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength);
|
|
71
|
+
return Array.from({ length: a.count }, (_, i) => Array.from({ length: components }, (_, k) => {
|
|
72
|
+
const offset = start + i * stride + k * bytesPer, value = a.componentType === 5126 ? view.getFloat32(offset, true) : bytesPer === 4 ? view.getUint32(offset, true) : view.getUint16(offset, true);
|
|
73
|
+
if (!Number.isFinite(value))
|
|
74
|
+
throw Error('model_preparation_nonfinite_accessor');
|
|
75
|
+
return value;
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
const p = accessor(primitive.attributes.POSITION, 3, [5126]), n = accessor(primitive.attributes.NORMAL, 3, [5126]), uv = accessor(primitive.attributes.TEXCOORD_0, 2, [5126]);
|
|
79
|
+
const indices = accessor(primitive.indices, 1, [5123, 5125]).flat();
|
|
80
|
+
if (p.length !== n.length || p.length !== uv.length || indices.length % 3 || indices.length / 3 > 100000 || indices.some(i => i >= p.length))
|
|
81
|
+
throw Error('model_preparation_primitive_bound');
|
|
82
|
+
const transformVector = (v) => add(add(mul(axes[0], v[0]), mul(axes[1], v[1])), mul(axes[2], v[2]));
|
|
83
|
+
const faces = [];
|
|
84
|
+
for (let i = 0; i < indices.length; i += 3)
|
|
85
|
+
faces.push({ sourceTriangle: i / 3, kind: 'exterior', v: [0, 1, 2].map(k => {
|
|
86
|
+
const id = indices[i + k];
|
|
87
|
+
return { p: f32(add(transformVector(p[id]), [m[12], m[13], m[14]])), n: unit(transformVector(n[id])), uv: uv[id],
|
|
88
|
+
bary: [+(k === 0), +(k === 1), +(k === 2)] };
|
|
89
|
+
}) });
|
|
90
|
+
const textures = (d.images ?? []).map((image) => {
|
|
91
|
+
const v = d.bufferViews?.[image.bufferView];
|
|
92
|
+
if (image.uri || !v || v.buffer !== 0 || !['image/png', 'image/jpeg', 'image/webp'].includes(image.mimeType) || v.byteOffset < 0 || v.byteOffset + v.byteLength > binary.length)
|
|
93
|
+
throw Error('model_preparation_embedded_image_required');
|
|
94
|
+
const data = binary.slice(v.byteOffset ?? 0, (v.byteOffset ?? 0) + v.byteLength);
|
|
95
|
+
return { image, bytes: data, contentHash: digest(data) };
|
|
96
|
+
});
|
|
97
|
+
if (!d.materials?.[primitive.material])
|
|
98
|
+
throw Error('model_preparation_material_required');
|
|
99
|
+
const positionAccessor = d.accessors[primitive.attributes.POSITION], positionView = d.bufferViews[positionAccessor.bufferView];
|
|
100
|
+
const binaryOffset = 28 + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(12, true);
|
|
101
|
+
const positionByteOffsets = p.map((_, i) => binaryOffset + (positionView.byteOffset ?? 0) + (positionAccessor.byteOffset ?? 0) + i * (positionView.byteStride ?? 12));
|
|
102
|
+
return { faces, document: d, textures, materialIndex: primitive.material, sourceWorldMatrix: m,
|
|
103
|
+
localPositions: p, sourceIndices: indices, positionByteOffsets };
|
|
104
|
+
}
|
|
105
|
+
export function encodeSplitGlb(source, parts, parentHash) {
|
|
106
|
+
const chunks = [], bufferViews = [], accessors = [];
|
|
107
|
+
let byteOffset = 0;
|
|
108
|
+
const append = (bytes, target) => {
|
|
109
|
+
const id = bufferViews.length;
|
|
110
|
+
bufferViews.push({ buffer: 0, byteOffset, byteLength: bytes.length, ...(target ? { target } : {}) });
|
|
111
|
+
const padded = new Uint8Array(Math.ceil(bytes.length / 4) * 4);
|
|
112
|
+
padded.set(bytes);
|
|
113
|
+
chunks.push(padded);
|
|
114
|
+
byteOffset += padded.length;
|
|
115
|
+
return id;
|
|
116
|
+
};
|
|
117
|
+
const images = source.textures.map(t => ({ ...t.image, bufferView: append(t.bytes) }));
|
|
118
|
+
const materials = [...source.document.materials, { name: 'authored_untextured_wood_interior', doubleSided: false,
|
|
119
|
+
pbrMetallicRoughness: { baseColorFactor: [.18, .095, .035, 1], metallicFactor: 0, roughnessFactor: .9 } }];
|
|
120
|
+
const attribute = (array, type, components, target) => {
|
|
121
|
+
const id = accessors.length, view = append(new Uint8Array(array.buffer), target), count = array.length / components;
|
|
122
|
+
const min = Array.from({ length: components }, () => Infinity), max = min.map(() => -Infinity);
|
|
123
|
+
for (let i = 0; i < array.length; i++) {
|
|
124
|
+
const k = i % components;
|
|
125
|
+
min[k] = Math.min(min[k], array[i]);
|
|
126
|
+
max[k] = Math.max(max[k], array[i]);
|
|
127
|
+
}
|
|
128
|
+
accessors.push({ bufferView: view, componentType: array instanceof Float32Array ? 5126 : 5125, count, type, min, max });
|
|
129
|
+
return id;
|
|
130
|
+
};
|
|
131
|
+
const primitiveFaceCounts = [];
|
|
132
|
+
const meshes = parts.map(part => {
|
|
133
|
+
const groups = [part.faces.filter(f => ['exterior', 'auxiliary'].includes(f.kind)), part.faces.filter(f => ['interior', 'rim'].includes(f.kind))];
|
|
134
|
+
primitiveFaceCounts.push({ exterior: groups[0].length, interiorAndRim: groups[1].length });
|
|
135
|
+
return { name: `split_${part.role}_mesh`, primitives: groups.filter(fs => fs.length).map((fs, group) => {
|
|
136
|
+
const positions = [], normals = [], uvs = [], indices = [], seen = new Map();
|
|
137
|
+
for (const face of fs)
|
|
138
|
+
for (const v of face.v) {
|
|
139
|
+
const p = f32(v.p), n = f32(v.n), uv = v.uv.map(Math.fround), key = [...p, ...n, ...uv].join(',');
|
|
140
|
+
let id = seen.get(key);
|
|
141
|
+
if (id === undefined) {
|
|
142
|
+
id = positions.length / 3;
|
|
143
|
+
seen.set(key, id);
|
|
144
|
+
positions.push(...p);
|
|
145
|
+
normals.push(...n);
|
|
146
|
+
uvs.push(...uv);
|
|
147
|
+
}
|
|
148
|
+
indices.push(id);
|
|
149
|
+
}
|
|
150
|
+
return { attributes: { POSITION: attribute(new Float32Array(positions), 'VEC3', 3, 34962), NORMAL: attribute(new Float32Array(normals), 'VEC3', 3, 34962),
|
|
151
|
+
TEXCOORD_0: attribute(new Float32Array(uvs), 'VEC2', 2, 34962) }, indices: attribute(new Uint32Array(indices), 'SCALAR', 1, 34963), material: group ? materials.length - 1 : source.materialIndex };
|
|
152
|
+
}) };
|
|
153
|
+
});
|
|
154
|
+
const document = { asset: { ...source.document.asset, generator: 'PlayCraft restricted rigid shell preparation' }, buffers: [{ byteLength: byteOffset }], bufferViews, accessors,
|
|
155
|
+
images, textures: source.document.textures ?? [], samplers: source.document.samplers ?? [], materials, meshes,
|
|
156
|
+
nodes: parts.map((part, mesh) => ({ name: `split_${part.role}`, mesh, extras: { playcraftPart: part.role } })), scenes: [{ nodes: parts.map((_, i) => i) }], scene: 0,
|
|
157
|
+
...(source.document.extensionsUsed ? { extensionsUsed: source.document.extensionsUsed } : {}),
|
|
158
|
+
...(source.document.extensionsRequired ? { extensionsRequired: source.document.extensionsRequired } : {}), extras: { playcraftParentSourceHash: parentHash, preparation: 'restricted-rigid-shell-v1' } };
|
|
159
|
+
const json = new TextEncoder().encode(JSON.stringify(document)), jsonLength = Math.ceil(json.length / 4) * 4;
|
|
160
|
+
const output = new Uint8Array(12 + 8 + jsonLength + 8 + byteOffset), view = new DataView(output.buffer);
|
|
161
|
+
view.setUint32(0, 0x46546c67, true);
|
|
162
|
+
view.setUint32(4, 2, true);
|
|
163
|
+
view.setUint32(8, output.length, true);
|
|
164
|
+
view.setUint32(12, jsonLength, true);
|
|
165
|
+
view.setUint32(16, 0x4e4f534a, true);
|
|
166
|
+
output.fill(32, 20, 20 + jsonLength);
|
|
167
|
+
output.set(json, 20);
|
|
168
|
+
view.setUint32(20 + jsonLength, byteOffset, true);
|
|
169
|
+
view.setUint32(24 + jsonLength, 0x004e4942, true);
|
|
170
|
+
let offset = 28 + jsonLength;
|
|
171
|
+
for (const chunk of chunks) {
|
|
172
|
+
output.set(chunk, offset);
|
|
173
|
+
offset += chunk.length;
|
|
174
|
+
}
|
|
175
|
+
return { bytes: output, document, primitiveFaceCounts };
|
|
176
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { modelPreparationPayloadSchema, workspaceModelAdaptationSchema, canonicalModelAdaptation } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { inspectModelSourceFiles } from '../model-adaptation/source.js';
|
|
6
|
+
import { generateModelAdaptationProject } from '../model-adaptation/project.js';
|
|
7
|
+
import { inspectRigidModel } from '../model-adaptation/node-inspection.js';
|
|
8
|
+
import { applyRigidVertexRepairs } from './vertex-repairs.js';
|
|
9
|
+
import { prepareRigidSplit } from './split-rigid.js';
|
|
10
|
+
import { optimizeModel } from '../model-optimization/optimize.js';
|
|
11
|
+
import { hash, hashData, preparationInventory, readPreparationFile, readPreparationJson, writePreparationJson } from './pipeline-files.js';
|
|
12
|
+
/** Deterministic stages only. All destinations are fresh; prior incomplete attempts remain available. */
|
|
13
|
+
export async function runPreparationEngine(input) {
|
|
14
|
+
const payload = modelPreparationPayloadSchema.parse(input.payload), { resumeFromReceiptHash: _resume, ...request } = payload;
|
|
15
|
+
const inputHash = hashData(request);
|
|
16
|
+
await mkdir(input.outputRoot, { recursive: true });
|
|
17
|
+
const checkpoint = await readPreparationJson(input.outputRoot, 'checkpoint.json') ?? {
|
|
18
|
+
schemaVersion: 1, inputHash, toolHash: input.toolHash, stages: {},
|
|
19
|
+
};
|
|
20
|
+
if (checkpoint.schemaVersion !== 1 || checkpoint.inputHash !== inputHash || checkpoint.toolHash !== input.toolHash)
|
|
21
|
+
throw Error('model_prepare_checkpoint_identity_changed');
|
|
22
|
+
const stage = async (name, identity, action) => {
|
|
23
|
+
input.signal?.throwIfAborted();
|
|
24
|
+
const stageHash = hashData({ inputHash, toolHash: input.toolHash, identity });
|
|
25
|
+
const prior = checkpoint.stages[name];
|
|
26
|
+
if (prior) {
|
|
27
|
+
if (prior.inputHash !== stageHash || !new RegExp(`^${name}-[a-f0-9-]{36}$`).test(prior.directory))
|
|
28
|
+
throw Error('model_prepare_checkpoint_identity_changed');
|
|
29
|
+
const actual = await preparationInventory(join(input.outputRoot, prior.directory));
|
|
30
|
+
if (canonicalModelAdaptation(actual) !== canonicalModelAdaptation(prior.files))
|
|
31
|
+
throw Error('model_prepare_checkpoint_output_changed');
|
|
32
|
+
return join(input.outputRoot, prior.directory);
|
|
33
|
+
}
|
|
34
|
+
const relative = `${name}-${randomUUID()}`, directory = join(input.outputRoot, relative);
|
|
35
|
+
await mkdir(directory);
|
|
36
|
+
await action(directory);
|
|
37
|
+
input.signal?.throwIfAborted();
|
|
38
|
+
const files = await preparationInventory(directory);
|
|
39
|
+
checkpoint.stages[name] = { inputHash: stageHash, directory: relative, files };
|
|
40
|
+
await writePreparationJson(join(input.outputRoot, 'checkpoint.json'), checkpoint, true);
|
|
41
|
+
return directory;
|
|
42
|
+
};
|
|
43
|
+
const observed = await inspectModelSourceFiles({ projectRoot: input.sourceRoot, modelPath: payload.source.path, signal: input.signal });
|
|
44
|
+
if (canonicalModelAdaptation({ ...observed.source, files: [...observed.source.files].sort((a, b) => a.path.localeCompare(b.path)) }) !==
|
|
45
|
+
canonicalModelAdaptation({ ...payload.source, files: [...payload.source.files].sort((a, b) => a.path.localeCompare(b.path)) }))
|
|
46
|
+
throw Error('model_prepare_source_identity_mismatch');
|
|
47
|
+
if (observed.metadata.unsupported.length)
|
|
48
|
+
throw Error('model_prepare_rigid_source_required');
|
|
49
|
+
if (payload.recipe.adaptation && canonicalModelAdaptation(payload.recipe.adaptation.source) !== canonicalModelAdaptation(payload.source))
|
|
50
|
+
throw Error('model_prepare_adaptation_source_mismatch');
|
|
51
|
+
if (payload.recipe.repair && (!/\.glb$/i.test(payload.source.path) || payload.source.files.length !== 1))
|
|
52
|
+
throw Error('model_prepare_repair_glb_required');
|
|
53
|
+
const initial = await stage('inspect', payload.source, async (directory) => {
|
|
54
|
+
await writeFile(join(directory, 'model.glb'), observed.packed, { flag: 'wx' });
|
|
55
|
+
await writeFile(join(directory, 'source-inspection.json'), JSON.stringify({ source: observed.source, packedHash: observed.packedHash, metadata: observed.metadata }) + '\n', { flag: 'wx' });
|
|
56
|
+
});
|
|
57
|
+
let current = join(initial, 'model.glb');
|
|
58
|
+
const lineage = [{ name: 'inspect', directory: initial }];
|
|
59
|
+
if (payload.recipe.repair) {
|
|
60
|
+
const bytes = new Uint8Array(await readFile(current)), recipe = { ...payload.recipe.repair, weldedPositionIdentity: 'exact-local-float32-lexicographic-v1' };
|
|
61
|
+
const repaired = await stage('repair', { parentHash: hash(bytes), recipe }, async (directory) => {
|
|
62
|
+
const result = applyRigidVertexRepairs(bytes, recipe);
|
|
63
|
+
await writeFile(join(directory, 'model.glb'), result.bytes, { flag: 'wx' });
|
|
64
|
+
await writeFile(join(directory, 'repair-manifest.json'), JSON.stringify(result.manifest) + '\n', { flag: 'wx' });
|
|
65
|
+
});
|
|
66
|
+
current = join(repaired, 'model.glb');
|
|
67
|
+
lineage.push({ name: 'repair', directory: repaired });
|
|
68
|
+
}
|
|
69
|
+
let splitManifest;
|
|
70
|
+
if (payload.recipe.split) {
|
|
71
|
+
const bytes = new Uint8Array(await readFile(current));
|
|
72
|
+
const split = await stage('split', { parentHash: hash(bytes), recipe: payload.recipe.split }, async (directory) => {
|
|
73
|
+
const result = prepareRigidSplit(bytes, payload.recipe.split, input.signal);
|
|
74
|
+
await writeFile(join(directory, 'model.glb'), result.bytes, { flag: 'wx' });
|
|
75
|
+
await writeFile(join(directory, 'split-manifest.json'), JSON.stringify(result.manifest) + '\n', { flag: 'wx' });
|
|
76
|
+
await writeFile(join(directory, 'exterior-trace.json'), JSON.stringify(result.trace) + '\n', { flag: 'wx' });
|
|
77
|
+
});
|
|
78
|
+
current = join(split, 'model.glb');
|
|
79
|
+
splitManifest = JSON.parse(await readFile(join(split, 'split-manifest.json'), 'utf8'));
|
|
80
|
+
lineage.push({ name: 'split', directory: split });
|
|
81
|
+
}
|
|
82
|
+
// Verify exact runtime fingerprints across texture-only optimization, including grouped meshes.
|
|
83
|
+
const beforeBytes = new Uint8Array(await readFile(current));
|
|
84
|
+
const beforeSource = { kind: 'resource', path: 'prepared/model.glb', contentHash: hash(beforeBytes), files: [{ path: 'prepared/model.glb', contentHash: hash(beforeBytes) }] };
|
|
85
|
+
const before = await inspectRigidModel({ bytes: beforeBytes, source: beforeSource,
|
|
86
|
+
adaptation: payload.recipe.adaptation, split: splitManifest, requiredCapability: payload.requiredCapability, signal: input.signal });
|
|
87
|
+
if (payload.recipe.optimize) {
|
|
88
|
+
const optimized = await stage('optimize', { parentHash: hash(beforeBytes), recipe: payload.recipe.optimize }, async (directory) => {
|
|
89
|
+
await optimizeModel({ input: current, output: join(directory, 'model.glb'), lineagePath: join(directory, 'optimization.json'),
|
|
90
|
+
...payload.recipe.optimize, texturesOnly: true, maxOutputBytes: payload.maxModelBytes });
|
|
91
|
+
});
|
|
92
|
+
current = join(optimized, 'model.glb');
|
|
93
|
+
lineage.push({ name: 'optimize', directory: optimized });
|
|
94
|
+
}
|
|
95
|
+
const finalBytes = new Uint8Array(await readFile(current));
|
|
96
|
+
if (finalBytes.length > payload.maxModelBytes)
|
|
97
|
+
throw Error('model_prepare_model_budget_exceeded');
|
|
98
|
+
const derived = Boolean(payload.recipe.repair || payload.recipe.split || payload.recipe.optimize);
|
|
99
|
+
const source = derived ? { ...beforeSource, contentHash: hash(finalBytes), files: [{ path: beforeSource.path, contentHash: hash(finalBytes) }] } : payload.source;
|
|
100
|
+
const inspection = await inspectRigidModel({ bytes: finalBytes, source, adaptation: payload.recipe.adaptation, split: splitManifest,
|
|
101
|
+
requiredCapability: payload.requiredCapability, signal: input.signal });
|
|
102
|
+
if (inspection.triangles > payload.maxTriangles)
|
|
103
|
+
throw Error('model_prepare_triangle_budget_exceeded');
|
|
104
|
+
const geometry = (value) => value.inventory.nodes.map(({ sourceNodeId, geometryHash }) => ({ sourceNodeId, geometryHash }));
|
|
105
|
+
if (canonicalModelAdaptation(geometry(before)) !== canonicalModelAdaptation(geometry(inspection)))
|
|
106
|
+
throw Error('model_prepare_optimized_geometry_changed');
|
|
107
|
+
const adapted = await stage('adapt', { source, plan: inspection.plan }, async (directory) => {
|
|
108
|
+
const inputRoot = join(directory, 'input');
|
|
109
|
+
await mkdir(inputRoot);
|
|
110
|
+
if (derived) {
|
|
111
|
+
await mkdir(join(inputRoot, 'prepared'));
|
|
112
|
+
await writeFile(join(inputRoot, source.path), finalBytes, { flag: 'wx' });
|
|
113
|
+
}
|
|
114
|
+
else
|
|
115
|
+
for (const [path, bytes] of observed.originalFiles) {
|
|
116
|
+
await mkdir(dirname(join(inputRoot, path)), { recursive: true });
|
|
117
|
+
await writeFile(join(inputRoot, path), bytes, { flag: 'wx' });
|
|
118
|
+
}
|
|
119
|
+
await mkdir(join(inputRoot, '.playcraft'));
|
|
120
|
+
await writeFile(join(inputRoot, '.playcraft/model-adaptation.json'), JSON.stringify(inspection.plan, null, 2) + '\n', { flag: 'wx' });
|
|
121
|
+
const generated = await generateModelAdaptationProject({ projectRoot: inputRoot, planPath: '.playcraft/model-adaptation.json',
|
|
122
|
+
outputRoot: join(directory, 'project'), candidateId: payload.pipelineId, signal: input.signal });
|
|
123
|
+
const project = generated.outputRoot, proofPrefix = `.playcraft/model-production/${payload.pipelineId}`, proofRoot = join(project, proofPrefix);
|
|
124
|
+
await mkdir(proofRoot, { recursive: true });
|
|
125
|
+
const proof = { schemaVersion: 1, pipelineId: payload.pipelineId, stepId: payload.stepId, inputHash,
|
|
126
|
+
sourceScope: payload.sourceScope, parentSource: payload.source, recipe: payload.recipe, toolHash: input.toolHash,
|
|
127
|
+
outputSource: generated.manifest.derivedSource, actualInspection: inspection, modelAcceptance: false, browserValidation: 'pending' };
|
|
128
|
+
await writeFile(join(proofRoot, 'lineage.json'), JSON.stringify(proof, null, 2) + '\n', { flag: 'wx' });
|
|
129
|
+
await writeFile(join(proofRoot, 'recipe.json'), JSON.stringify(payload.recipe, null, 2) + '\n', { flag: 'wx' });
|
|
130
|
+
await writeFile(join(proofRoot, 'tool.json'), JSON.stringify({ toolHash: input.toolHash, manifest: input.toolManifest, actualInspector: inspection.tool }, null, 2) + '\n', { flag: 'wx' });
|
|
131
|
+
for (const [path, bytes] of observed.originalFiles) {
|
|
132
|
+
await mkdir(dirname(join(proofRoot, 'original', path)), { recursive: true });
|
|
133
|
+
await writeFile(join(proofRoot, 'original', path), bytes, { flag: 'wx' });
|
|
134
|
+
}
|
|
135
|
+
for (const item of lineage)
|
|
136
|
+
for (const file of await preparationInventory(item.directory)) {
|
|
137
|
+
await writeFile(join(proofRoot, `${item.name}-${file.path.replaceAll('/', '-')}`), (await readPreparationFile(item.directory, file.path)).bytes, { flag: 'wx' });
|
|
138
|
+
}
|
|
139
|
+
const buildPath = join(project, '.playcraft/model-build-inputs.json'), build = JSON.parse(await readFile(buildPath, 'utf8'));
|
|
140
|
+
for (const file of await preparationInventory(proofRoot))
|
|
141
|
+
build.models[0].adaptationFiles.push({ path: `${proofPrefix}/${file.path}`, contentHash: file.contentHash });
|
|
142
|
+
if (build.models[0].adaptationFiles.length > 64)
|
|
143
|
+
throw Error('model_prepare_lineage_limit');
|
|
144
|
+
await writeFile(buildPath, JSON.stringify(build, null, 2) + '\n');
|
|
145
|
+
const sourceManifestPath = join(project, 'model-adaptation-source-manifest.json');
|
|
146
|
+
const sourceManifest = JSON.parse(await readFile(sourceManifestPath, 'utf8'));
|
|
147
|
+
sourceManifest.files = (await preparationInventory(project)).filter(file => !['AGENTS.md', 'README.md', 'model-adaptation-source-manifest.json'].includes(file.path));
|
|
148
|
+
sourceManifest.preservedWorkspaceInstructions = true;
|
|
149
|
+
await writeFile(sourceManifestPath, JSON.stringify(sourceManifest, null, 2) + '\n');
|
|
150
|
+
});
|
|
151
|
+
const projectRoot = join(adapted, 'project'), config = await readPreparationFile(projectRoot, '.playcraft/model-adaptation.json');
|
|
152
|
+
const plan = workspaceModelAdaptationSchema.parse(JSON.parse(config.bytes.toString('utf8')));
|
|
153
|
+
const files = await preparationInventory(projectRoot);
|
|
154
|
+
return { status: 'prepared', projectRoot, model: plan.source, adaptationPath: '.playcraft/model-adaptation.json', adaptationHash: config.contentHash,
|
|
155
|
+
modelBytes: finalBytes.length, triangles: inspection.triangles, files,
|
|
156
|
+
lineageFiles: files.filter(file => file.path.startsWith(`.playcraft/model-production/${payload.pipelineId}/`)).map(({ path, contentHash }) => ({ path, contentHash })) };
|
|
157
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { workspaceRelativePathSchema, canonicalModelAdaptation } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { modelSourceDigest as hash } from '../model-adaptation/source.js';
|
|
7
|
+
export const hashData = (value) => hash(canonicalModelAdaptation(value));
|
|
8
|
+
export function preparationPath(value) {
|
|
9
|
+
workspaceRelativePathSchema.parse(value);
|
|
10
|
+
if (value.split('/').some(part => ['.git', '.ssh', 'node_modules'].includes(part) || part === '.env' || part.startsWith('.env.')))
|
|
11
|
+
throw Error('model_prepare_path_unsafe');
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
const same = (a, b) => a.ino === b.ino && a.dev === b.dev && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
15
|
+
/** Safe byte reads include inode/parent identity checks before and after reading, not only a digest. */
|
|
16
|
+
export async function readPreparationFile(root, relative, maximum = 64 * 1024 * 1024) {
|
|
17
|
+
preparationPath(relative);
|
|
18
|
+
const base = resolve(root);
|
|
19
|
+
if (await realpath(base) !== base || !(await lstat(base)).isDirectory())
|
|
20
|
+
throw Error('model_prepare_root_unsafe');
|
|
21
|
+
const parents = [];
|
|
22
|
+
let path = base;
|
|
23
|
+
for (const part of relative.split('/').slice(0, -1)) {
|
|
24
|
+
path = join(path, part);
|
|
25
|
+
const stat = await lstat(path);
|
|
26
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
27
|
+
throw Error('model_prepare_path_unsafe');
|
|
28
|
+
parents.push({ path, stat });
|
|
29
|
+
}
|
|
30
|
+
path = join(base, relative);
|
|
31
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
32
|
+
try {
|
|
33
|
+
const stat = await handle.stat();
|
|
34
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > maximum)
|
|
35
|
+
throw Error('model_prepare_file_bound');
|
|
36
|
+
const bytes = await handle.readFile();
|
|
37
|
+
if (!same(stat, await handle.stat()) || !same(stat, await lstat(path)) || await realpath(path) !== path)
|
|
38
|
+
throw Error('model_prepare_source_changed');
|
|
39
|
+
for (const parent of parents) {
|
|
40
|
+
const now = await lstat(parent.path);
|
|
41
|
+
if (!now.isDirectory() || now.isSymbolicLink() || now.ino !== parent.stat.ino || now.dev !== parent.stat.dev)
|
|
42
|
+
throw Error('model_prepare_source_changed');
|
|
43
|
+
}
|
|
44
|
+
return { bytes, stat, contentHash: hash(bytes) };
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await handle.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function writePreparationJson(path, value, shared = false) {
|
|
51
|
+
await mkdir(dirname(path), { recursive: true, mode: shared ? 0o2770 : 0o700 });
|
|
52
|
+
const temporary = `${path}.${randomUUID()}.pending`;
|
|
53
|
+
try {
|
|
54
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, shared ? 0o660 : 0o600);
|
|
55
|
+
try {
|
|
56
|
+
await handle.writeFile(JSON.stringify(value, null, 2) + '\n');
|
|
57
|
+
await handle.sync();
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
}
|
|
62
|
+
await rename(temporary, path);
|
|
63
|
+
const directory = await open(dirname(path), constants.O_RDONLY);
|
|
64
|
+
try {
|
|
65
|
+
await directory.sync();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await directory.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await rm(temporary, { force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function preparationInventory(root) {
|
|
76
|
+
const files = [];
|
|
77
|
+
let total = 0;
|
|
78
|
+
async function walk(prefix = '') {
|
|
79
|
+
for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
|
|
80
|
+
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
81
|
+
preparationPath(path);
|
|
82
|
+
if (entry.isSymbolicLink())
|
|
83
|
+
throw Error('model_prepare_path_unsafe');
|
|
84
|
+
if (entry.isDirectory())
|
|
85
|
+
await walk(path);
|
|
86
|
+
else {
|
|
87
|
+
const file = await readPreparationFile(root, path);
|
|
88
|
+
total += file.bytes.length;
|
|
89
|
+
if (files.length >= 256 || total > 256 * 1024 * 1024)
|
|
90
|
+
throw Error('model_prepare_inventory_bound');
|
|
91
|
+
files.push({ path, contentHash: file.contentHash, bytes: file.bytes.length });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
await walk();
|
|
96
|
+
return files.sort((a, b) => a.path.localeCompare(b.path, 'en'));
|
|
97
|
+
}
|
|
98
|
+
export async function readPreparationJson(root, path, maximum = 4 * 1024 * 1024) {
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse((await readPreparationFile(root, path, maximum)).bytes.toString('utf8'));
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error.code === 'ENOENT')
|
|
104
|
+
return undefined;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export { hash };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { modelPreparationPayloadSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { runPreparationEngine } from './pipeline-engine.js';
|
|
6
|
+
import { hash, writePreparationJson } from './pipeline-files.js';
|
|
7
|
+
/** Internal frozen CLI worker; accepts neither a project module nor executable recipe strings. */
|
|
8
|
+
export async function modelPrepareWorker(configPath, expectedHash) {
|
|
9
|
+
const bytes = await readFile(configPath);
|
|
10
|
+
if (hash(bytes) !== expectedHash)
|
|
11
|
+
throw Error('model_prepare_worker_input_changed');
|
|
12
|
+
const config = JSON.parse(bytes.toString('utf8'));
|
|
13
|
+
const payload = modelPreparationPayloadSchema.parse(config.payload);
|
|
14
|
+
try {
|
|
15
|
+
const result = await runPreparationEngine({ payload, sourceRoot: config.sourceRoot, outputRoot: config.outputRoot, toolHash: config.toolHash, toolManifest: config.tools });
|
|
16
|
+
await writePreparationJson(join(config.outputRoot, 'result.json'), result, true);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
const safeCode = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
|
20
|
+
&& /^model_adaptation_[a-z_]+$/.test(error.code) ? error.code : undefined;
|
|
21
|
+
const message = safeCode ?? (error instanceof Error ? error.message : '');
|
|
22
|
+
let code = message.split(':')[0];
|
|
23
|
+
if (['invalid_model', 'invalid_model_resource', 'invalid_model_image', 'asset_too_large'].includes(code))
|
|
24
|
+
code = `model_prepare_${code}`;
|
|
25
|
+
if (!/^(model_prepare_|model_preparation_|model_adaptation_|model_source_|optimization_)[a-z_]+$/.test(code))
|
|
26
|
+
throw error;
|
|
27
|
+
await writePreparationJson(join(config.outputRoot, 'result.json'), { status: 'blocked', reasonCode: code }, true);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
31
|
+
modelPrepareWorker(process.argv[2] ?? '', process.argv[3] ?? '').catch(() => { process.stderr.write('model_prepare_worker_failed\n'); process.exitCode = 1; });
|
|
32
|
+
}
|