@punal_manalan/blender-project-harness 0.0.0-stage → 1.0.2
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/.kilo/agent/blender-grader.md +197 -0
- package/.kilo/agent/blender-worker.md +127 -0
- package/.kilo/blender-project-harness/config.example.json +71 -0
- package/.kilo/blender-project-harness/tools/harness/EXPECTED_FLOW.md +656 -0
- package/.kilo/blender-project-harness/tools/harness/blender-operations.mjs +882 -0
- package/.kilo/blender-project-harness/tools/harness/blender-operations.test.mjs +550 -0
- package/.kilo/blender-project-harness/tools/harness/blender.mjs +301 -0
- package/.kilo/blender-project-harness/tools/harness/blender.test.mjs +87 -0
- package/.kilo/blender-project-harness/tools/harness/cli.mjs +127 -0
- package/.kilo/blender-project-harness/tools/harness/documents.mjs +201 -0
- package/.kilo/blender-project-harness/tools/harness/download.mjs +94 -0
- package/.kilo/blender-project-harness/tools/harness/download.test.mjs +63 -0
- package/.kilo/blender-project-harness/tools/harness/engine.mjs +1866 -0
- package/.kilo/blender-project-harness/tools/harness/engine.test.mjs +2219 -0
- package/.kilo/blender-project-harness/tools/harness/intake.mjs +571 -0
- package/.kilo/blender-project-harness/tools/harness/intake.test.mjs +181 -0
- package/.kilo/blender-project-harness/tools/harness/package-lock.json +1227 -0
- package/.kilo/blender-project-harness/tools/harness/package.json +15 -0
- package/.kilo/blender-project-harness/tools/harness/plugin.mjs +550 -0
- package/.kilo/blender-project-harness/tools/harness/plugin.test.mjs +526 -0
- package/.kilo/blender-project-harness/tools/harness/proxy.mjs +222 -0
- package/.kilo/blender-project-harness/tools/harness/proxy.test.mjs +217 -0
- package/.kilo/blender-project-harness/tools/harness/scaffold.mjs +83 -0
- package/.kilo/blender-project-harness/tools/harness/scaffold.test.mjs +74 -0
- package/.kilo/blender-project-harness/tools/harness/server.mjs +24 -0
- package/.kilo/blender-project-harness/tools/harness/shell-policy.mjs +196 -0
- package/.kilo/blender-project-harness/tools/harness/shell-policy.test.mjs +68 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/ASSETS.md +29 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/DECISIONS.md +8 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/01-project-brief.md +71 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/README.md +17 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/INTAKE.md +8 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/README.md +57 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REQUIREMENTS.md +64 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REVIEW.md +29 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/References/README.md +18 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Reviews/README.md +48 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/TASKSTRACKER.md +10 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Tasks/README.md +4 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/WORKFLOW.md +36 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/README.md +130 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/README.md +31 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/audio/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/README.md +43 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/SOURCE_TEMPLATE.md +18 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/hdri/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/models/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/textures/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/backups/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/backups/README.md +5 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/exports/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/exports/README.md +11 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/README.md +7 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/final/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/wip/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/scenes/README.md +10 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/scripts/README.md +16 -0
- package/.kilo/blender-project-harness/tools/harness/test-fixture.mjs +91 -0
- package/.kilo/blender-project-harness/tools/harness/workspace.mjs +30 -0
- package/.kilo/blender-project-harness/tools/harness/workspace.test.mjs +31 -0
- package/.kilo/blender-project-harness/tools/test_blender_tools.py +427 -0
- package/.kilo/plugins/blender-workflow.js +3 -0
- package/.kilo/skills/blender-assets/SKILL.md +99 -0
- package/.kilo/skills/blender-mcp/SKILL.md +147 -0
- package/.kilo/skills/blender-mcp/references/README.md +22 -0
- package/.kilo/skills/blender-mcp/references/animation.md +83 -0
- package/.kilo/skills/blender-mcp/references/bpy.md +97 -0
- package/.kilo/skills/blender-mcp/references/level-design.md +53 -0
- package/.kilo/skills/blender-mcp/references/materials.md +80 -0
- package/.kilo/skills/blender-mcp/references/retopology.md +104 -0
- package/.kilo/skills/blender-mcp/references/rigging.md +123 -0
- package/.kilo/skills/blender-mcp/references/scene.md +72 -0
- package/.kilo/skills/blender-project-goal/SKILL.md +115 -0
- package/.kilo/skills/blender-project-grading/SKILL.md +184 -0
- package/.kilo/skills/blender-project-readme/SKILL.md +46 -0
- package/.kilo/skills/blender-project-requirements/SKILL.md +84 -0
- package/.kilo/skills/blender-project-scaffold/SKILL.md +59 -0
- package/.kilo/skills/blender-project-workflow/SKILL.md +96 -0
- package/.kilo/skills/blender-subagents/SKILL.md +204 -0
- package/.kilo/skills/blender-workflow-harness/SKILL.md +187 -0
- package/AGENTS.md +234 -0
- package/Blender/README.md +30 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +368 -2
- package/THIRD_PARTY_NOTICES.md +51 -0
- package/bin/blender-project-harness.mjs +4 -0
- package/installer/cli.mjs +116 -0
- package/installer/install.mjs +325 -0
- package/installer/install.test.mjs +134 -0
- package/package.json +58 -6
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const MAX_INTAKE_FILES = 200;
|
|
6
|
+
export const MAX_INTAKE_FILE_BYTES = 512 * 1024 * 1024;
|
|
7
|
+
export const MAX_INTAKE_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
|
|
8
|
+
export const MAX_INTAKE_DEPTH = 8;
|
|
9
|
+
|
|
10
|
+
const MODEL_EXTENSIONS = new Set(['.glb', '.gltf', '.fbx', '.obj', '.usd', '.usdz', '.blend']);
|
|
11
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.tif', '.tiff', '.bmp']);
|
|
12
|
+
const HDRI_EXTENSIONS = new Set(['.hdr', '.exr']);
|
|
13
|
+
const AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.ogg', '.flac']);
|
|
14
|
+
const SCRIPT_EXTENSIONS = new Set(['.py', '.pyw']);
|
|
15
|
+
const MIME_TYPES = new Map([
|
|
16
|
+
['.glb', 'model/gltf-binary'], ['.gltf', 'model/gltf+json'], ['.fbx', 'application/octet-stream'],
|
|
17
|
+
['.obj', 'model/obj'], ['.usd', 'model/vnd.usd'], ['.usdz', 'model/vnd.usdz+zip'],
|
|
18
|
+
['.blend', 'application/x-blender'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'],
|
|
19
|
+
['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], ['.tif', 'image/tiff'],
|
|
20
|
+
['.tiff', 'image/tiff'], ['.bmp', 'image/bmp'], ['.hdr', 'image/vnd.radiance'],
|
|
21
|
+
['.exr', 'image/x-exr'], ['.wav', 'audio/wav'], ['.mp3', 'audio/mpeg'],
|
|
22
|
+
['.ogg', 'audio/ogg'], ['.flac', 'audio/flac'], ['.py', 'text/x-python'], ['.pyw', 'text/x-python'],
|
|
23
|
+
]);
|
|
24
|
+
const SIGNATURE_MESSAGES = new Map([
|
|
25
|
+
['.png', 'PNG'], ['.jpg', 'JPEG'], ['.jpeg', 'JPEG'], ['.webp', 'WebP'],
|
|
26
|
+
['.tif', 'TIFF'], ['.tiff', 'TIFF'], ['.bmp', 'BMP'], ['.glb', 'GLB'],
|
|
27
|
+
['.gltf', 'glTF JSON'], ['.fbx', 'FBX'], ['.obj', 'OBJ'], ['.usd', 'USD'],
|
|
28
|
+
['.usdz', 'USDZ'], ['.blend', 'Blender'], ['.hdr', 'Radiance HDR'],
|
|
29
|
+
['.exr', 'OpenEXR'], ['.wav', 'WAV'], ['.mp3', 'MP3'], ['.ogg', 'Ogg'],
|
|
30
|
+
['.flac', 'FLAC'], ['.py', 'Python source'], ['.pyw', 'Python source'],
|
|
31
|
+
]);
|
|
32
|
+
const SKIP_DIRECTORIES = new Set(['.git', 'node_modules', '__pycache__', '.pytest_cache', '.cache', 'cache']);
|
|
33
|
+
|
|
34
|
+
function fail(message) {
|
|
35
|
+
throw new Error(message);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function classifyIntakeFile(name) {
|
|
39
|
+
const extension = path.extname(name).toLowerCase();
|
|
40
|
+
if (MODEL_EXTENSIONS.has(extension)) return 'model';
|
|
41
|
+
if (IMAGE_EXTENSIONS.has(extension)) return 'reference';
|
|
42
|
+
if (HDRI_EXTENSIONS.has(extension)) return 'hdri';
|
|
43
|
+
if (AUDIO_EXTENSIONS.has(extension)) return 'audio';
|
|
44
|
+
if (SCRIPT_EXTENSIONS.has(extension)) return 'script';
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function safePart(value, fallback) {
|
|
49
|
+
const text = String(value ?? '')
|
|
50
|
+
.normalize('NFKD')
|
|
51
|
+
.replace(/[^\x20-\x7e]/g, '')
|
|
52
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
53
|
+
.replace(/^[._-]+|[._-]+$/g, '')
|
|
54
|
+
.slice(0, 80);
|
|
55
|
+
return text || fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function safeIntakeFileName(name, fallback = 'asset') {
|
|
59
|
+
const extension = path.extname(name).toLowerCase();
|
|
60
|
+
const stem = safePart(path.basename(name, path.extname(name)), fallback);
|
|
61
|
+
return `${stem}${extension}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hashFile(file) {
|
|
65
|
+
const hash = crypto.createHash('sha256');
|
|
66
|
+
const fd = fs.openSync(file, 'r');
|
|
67
|
+
try {
|
|
68
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
69
|
+
let read;
|
|
70
|
+
while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read));
|
|
71
|
+
} finally {
|
|
72
|
+
fs.closeSync(fd);
|
|
73
|
+
}
|
|
74
|
+
return hash.digest('hex');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readPrefix(file, length = 4096) {
|
|
78
|
+
const fd = fs.openSync(file, 'r');
|
|
79
|
+
try {
|
|
80
|
+
const buffer = Buffer.alloc(length);
|
|
81
|
+
const read = fs.readSync(fd, buffer, 0, length, 0);
|
|
82
|
+
return buffer.subarray(0, read);
|
|
83
|
+
} finally {
|
|
84
|
+
fs.closeSync(fd);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function startsWith(buffer, bytes) {
|
|
89
|
+
return buffer.length >= bytes.length && bytes.every((byte, index) => buffer[index] === byte);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function textPrefix(buffer) {
|
|
93
|
+
return buffer.toString('utf8').replace(/^\uFEFF/, '').trimStart();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function imageDimensions(buffer, extension) {
|
|
97
|
+
if (extension === '.png' && buffer.length >= 24 && buffer.toString('ascii', 12, 16) === 'IHDR') {
|
|
98
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
99
|
+
}
|
|
100
|
+
if (['.jpg', '.jpeg'].includes(extension)) {
|
|
101
|
+
let offset = 2;
|
|
102
|
+
while (offset + 9 < buffer.length) {
|
|
103
|
+
if (buffer[offset] !== 0xff) { offset += 1; continue; }
|
|
104
|
+
const marker = buffer[offset + 1];
|
|
105
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
106
|
+
return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) };
|
|
107
|
+
}
|
|
108
|
+
if (offset + 3 >= buffer.length) break;
|
|
109
|
+
offset += 2 + buffer.readUInt16BE(offset + 2);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (extension === '.webp' && buffer.length >= 30 && buffer.toString('ascii', 12, 16) === 'VP8X') {
|
|
113
|
+
return { width: buffer.readUIntLE(24, 3) + 1, height: buffer.readUIntLE(27, 3) + 1 };
|
|
114
|
+
}
|
|
115
|
+
return { width: null, height: null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function signatureMatches(buffer, extension) {
|
|
119
|
+
switch (extension) {
|
|
120
|
+
case '.png': return startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
121
|
+
case '.jpg':
|
|
122
|
+
case '.jpeg': return startsWith(buffer, [0xff, 0xd8, 0xff]);
|
|
123
|
+
case '.webp': return buffer.length >= 12 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP';
|
|
124
|
+
case '.tif':
|
|
125
|
+
case '.tiff': return startsWith(buffer, [0x49, 0x49, 0x2a, 0x00]) || startsWith(buffer, [0x4d, 0x4d, 0x00, 0x2a]);
|
|
126
|
+
case '.bmp': return startsWith(buffer, [0x42, 0x4d]);
|
|
127
|
+
case '.glb': return buffer.length >= 4 && buffer.toString('ascii', 0, 4) === 'glTF';
|
|
128
|
+
case '.gltf': return textPrefix(buffer).startsWith('{');
|
|
129
|
+
case '.fbx': return buffer.toString('ascii', 0, 21).startsWith('Kaydara FBX Binary') || textPrefix(buffer).startsWith('; FBX');
|
|
130
|
+
case '.obj': return !buffer.includes(0);
|
|
131
|
+
case '.usd': return buffer.length >= 8 && buffer.toString('ascii', 0, 8) === 'PXR-USDC' || textPrefix(buffer).startsWith('#usda');
|
|
132
|
+
case '.usdz': return startsWith(buffer, [0x50, 0x4b]);
|
|
133
|
+
case '.blend': return buffer.length >= 7 && buffer.toString('ascii', 0, 7) === 'BLENDER' || startsWith(buffer, [0x1f, 0x8b]);
|
|
134
|
+
case '.hdr': return /^#\?(?:RADIANCE|RGBE)/.test(textPrefix(buffer));
|
|
135
|
+
case '.exr': return startsWith(buffer, [0x76, 0x2f, 0x31, 0x01]);
|
|
136
|
+
case '.wav': return buffer.length >= 12 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WAVE';
|
|
137
|
+
case '.mp3': return startsWith(buffer, [0x49, 0x44, 0x33]) || (buffer.length >= 2 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0);
|
|
138
|
+
case '.ogg': return buffer.length >= 4 && buffer.toString('ascii', 0, 4) === 'OggS';
|
|
139
|
+
case '.flac': return buffer.length >= 4 && buffer.toString('ascii', 0, 4) === 'fLaC';
|
|
140
|
+
case '.py':
|
|
141
|
+
case '.pyw': return !buffer.includes(0);
|
|
142
|
+
default: return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function inspectIntakeFile(file, kind) {
|
|
147
|
+
const extension = path.extname(file).toLowerCase();
|
|
148
|
+
const buffer = readPrefix(file);
|
|
149
|
+
if (!signatureMatches(buffer, extension)) {
|
|
150
|
+
fail(`file signature does not match ${SIGNATURE_MESSAGES.get(extension) ?? extension}`);
|
|
151
|
+
}
|
|
152
|
+
const dimensions = kind === 'reference' || kind === 'hdri' ? imageDimensions(buffer, extension) : { width: null, height: null };
|
|
153
|
+
return {
|
|
154
|
+
mime: MIME_TYPES.get(extension) ?? 'application/octet-stream',
|
|
155
|
+
width: dimensions.width,
|
|
156
|
+
height: dimensions.height,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assertNotState(file, home) {
|
|
161
|
+
if (home && path.resolve(file).toLowerCase().startsWith(path.resolve(home).toLowerCase() + path.sep)) {
|
|
162
|
+
fail('source cannot be the harness state directory');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function assertExplicitSource(file) {
|
|
167
|
+
const parsed = path.parse(file);
|
|
168
|
+
if (parsed.root && path.resolve(parsed.root) === path.resolve(file)) fail('source cannot be a volume root');
|
|
169
|
+
if (file.split(path.sep).some((part) => part && (part === '.git' || part === 'node_modules'))) {
|
|
170
|
+
fail('source cannot be inside .git or node_modules');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function assertRegularFile(file) {
|
|
175
|
+
const stat = fs.lstatSync(file);
|
|
176
|
+
if (stat.isSymbolicLink()) fail(`symlink rejected: ${file}`);
|
|
177
|
+
if (!stat.isFile()) fail(`not a regular file: ${file}`);
|
|
178
|
+
return stat;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function walkDirectory(directory, depth, state, rejected) {
|
|
182
|
+
if (depth > MAX_INTAKE_DEPTH) {
|
|
183
|
+
rejected.push({ path: directory, reason: `folder depth exceeds ${MAX_INTAKE_DEPTH}` });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
let entries;
|
|
187
|
+
try {
|
|
188
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
189
|
+
} catch (error) {
|
|
190
|
+
rejected.push({ path: directory, reason: `cannot read folder: ${error.message}` });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const full = path.join(directory, entry.name);
|
|
196
|
+
if (entry.isSymbolicLink()) {
|
|
197
|
+
rejected.push({ path: full, reason: 'symlink rejected' });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (entry.isDirectory()) {
|
|
201
|
+
if (SKIP_DIRECTORIES.has(entry.name)) continue;
|
|
202
|
+
walkDirectory(full, depth + 1, state, rejected);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
addFile(full, state, rejected);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function adjacentLicenseCandidates(file) {
|
|
210
|
+
const directory = path.dirname(file);
|
|
211
|
+
try {
|
|
212
|
+
return fs.readdirSync(directory, { withFileTypes: true })
|
|
213
|
+
.filter((entry) => entry.isFile() && (/^(LICENSE|LICENCE|COPYING|COPYRIGHT|ATTRIBUTION|README)(\.|$)/i.test(entry.name) || /\.(license|licence)$/i.test(entry.name)))
|
|
214
|
+
.map((entry) => entry.name)
|
|
215
|
+
.slice(0, 20);
|
|
216
|
+
} catch {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function addFile(file, state, rejected) {
|
|
222
|
+
if (state.files >= MAX_INTAKE_FILES) {
|
|
223
|
+
rejected.push({ path: file, reason: `file count exceeds ${MAX_INTAKE_FILES}` });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
let stat;
|
|
227
|
+
try {
|
|
228
|
+
stat = assertRegularFile(file);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
rejected.push({ path: file, reason: error.message });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (stat.size > MAX_INTAKE_FILE_BYTES) {
|
|
234
|
+
rejected.push({ path: file, reason: `file exceeds ${MAX_INTAKE_FILE_BYTES} bytes` });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (state.bytes + stat.size > MAX_INTAKE_TOTAL_BYTES) {
|
|
238
|
+
rejected.push({ path: file, reason: `total intake exceeds ${MAX_INTAKE_TOTAL_BYTES} bytes` });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const kind = classifyIntakeFile(file);
|
|
242
|
+
if (!kind) {
|
|
243
|
+
rejected.push({ path: file, reason: 'unsupported file type' });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
let metadata;
|
|
247
|
+
try {
|
|
248
|
+
metadata = inspectIntakeFile(file, kind);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
rejected.push({ path: file, reason: error.message });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
state.files += 1;
|
|
254
|
+
state.bytes += stat.size;
|
|
255
|
+
state.entries.push({
|
|
256
|
+
sourcePath: file,
|
|
257
|
+
name: path.basename(file),
|
|
258
|
+
kind,
|
|
259
|
+
bytes: stat.size,
|
|
260
|
+
sha256: hashFile(file),
|
|
261
|
+
licenseCandidates: adjacentLicenseCandidates(file),
|
|
262
|
+
...metadata,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function collectIntakeSources({ root, files = [], folders = [], home = null }) {
|
|
267
|
+
if (!Array.isArray(files) || !Array.isArray(folders)) fail('files and folders must be arrays');
|
|
268
|
+
const state = { files: 0, bytes: 0, entries: [] };
|
|
269
|
+
const rejected = [];
|
|
270
|
+
const add = (value) => {
|
|
271
|
+
if (typeof value !== 'string' || !value.trim()) fail('intake paths must be non-empty strings');
|
|
272
|
+
const full = path.resolve(root, value);
|
|
273
|
+
assertNotState(full, home);
|
|
274
|
+
assertExplicitSource(full);
|
|
275
|
+
let stat;
|
|
276
|
+
try {
|
|
277
|
+
stat = fs.lstatSync(full);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
rejected.push({ path: full, reason: `missing source: ${error.message}` });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (stat.isSymbolicLink()) {
|
|
283
|
+
rejected.push({ path: full, reason: 'symlink rejected' });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (stat.isDirectory()) {
|
|
287
|
+
walkDirectory(full, 0, state, rejected);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
addFile(full, state, rejected);
|
|
291
|
+
};
|
|
292
|
+
for (const file of files) add(file);
|
|
293
|
+
for (const folder of folders) add(folder);
|
|
294
|
+
return { entries: state.entries, rejected, bytes: state.bytes };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function sourceOptions(source) {
|
|
298
|
+
if (source === undefined || source === null) return {};
|
|
299
|
+
if (typeof source !== 'object' || Array.isArray(source)) fail('source must be an object');
|
|
300
|
+
const allowed = ['name', 'asset', 'url', 'vendor', 'author', 'license', 'licenseText', 'licenseFile', 'attribution', 'redistributable', 'external', 'packaging', 'notes', 'licenseUrl'];
|
|
301
|
+
for (const key of Object.keys(source)) if (!allowed.includes(key)) fail(`source has unsupported field: ${key}`);
|
|
302
|
+
for (const key of ['name', 'asset', 'url', 'vendor', 'author', 'license', 'licenseText', 'licenseFile', 'attribution', 'packaging', 'notes', 'licenseUrl']) {
|
|
303
|
+
if (source[key] !== undefined && typeof source[key] !== 'string') fail(`source.${key} must be a string`);
|
|
304
|
+
}
|
|
305
|
+
if (source.external !== undefined && typeof source.external !== 'boolean') fail('source.external must be a boolean');
|
|
306
|
+
if (source.redistributable !== undefined && typeof source.redistributable !== 'boolean') fail('source.redistributable must be a boolean');
|
|
307
|
+
return source;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function generatedOptions(generated) {
|
|
311
|
+
if (generated === undefined || generated === null) return null;
|
|
312
|
+
if (typeof generated !== 'object' || Array.isArray(generated)) fail('generated must be an object');
|
|
313
|
+
const allowed = ['tool', 'model', 'prompt', 'seed', 'width', 'height', 'c2pa'];
|
|
314
|
+
for (const key of Object.keys(generated)) if (!allowed.includes(key)) fail(`generated has unsupported field: ${key}`);
|
|
315
|
+
for (const key of ['tool', 'model', 'prompt', 'seed']) {
|
|
316
|
+
if (generated[key] !== undefined && typeof generated[key] !== 'string') fail(`generated.${key} must be a string`);
|
|
317
|
+
}
|
|
318
|
+
for (const key of ['width', 'height']) {
|
|
319
|
+
if (generated[key] !== undefined && (!Number.isInteger(generated[key]) || generated[key] < 1 || generated[key] > 100000)) {
|
|
320
|
+
fail(`generated.${key} must be a positive integer`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (generated.c2pa !== undefined && typeof generated.c2pa !== 'boolean') fail('generated.c2pa must be a boolean');
|
|
324
|
+
return generated;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function licenseTextFor(source) {
|
|
328
|
+
if (!source) return null;
|
|
329
|
+
if (source.licenseText && source.licenseText.trim()) {
|
|
330
|
+
if (source.licenseText.length > 1_000_000) fail('source.licenseText is too large');
|
|
331
|
+
return source.licenseText.replace(/\r\n?/g, '\n');
|
|
332
|
+
}
|
|
333
|
+
if (source.licenseFile) {
|
|
334
|
+
const file = path.resolve(source.licenseFile);
|
|
335
|
+
const stat = assertRegularFile(file);
|
|
336
|
+
if (stat.size > 1_000_000) fail('source.licenseFile is too large');
|
|
337
|
+
return fs.readFileSync(file, 'utf8').replace(/\r\n?/g, '\n');
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function externalFor(kind, source) {
|
|
343
|
+
if (kind === 'script') return false;
|
|
344
|
+
if (source?.external !== undefined) return source.external;
|
|
345
|
+
if (source?.url || source?.license) return true;
|
|
346
|
+
return kind === 'model';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function validateIntakeOptions({ kind = 'auto', source, generated } = {}) {
|
|
350
|
+
const allowedKinds = new Set(['auto', 'model', 'reference', 'texture', 'hdri', 'audio', 'script']);
|
|
351
|
+
if (!allowedKinds.has(kind)) fail(`kind must be one of ${[...allowedKinds].join(', ')}`);
|
|
352
|
+
const normalizedSource = sourceOptions(source);
|
|
353
|
+
const normalizedGenerated = generatedOptions(generated);
|
|
354
|
+
const external = kind === 'auto' || normalizedGenerated ? false : externalFor(kind, normalizedSource);
|
|
355
|
+
const licenseText = licenseTextFor(normalizedSource);
|
|
356
|
+
return { kind, source: normalizedSource, generated: normalizedGenerated, external, licenseText };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function intakeDestination({ kind, entry, source = {}, generated = null }) {
|
|
360
|
+
const extension = path.extname(entry.name).toLowerCase();
|
|
361
|
+
const hash = entry.sha256.slice(0, 8);
|
|
362
|
+
if (kind === 'script') return `scripts/${safeIntakeFileName(entry.name, 'script')}`;
|
|
363
|
+
const external = generated ? false : externalFor(kind, source);
|
|
364
|
+
const vendor = safePart(source.vendor || source.author || 'local', 'local');
|
|
365
|
+
const asset = safePart(source.name || source.asset || path.basename(entry.name, path.extname(entry.name)), 'asset');
|
|
366
|
+
if (kind === 'reference') {
|
|
367
|
+
if (external) return `Documents/References/${vendor}/${asset}-${hash}/${safeIntakeFileName(entry.name, 'reference')}`;
|
|
368
|
+
const stem = safePart(path.basename(entry.name, path.extname(entry.name)), 'reference');
|
|
369
|
+
return `Documents/References/${stem}-${hash}${extension}`;
|
|
370
|
+
}
|
|
371
|
+
if (external) return `assets/external/${vendor}/${asset}/${safeIntakeFileName(entry.name, 'asset')}`;
|
|
372
|
+
const directory = { model: 'assets/models', texture: 'assets/textures', hdri: 'assets/hdri', audio: 'assets/audio' }[kind];
|
|
373
|
+
if (!directory) fail(`cannot choose a destination for ${kind}`);
|
|
374
|
+
return `${directory}/${safeIntakeFileName(entry.name, 'asset')}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function planIntake({ root, projectName, files = [], folders = [], kind = 'auto', source, generated, home = null }) {
|
|
378
|
+
const options = validateIntakeOptions({ kind, source, generated });
|
|
379
|
+
const collected = collectIntakeSources({ root, files, folders, home });
|
|
380
|
+
const entries = [];
|
|
381
|
+
const rejected = [...collected.rejected];
|
|
382
|
+
for (const entry of collected.entries) {
|
|
383
|
+
const selected = options.kind === 'auto' ? entry.kind : options.kind;
|
|
384
|
+
if (entry.kind !== selected && !(selected === 'texture' && (entry.kind === 'reference' || entry.kind === 'hdri'))) {
|
|
385
|
+
rejected.push({ path: entry.sourcePath, reason: `file type ${entry.kind} is not ${selected}` });
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (selected === 'script' && !SCRIPT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
389
|
+
rejected.push({ path: entry.sourcePath, reason: 'scripts must be .py or .pyw' });
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const external = options.generated ? false : externalFor(selected, options.source);
|
|
393
|
+
if (external && !options.source.license) {
|
|
394
|
+
rejected.push({ path: entry.sourcePath, reason: 'external asset requires source.license' });
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (external && !options.licenseText) {
|
|
398
|
+
rejected.push({ path: entry.sourcePath, reason: 'external asset requires source.licenseText or source.licenseFile' });
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
entries.push({
|
|
402
|
+
...entry,
|
|
403
|
+
kind: selected,
|
|
404
|
+
destination: intakeDestination({ kind: selected, entry, source: options.source, generated: options.generated }),
|
|
405
|
+
external,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return { projectName, entries, rejected, source: options.source, generated: options.generated, external: options.external, licenseText: options.licenseText };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function copyIntake({ projectRoot, plan, licenseText = null }) {
|
|
412
|
+
const copied = [];
|
|
413
|
+
const duplicates = [];
|
|
414
|
+
const rejected = [...plan.rejected];
|
|
415
|
+
for (const entry of plan.entries) {
|
|
416
|
+
const target = path.join(projectRoot, ...entry.destination.split('/'));
|
|
417
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
418
|
+
let final = target;
|
|
419
|
+
if (fs.existsSync(final)) {
|
|
420
|
+
const existing = hashFile(final);
|
|
421
|
+
if (existing === entry.sha256) {
|
|
422
|
+
duplicates.push({ ...entry, stored: entry.destination, reason: 'same content already exists' });
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const extension = path.extname(final);
|
|
426
|
+
const stem = final.slice(0, -extension.length);
|
|
427
|
+
let index = 2;
|
|
428
|
+
while (fs.existsSync(`${stem}-${index}${extension}`)) index += 1;
|
|
429
|
+
final = `${stem}-${index}${extension}`;
|
|
430
|
+
}
|
|
431
|
+
fs.copyFileSync(entry.sourcePath, final, fs.constants.COPYFILE_EXCL);
|
|
432
|
+
if (hashFile(final) !== entry.sha256) {
|
|
433
|
+
fs.rmSync(final, { force: true });
|
|
434
|
+
fail(`copied file hash mismatch: ${entry.sourcePath}`);
|
|
435
|
+
}
|
|
436
|
+
const stored = path.relative(projectRoot, final).replaceAll('\\', '/');
|
|
437
|
+
if (entry.external) {
|
|
438
|
+
if (!licenseText) {
|
|
439
|
+
fs.rmSync(final, { force: true });
|
|
440
|
+
rejected.push({ path: entry.sourcePath, reason: 'external asset requires licence text' });
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
fs.writeFileSync(path.join(path.dirname(final), 'LICENSE.txt'), licenseText, 'utf8');
|
|
444
|
+
}
|
|
445
|
+
copied.push({ ...entry, stored, target: final });
|
|
446
|
+
}
|
|
447
|
+
return { copied, duplicates, rejected };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const cell = (value) => String(value ?? '').replaceAll('|', '\\|').replace(/[\r\n]+/g, ' ').slice(0, 300);
|
|
451
|
+
|
|
452
|
+
function insertTableRow(content, header, row, placeholder) {
|
|
453
|
+
const lines = content.replace(/\r\n?/g, '\n').split('\n');
|
|
454
|
+
const headerIndex = lines.findIndex((line) => line.trim() === header);
|
|
455
|
+
if (headerIndex < 0) {
|
|
456
|
+
const rules = lines.findIndex((line) => /^##\s+Rules\s*$/.test(line));
|
|
457
|
+
const insert = rules >= 0 ? rules : lines.length;
|
|
458
|
+
lines.splice(insert, 0, header, header.replace(/[^|]+/g, (value) => value.trim() ? '---' : value), row, '');
|
|
459
|
+
return lines.join('\n');
|
|
460
|
+
}
|
|
461
|
+
let end = headerIndex + 2;
|
|
462
|
+
while (end < lines.length && lines[end].trim().startsWith('|')) end += 1;
|
|
463
|
+
const placeholderIndex = lines.findIndex((line, index) => index > headerIndex && index < end && line.trim() === placeholder);
|
|
464
|
+
if (placeholderIndex >= 0) lines[placeholderIndex] = row;
|
|
465
|
+
else lines.splice(end, 0, row);
|
|
466
|
+
return lines.join('\n');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function appendAssetRow(content, row) {
|
|
470
|
+
const header = '| Asset ID | Asset | Type | Source URL | Author / vendor | Licence | Local path | Packaging | Redistributable | SHA-256 | Preview | Used in | Notes |';
|
|
471
|
+
const placeholder = '| — | None yet | — | — | — | — | — | — | — | — | — | — | — |';
|
|
472
|
+
const line = `| ${cell(row.assetId || row.sha256?.slice(0, 12) || '—')} | ${cell(row.asset)} | ${cell(row.type)} | ${cell(row.sourceUrl)} | ${cell(row.author)} | ${cell(row.license)} | ${cell(row.localPath)} | ${cell(row.packaging)} | ${cell(row.redistributable)} | ${cell(row.sha256)} | ${cell(row.preview)} | ${cell(row.usedIn)} | ${cell(row.notes)} |`;
|
|
473
|
+
if (content.includes(`| ${cell(row.localPath)} |`)) return content;
|
|
474
|
+
return insertTableRow(content, header, line, placeholder);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function appendReferenceRow(content, row) {
|
|
478
|
+
const header = '| Reference | Source | What it informs | Notes |';
|
|
479
|
+
const placeholder = '| — | — | — | — |';
|
|
480
|
+
const line = `| ${cell(row.reference)} | ${cell(row.source)} | ${cell(row.informs)} | ${cell(row.notes)} |`;
|
|
481
|
+
if (content.includes(`| ${cell(row.reference)} |`)) return content;
|
|
482
|
+
return insertTableRow(content, header, line, placeholder);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function appendIntakeRow(content, row) {
|
|
486
|
+
const header = '| Time | Kind | Original | Stored | Bytes | MIME | Dimensions | SHA-256 | Source | Licence | Status |';
|
|
487
|
+
const dimensions = row.width && row.height ? `${row.width}x${row.height}` : '';
|
|
488
|
+
const line = `| ${cell(row.time)} | ${cell(row.kind)} | ${cell(row.original)} | ${cell(row.stored)} | ${cell(row.bytes)} | ${cell(row.mime)} | ${cell(dimensions)} | ${cell(row.sha256)} | ${cell(row.source)} | ${cell(row.license)} | ${cell(row.status)} |`;
|
|
489
|
+
if (content.includes(`| ${cell(row.stored)} |`)) return content;
|
|
490
|
+
return insertTableRow(content, header, line, null);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function createIntakeDocument(projectName) {
|
|
494
|
+
return [
|
|
495
|
+
`# Intake — ${projectName}`,
|
|
496
|
+
'',
|
|
497
|
+
'Harness-owned record of files supplied by the user. Original files are never moved or deleted.',
|
|
498
|
+
'',
|
|
499
|
+
'## Intake',
|
|
500
|
+
'',
|
|
501
|
+
'| Time | Kind | Original | Stored | Bytes | MIME | Dimensions | SHA-256 | Source | Licence | Status |',
|
|
502
|
+
'|---|---|---|---|---|---|---|---|---|---|---|',
|
|
503
|
+
'',
|
|
504
|
+
].join('\n');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function sourceDocument({ entry, source = {}, license = '', generated = null }) {
|
|
508
|
+
const generatedFields = generated
|
|
509
|
+
? [
|
|
510
|
+
`- Generated by: ${generated.tool || '—'}`,
|
|
511
|
+
`- Model: ${generated.model || '—'}`,
|
|
512
|
+
`- Prompt: ${generated.prompt || '—'}`,
|
|
513
|
+
`- Seed: ${generated.seed || '—'}`,
|
|
514
|
+
`- Dimensions: ${generated.width || '—'} × ${generated.height || '—'}`,
|
|
515
|
+
`- C2PA present: ${generated.c2pa ? 'yes' : 'unknown'}`,
|
|
516
|
+
]
|
|
517
|
+
: [];
|
|
518
|
+
return [
|
|
519
|
+
'# Asset Source',
|
|
520
|
+
'',
|
|
521
|
+
`- Asset: ${source.name || entry.name}`,
|
|
522
|
+
`- Original filename: ${entry.name}`,
|
|
523
|
+
`- Source URL: ${source.url || '—'}`,
|
|
524
|
+
`- Author / vendor: ${source.author || source.vendor || '—'}`,
|
|
525
|
+
`- Licence: ${source.license || license || '—'}`,
|
|
526
|
+
`- Licence source URL: ${source.licenseUrl || '—'}`,
|
|
527
|
+
'- Verbatim licence copy: LICENSE.txt (required; preserve original licence files too)',
|
|
528
|
+
`- Attribution required: ${source.attribution || '—'}`,
|
|
529
|
+
`- Redistribution allowed: ${source.redistributable === undefined ? '—' : source.redistributable ? 'yes' : 'no'}`,
|
|
530
|
+
`- Licence status: ${source.license ? 'provided' : entry.licenseCandidates?.length ? 'candidates only; not accepted' : 'missing'}`,
|
|
531
|
+
`- Adjacent licence candidates: ${entry.licenseCandidates?.length ? entry.licenseCandidates.join(', ') : '—'}`,
|
|
532
|
+
'- Licence candidate policy: candidates are evidence only; verify and provide the accepted licence text',
|
|
533
|
+
`- Packaging in .blend: ${source.packaging || 'none'}`,
|
|
534
|
+
`- Local path: ${entry.stored}`,
|
|
535
|
+
`- Bytes: ${entry.bytes}`,
|
|
536
|
+
`- MIME type: ${entry.mime || '—'}`,
|
|
537
|
+
`- Dimensions: ${entry.width && entry.height ? `${entry.width} × ${entry.height}` : '—'}`,
|
|
538
|
+
'- Imported into: —',
|
|
539
|
+
'- Modifications: none',
|
|
540
|
+
'- Original asset / derivative relationship: original',
|
|
541
|
+
`- Acquisition instructions and file hashes: ${entry.sha256}`,
|
|
542
|
+
`- Date: ${new Date().toISOString().slice(0, 10)}`,
|
|
543
|
+
`- Notes: ${source.notes || '—'}`,
|
|
544
|
+
...generatedFields,
|
|
545
|
+
'',
|
|
546
|
+
].join('\n');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function referenceSourceDocument({ entry, source = {}, generated = null }) {
|
|
550
|
+
return [
|
|
551
|
+
`# Reference — ${source.name || entry.name}`,
|
|
552
|
+
'',
|
|
553
|
+
`- Original filename: ${entry.name}`,
|
|
554
|
+
`- Stored: ${entry.stored}`,
|
|
555
|
+
`- Bytes: ${entry.bytes}`,
|
|
556
|
+
`- MIME type: ${entry.mime || '—'}`,
|
|
557
|
+
`- Dimensions: ${entry.width && entry.height ? `${entry.width} × ${entry.height}` : '—'}`,
|
|
558
|
+
`- Source: ${source.url || source.author || 'user-supplied'}`,
|
|
559
|
+
`- Licence: ${source.license || 'project-owned or user-supplied'}`,
|
|
560
|
+
`- Informs: ${source.notes || 'visual direction'}`,
|
|
561
|
+
...(generated ? [
|
|
562
|
+
`- Generated by: ${generated.tool || '—'}`,
|
|
563
|
+
`- Model: ${generated.model || '—'}`,
|
|
564
|
+
`- Prompt: ${generated.prompt || '—'}`,
|
|
565
|
+
`- Seed: ${generated.seed || '—'}`,
|
|
566
|
+
`- Dimensions: ${generated.width || '—'} × ${generated.height || '—'}`,
|
|
567
|
+
`- C2PA present: ${generated.c2pa ? 'yes' : 'unknown'}`,
|
|
568
|
+
] : []),
|
|
569
|
+
'',
|
|
570
|
+
].join('\n');
|
|
571
|
+
}
|