Advance Blender 5.2 web parity through M12-03D
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions

This commit is contained in:
mes123456
2026-08-17 17:30:27 -04:00
parent 0fe8d2bb56
commit 5a11045ca5
148 changed files with 11683 additions and 66 deletions

View File

@@ -0,0 +1,136 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const manifestPath = path.join(root, "tests/golden/M12-01A/asset-catalog-field-inventory.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const sourceByRole = new Map();
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
assert.equal(manifest.schemaVersion, 1);
assert.equal(manifest.task, "M12-01A");
assert.equal(manifest.enablingTask, true);
assert.equal(manifest.parityStateChange, false);
assert.equal(manifest.blenderVersion, "5.2.0");
assert.equal(manifest.nextTask, "M12-01B");
const expectedRoles = [
"DNA_ASSET_STORAGE",
"RNA_ASSET_API",
"ASSET_BLEND_IO",
"CATALOG_MODEL",
"CATALOG_PATH",
"CATALOG_FILE_API",
"CATALOG_FILE_FORMAT",
"CURRENT_WEB_V1_SNAPSHOT",
];
assert.deepEqual(manifest.sources.map((source) => source.role), expectedRoles);
for (const source of manifest.sources) {
const absolutePath = path.join(root, source.path);
const bytes = fs.readFileSync(absolutePath);
assert.equal(sha256(bytes), source.sha256, `${source.role} source hash drifted`);
sourceByRole.set(source.role, bytes.toString("utf8"));
}
const expectedTagFields = ["next", "prev", "name"];
const expectedDNAFields = [
"local_type_info",
"properties",
"catalog_id",
"catalog_simple_name",
"author",
"description",
"copyright",
"license",
"tags",
"active_tag",
"tot_tags",
"flag",
"preferred_import_method",
"_pad",
];
assert.deepEqual(manifest.assetTag.fields.map((field) => field.name), expectedTagFields);
assert.deepEqual(manifest.assetMetaData.dnaFields.map((field) => field.name), expectedDNAFields);
assert.equal(new Set(expectedDNAFields).size, manifest.assetMetaData.dnaFields.length);
const dna = sourceByRole.get("DNA_ASSET_STORAGE");
for (const field of [...manifest.assetTag.fields, ...manifest.assetMetaData.dnaFields]) {
assert.ok(dna.includes(field.sourceToken), `DNA token for ${field.name} is missing`);
}
assert.equal(manifest.assetTag.fields.find((field) => field.name === "name").maximumStorageBytesIncludingNull, 64);
assert.equal(manifest.assetMetaData.dnaFields.find((field) => field.name === "properties").semantic, "CUSTOM_METADATA_NO_ID_POINTERS");
assert.equal(manifest.assetMetaData.dnaFields.find((field) => field.name === "catalog_simple_name").semantic, "RECOVERY_ONLY_NOT_AUTHORITY");
const rna = sourceByRole.get("RNA_ASSET_API");
const assetDataDefinition = rna.slice(rna.indexOf("static void rna_def_asset_data"), rna.indexOf("static void rna_def_asset_representation"));
const sourceRNAProperties = [...assetDataDefinition.matchAll(/RNA_def_property\(srna, "([^"]+)"/g)].map((match) => match[1]);
assert.deepEqual(sourceRNAProperties, manifest.assetMetaData.rnaProperties.map((property) => property.identifier));
assert.match(rna, /RNA_def_property_string_maxlength\(prop, MAX_NAME\)/);
assert.match(rna, /RNA_def_function\(srna, "new", "rna_AssetMetaData_tag_new"\)/);
assert.match(rna, /RNA_def_function\(srna, "remove", "rna_AssetMetaData_tag_remove"\)/);
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
const python = [
"import bpy,json",
"props=[{'identifier':p.identifier,'type':p.type,'isReadonly':p.is_readonly,'isRuntime':p.is_runtime} for p in bpy.types.AssetMetaData.bl_rna.properties if p.identifier != 'rna_type']",
"print('M12_RNA='+json.dumps(props,separators=(',',':')))",
].join(";");
const runtimeOutput = execFileSync(blender, ["--factory-startup", "--background", "--python-expr", python], { encoding: "utf8" });
const runtimeLine = runtimeOutput.split(/\r?\n/).find((line) => line.startsWith("M12_RNA="));
assert.ok(runtimeLine, "Blender AssetMetaData RNA report is missing");
assert.deepEqual(JSON.parse(runtimeLine.slice("M12_RNA=".length)), manifest.assetMetaData.rnaProperties);
const blendIO = sourceByRole.get("ASSET_BLEND_IO");
for (const value of ["properties", "author", "description", "copyright", "license", "tags"]) {
assert.match(blendIO, new RegExp(`asset_data->${value}`), `${value} is not bound to Blend IO`);
}
assert.match(blendIO, /asset_data->tags\.count\(\) == asset_data->tot_tags/);
const catalog = sourceByRole.get("CATALOG_MODEL");
for (const field of [...manifest.catalog.semanticFields, ...manifest.catalog.runtimeFlags]) {
assert.ok(catalog.includes(field.sourceToken), `catalog token for ${field.name} is missing`);
}
assert.deepEqual(manifest.catalog.definitionFile.recordFields, ["catalog_id", "path", "simple_name"]);
assert.deepEqual(manifest.catalog.definitionFile.writeOrder, ["path", "is_first_loaded", "catalog_id"]);
assert.deepEqual(manifest.catalog.identityRules.duplicatePathSelectionOrder, ["is_first_loaded", "catalog_id"]);
const catalogFile = sourceByRole.get("CATALOG_FILE_FORMAT");
assert.match(catalogFile, /SUPPORTED_VERSION = 1/);
assert.match(catalogFile, /VERSION_MARKER = "VERSION "/);
assert.match(catalogFile, /catalog->catalog_id << ":" << catalog->path << ":" << catalog->simple_name/);
const catalogPath = sourceByRole.get("CATALOG_PATH");
for (const statement of [
"Only slashes are used as path component separators",
"Paths are stored as byte sequences, and assumed to be UTF8",
"Empty components (caused by double slashes or leading/trailing slashes) are removed",
]) assert.ok(catalogPath.includes(statement));
const webPath = path.join(root, manifest.sources.find((source) => source.role === "CURRENT_WEB_V1_SNAPSHOT").path);
const webSource = ts.createSourceFile(webPath, fs.readFileSync(webPath, "utf8"), ts.ScriptTarget.Latest, true);
function interfaceFields(name) {
const declaration = webSource.statements.find((statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === name);
assert.ok(declaration, `missing Web interface ${name}`);
return declaration.members.filter(ts.isPropertySignature).map((member) => member.name.getText(webSource));
}
assert.deepEqual(interfaceFields("AssetCatalogIR"), manifest.currentWebSnapshot.assetCatalogIRFields);
assert.deepEqual(interfaceFields("AssetEntryIR"), manifest.currentWebSnapshot.assetEntryIRFields);
assert.deepEqual(manifest.currentWebSnapshot.missingMetadata, [
"properties",
"description",
"copyright",
"catalog_simple_name",
"active_tag",
"use_preferred_import_method",
"preferred_import_method",
]);
assert.equal(manifest.currentWebSnapshot.semanticDrift.length, 5);
process.stdout.write(
`asset-catalog-field-inventory-ok dna=${expectedDNAFields.length} rna=${sourceRNAProperties.length} tag=${expectedTagFields.length} catalog=${manifest.catalog.semanticFields.length} gaps=${manifest.currentWebSnapshot.missingMetadata.length} next=${manifest.nextTask}\n`,
);

View File

@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const read = (file) => JSON.parse(fs.readFileSync(path.join(root, file), "utf8"));
const sha256File = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
const stableJSON = (value) => {
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
return JSON.stringify(value);
};
const sha256Value = (value) => crypto.createHash("sha256").update(stableJSON(value)).digest("hex");
const checkArtifact = (artifact) => assert.equal(sha256File(artifact.path), artifact.sha256, artifact.path);
const evidence = read("tests/golden/M12-01/evidence.json");
assert.equal(evidence.task, "M12-01I");
assert.equal(evidence.status, "READY");
assert.equal(evidence.enablingTask, true);
assert.equal(evidence.parityStateChange, false);
assert.equal(evidence.nextTask, "M12-02A");
assert.equal(evidence.subtasks.length, 8);
const taskNames = evidence.subtasks.map((entry) => entry.task);
assert.deepEqual(taskNames, ["M12-01A", "M12-01B", "M12-01C", "M12-01D", "M12-01E", "M12-01F", "M12-01G", "M12-01H"]);
evidence.subtasks.forEach((entry, index) => {
checkArtifact(entry);
const manifest = read(entry.path);
assert.equal(manifest.task, entry.task);
assert.equal(manifest.enablingTask, true);
assert.equal(manifest.parityStateChange, false);
assert.equal(manifest.nextTask, index === evidence.subtasks.length - 1 ? "M12-01I" : evidence.subtasks[index + 1].task);
if (Array.isArray(manifest.sources)) manifest.sources.forEach(checkArtifact);
if (record(manifest.artifacts)) Object.values(manifest.artifacts).forEach(checkArtifact);
});
Object.values(evidence.schema).forEach(checkArtifact);
for (const key of ["sourceFixture", "targetFixture", "report", "productionProtocol"]) checkArtifact(evidence.migration[key]);
for (const key of ["indexedDB", "restartWorker"]) checkArtifact(evidence.runtime[key]);
const source = read(evidence.migration.sourceFixture.path);
const target = read(evidence.migration.targetFixture.path);
const report = read(evidence.migration.report.path);
assert.equal(source.schemaVersion, 1);
assert.equal(target.schemaVersion, 2);
assert.equal(source.revision, evidence.migration.revision);
assert.equal(target.revision, evidence.migration.revision);
assert.equal(sha256Value(source), evidence.migration.sourceManifestSha256);
assert.equal(sha256Value(target), evidence.migration.targetManifestSha256);
assert.equal(report.sourceManifestSha256, evidence.migration.sourceManifestSha256);
assert.equal(report.targetManifestSha256, evidence.migration.targetManifestSha256);
assert.equal(report.sourceRevision, evidence.migration.revision);
assert.equal(report.targetRevision, evidence.migration.revision);
assert.equal(report.preserved.catalogs, evidence.runtime.catalogCount);
assert.equal(report.preserved.assets, evidence.runtime.assetCount);
const restart = read("tests/golden/M12-01H/manifest.json").restart;
assert.deepEqual(restart.catalogOrder, target.catalogs.map((catalog) => catalog.catalogId));
assert.deepEqual(restart.assetOrder, target.assets.map((asset) => asset.assetId));
assert.equal(restart.manifestSha256, evidence.migration.targetManifestSha256);
assert.deepEqual(restart.contexts, evidence.runtime.restartContexts);
assert.deepEqual(read("tests/golden/M12-01G/manifest.json").transaction.faultPoints, evidence.runtime.faultPoints);
process.stdout.write(`asset-catalog-m12-evidence-ok subtasks=${evidence.subtasks.length} catalogs=${evidence.runtime.catalogCount} assets=${evidence.runtime.assetCount} next=${evidence.nextTask}\n`);

View File

@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const manifestPath = path.join(root, "tests/golden/M12-01B/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const artifact = (name) => path.join(root, manifest.artifacts[name].path);
assert.equal(manifest.schemaVersion, 1);
assert.equal(manifest.task, "M12-01B");
assert.equal(manifest.enablingTask, true);
assert.equal(manifest.parityStateChange, false);
assert.equal(manifest.nextTask, "M12-01C");
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
for (const name of ["generator", "exporter", "checker", "fixture", "catalogDefinition", "canonicalReport"]) {
assert.equal(sha256(artifact(name)), manifest.artifacts[name].sha256, `${name} hash drifted`);
}
const expected = JSON.parse(fs.readFileSync(artifact("canonicalReport"), "utf8"));
assert.equal(expected.schemaVersion, 1);
assert.equal(expected.blenderVersion, "5.2.0");
assert.equal(expected.catalogDefinition.version, 1);
assert.equal(expected.catalogDefinition.records.length, 3);
assert.deepEqual(expected.catalogDefinition.records.map((item) => item.path), ["Characters", "Characters/Heroes", "Materials/Metal"]);
assert.deepEqual(expected.catalogDefinition.records.map((item) => item.parentPath), [null, "Characters", "Materials"]);
assert.equal(expected.assets.length, 3);
assert.deepEqual(expected.assets.map((item) => `${item.idType}:${item.name}`), [
"MATERIAL:M12 Brushed Metal",
"OBJECT:M12 Hero",
"WORLD:M12 Uncataloged World",
]);
const hero = expected.assets.find((item) => item.name === "M12 Hero");
assert.deepEqual(hero.tags, ["character", "hero", "rig-ready"]);
assert.equal(hero.activeTag, 1);
assert.equal(hero.catalogId, "22222222-2222-4222-8222-222222222222");
assert.equal(hero.catalogSimpleName, "");
assert.equal(hero.usePreferredImportMethod, true);
assert.equal(hero.preferredImportMethod, "APPEND");
assert.deepEqual(hero.customProperties.map((item) => item.name), ["approved", "dimensions", "rating", "source"]);
assert.deepEqual(hero.customProperties.find((item) => item.name === "dimensions").value, [2, 2, 0]);
const material = expected.assets.find((item) => item.name === "M12 Brushed Metal");
assert.equal(material.author, "");
assert.equal(material.license, "");
const world = expected.assets.find((item) => item.name === "M12 Uncataloged World");
assert.equal(world.catalogId, "00000000-0000-0000-0000-000000000000");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-asset-catalog-v1-"));
try {
execFileSync(blender, [
"--background", "--factory-startup", "--python", artifact("generator"), "--", temporary,
], { cwd: root, stdio: "pipe" });
const generatedFixture = path.join(temporary, "m12_asset_catalog_v1.blend");
const generatedCatalog = path.join(temporary, "blender_assets.cats.txt");
const generatedReport = path.join(temporary, "canonical.json");
assert.ok(fs.statSync(generatedFixture).size > 0);
assert.equal(fs.readFileSync(generatedCatalog, "utf8"), fs.readFileSync(artifact("catalogDefinition"), "utf8"));
execFileSync(blender, [
"--background", generatedFixture, "--python", artifact("exporter"), "--", generatedCatalog, generatedReport,
], { cwd: root, stdio: "pipe" });
assert.deepEqual(JSON.parse(fs.readFileSync(generatedReport, "utf8")), expected);
const reopenedReport = path.join(temporary, "checked-in-canonical.json");
execFileSync(blender, [
"--background", artifact("fixture"), "--python", artifact("exporter"), "--",
artifact("catalogDefinition"), reopenedReport,
], { cwd: root, stdio: "pipe" });
assert.deepEqual(JSON.parse(fs.readFileSync(reopenedReport, "utf8")), expected);
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
process.stdout.write(`asset-catalog-v1-fixture-ok catalogs=3 assets=3 metadata=complete next=${manifest.nextTask}\n`);

View File

@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02H/manifest.json"), "utf8"));
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02H/desktop-report.json"), "utf8"));
const content = path.join(root, "tests/golden/M12-02B/preview.png");
const generator = path.join(root, "tools/web/generate-asset-preview-identity.py");
const source = path.join(root, "tests/files/web/media/sequencer-frame.png");
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-preview-display-"));
try {
const regenerated = path.join(temporary, "preview.png");
const raw = path.join(temporary, "preview.rgba8");
const output = execFileSync(blender, ["--background", "--factory-startup", "--python", generator, "--", source, regenerated], {
cwd: root,
encoding: "utf8",
});
assert.match(output, /asset-preview-generated width=8 height=8/);
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
assert.equal(sha256(fs.readFileSync(regenerated)), report.contentSha256);
execFileSync("magick", [regenerated, "-depth", "8", `RGBA:${raw}`], { cwd: root, stdio: "pipe" });
const pixels = fs.readFileSync(raw);
assert.equal(pixels.byteLength, report.pixelCount * 4);
assert.equal(sha256(pixels), report.rgbaSha256);
const unique = new Set();
for (let offset = 0; offset < pixels.byteLength; offset += 4) {
const pixel = Array.from(pixels.subarray(offset, offset + 4));
unique.add(pixel.join(","));
assert.deepEqual(pixel, report.referencePixel);
}
assert.equal(unique.size, report.uniquePixelCount);
for (const artifact of Object.values(manifest.artifacts)) {
assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, artifact.path);
}
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
process.stdout.write(`asset-preview-display-desktop-ok size=${report.width}x${report.height} rgba=${report.rgbaSha256} next=${manifest.nextTask}\n`);

View File

@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02B/manifest.json"), "utf8"));
const identity = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02B/identity.json"), "utf8"));
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const blender = process.env.BLENDER_BIN ?? path.join(root, manifest.artifacts.blender.path);
const source = path.join(root, manifest.artifacts.source.path);
const generator = path.join(root, manifest.artifacts.generator.path);
const golden = path.join(root, manifest.artifacts.content.path);
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
assert.equal(sha256(blender), identity.generator.executableSha256);
assert.equal(sha256(generator), identity.generator.scriptSha256);
assert.equal(sha256(source), identity.source.sha256);
assert.equal(fs.statSync(source).size, identity.source.byteLength);
assert.equal(sha256(golden), identity.content.sha256);
assert.equal(fs.statSync(golden).size, identity.content.byteLength);
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-asset-preview-identity-"));
try {
const first = path.join(temporary, "first.png");
const second = path.join(temporary, "second.png");
for (const output of [first, second]) {
execFileSync(blender, ["--background", "--factory-startup", "--python", generator, "--", source, output], { cwd: root, stdio: "pipe" });
assert.equal(sha256(output), identity.content.sha256);
assert.equal(fs.statSync(output).size, identity.content.byteLength);
}
const probe = [
"import bpy,json",
`a=bpy.data.images.load(${JSON.stringify(source)},check_existing=False)`,
`b=bpy.data.images.load(${JSON.stringify(first)},check_existing=False)`,
"print('M12_PREVIEW_COMPARE='+json.dumps({'source':list(a.size),'content':list(b.size),'max':max(abs(x-y) for x,y in zip(a.pixels[:],b.pixels[:]))},separators=(',',':')))",
].join(";");
const output = execFileSync(blender, ["--background", "--factory-startup", "--python-expr", probe], { cwd: root, encoding: "utf8" });
const line = output.split(/\r?\n/).find((item) => item.startsWith("M12_PREVIEW_COMPARE="));
assert.ok(line);
assert.deepEqual(JSON.parse(line.slice("M12_PREVIEW_COMPARE=".length)), { source: [8, 8], content: [8, 8], max: 0 });
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
process.stdout.write(`asset-preview-identity-ok source=${identity.source.sha256} content=${identity.content.sha256} size=${identity.content.width}x${identity.content.height} next=${manifest.nextTask}\n`);

View File

@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const inventory = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02A/asset-preview-inventory.json"), "utf8"));
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
assert.equal(inventory.task, "M12-02A");
assert.equal(inventory.enablingTask, true);
assert.equal(inventory.parityStateChange, false);
assert.equal(inventory.blenderVersion, "5.2.0");
assert.equal(inventory.nextTask, "M12-02B");
for (const item of [...inventory.sources, ...inventory.fixtures]) assert.equal(sha256(item.path), item.sha256, item.path);
const source = (role) => fs.readFileSync(path.join(root, inventory.sources.find((item) => item.role === role).path), "utf8");
const dna = source("PREVIEW_DNA");
for (const token of ["unsigned int w[2]", "unsigned int h[2]", "short flag[2]", "short changed_timestamp[2]", "unsigned int *rect[2]", "PreviewImageRuntime *runtime"]) {
assert.ok(dna.includes(token), `PreviewImage DNA token is missing: ${token}`);
}
const slots = source("PREVIEW_SLOT_ENUM");
assert.match(slots, /ICON_SIZE_ICON = 0/);
assert.match(slots, /ICON_SIZE_PREVIEW = 1/);
assert.equal(inventory.storage.slotCount, 2);
const implementation = source("PREVIEW_IMPLEMENTATION");
assert.match(implementation, /PreviewImage assumes pre-multiplied alpha/);
assert.match(implementation, /writer->write_uint32_array\(prv_copy\.w\[0\] \* prv_copy\.h\[0\]/);
assert.match(implementation, /writer->write_uint32_array\(prv_copy\.w\[1\] \* prv_copy\.h\[1\]/);
assert.match(implementation, /prv->flag\[i\] &= ~PRV_RENDERING/);
assert.equal(inventory.storage.colorSpace, null);
assert.ok(!inventory.storage.fields.some((field) => /color/i.test(field.name)));
const rnaSource = source("PREVIEW_RNA");
assert.match(rnaSource, /Image pixels, as bytes \(always 32-bit RGBA\)/);
assert.match(rnaSource, /length\[0\] = prv_img->w\[size\] \* prv_img->h\[size\] \* 4/);
assert.match(rnaSource, /values\[i\] = data\[i\] \* \(1\.0f \/ 255\.0f\)/);
assert.doesNotMatch(rnaSource.slice(rnaSource.indexOf("static void rna_def_image_preview"), rnaSource.indexOf("static void rna_def_image_user")), /color.?space/i);
const parseInterface = (file, name) => {
const absolute = path.join(root, file);
const parsed = ts.createSourceFile(absolute, fs.readFileSync(absolute, "utf8"), ts.ScriptTarget.Latest, true);
const declaration = parsed.statements.find((statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === name);
assert.ok(declaration, `${name} is missing`);
return declaration.members.filter(ts.isPropertySignature).map((member) => member.name.getText(parsed));
};
assert.deepEqual(parseInterface("web/protocol/asset-library-io.ts", "AssetPreviewIR"), inventory.currentWeb.v1Fields);
assert.deepEqual(parseInterface("web/protocol/asset-catalog-v2.ts", "AssetCatalogV2PreviewIR"), inventory.currentWeb.v2Fields);
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
const rnaProbe = [
"import bpy,json",
"props=[{'identifier':p.identifier,'type':p.type,'readOnly':p.is_readonly,'arrayLength':p.array_length} for p in bpy.types.ImagePreview.bl_rna.properties if p.identifier != 'rna_type']",
"idp=bpy.types.ID.bl_rna.properties['preview']",
"print('M12_PREVIEW_RNA='+json.dumps({'idProperty':{'identifier':idp.identifier,'type':idp.type,'readOnly':idp.is_readonly,'nullWhenAbsent':True},'imagePreviewProperties':props},separators=(',',':')))",
].join(";");
const rnaOutput = execFileSync(blender, ["--factory-startup", "--background", "--python-expr", rnaProbe], { encoding: "utf8" });
const rnaLine = rnaOutput.split(/\r?\n/).find((line) => line.startsWith("M12_PREVIEW_RNA="));
assert.ok(rnaLine);
assert.deepEqual(JSON.parse(rnaLine.slice("M12_PREVIEW_RNA=".length)), {
idProperty: inventory.rna.idProperty,
imagePreviewProperties: inventory.rna.imagePreviewProperties,
});
const noPreviewScript = [
"import bpy,json",
"items=[]",
"groups=(bpy.data.objects,bpy.data.materials,bpy.data.worlds)",
"[items.append({'type':item.bl_rna.identifier,'preview':None if item.preview is None else list(item.preview.image_size)}) for group in groups for item in group if item.asset_data]",
"print('M12_NO_PREVIEW='+json.dumps(items,separators=(',',':')))",
].join(";");
const noPreviewOutput = execFileSync(blender, ["--factory-startup", "--background", path.join(root, inventory.fixtures[1].path), "--python-expr", noPreviewScript], { encoding: "utf8" });
const noPreviewLine = noPreviewOutput.split(/\r?\n/).find((line) => line.startsWith("M12_NO_PREVIEW="));
assert.ok(noPreviewLine);
const noPreview = JSON.parse(noPreviewLine.slice("M12_NO_PREVIEW=".length));
assert.deepEqual(noPreview.map((item) => item.type), inventory.runtimeStates.noPreview.assetTypes);
assert.deepEqual(noPreview.map((item) => item.preview), inventory.runtimeStates.noPreview.previewValues);
const png = path.join(root, inventory.fixtures[0].path);
const loadedScript = [
"import bpy,json,bpy.utils.previews",
"collection=bpy.utils.previews.new()",
`preview=collection.load('m12-preview',${JSON.stringify(png)},'IMAGE',True)`,
"result={'sourceSize':[8,8],'imageSize':list(preview.image_size),'iconSize':list(preview.icon_size),'imagePackedPixelCount':len(preview.image_pixels),'imageFloatComponentCount':len(preview.image_pixels_float),'iconPackedPixelCount':len(preview.icon_pixels),'imageCustom':preview.is_image_custom,'iconCustom':preview.is_icon_custom}",
"print('M12_LOADED_PREVIEW='+json.dumps(result,separators=(',',':')))",
"bpy.utils.previews.remove(collection)",
].join(";");
const loadedOutput = execFileSync(blender, ["--factory-startup", "--background", "--python-expr", loadedScript], { encoding: "utf8" });
const loadedLine = loadedOutput.split(/\r?\n/).find((line) => line.startsWith("M12_LOADED_PREVIEW="));
assert.ok(loadedLine);
assert.deepEqual(JSON.parse(loadedLine.slice("M12_LOADED_PREVIEW=".length)), inventory.runtimeStates.loadedPreview);
process.stdout.write(`asset-preview-inventory-ok slots=${inventory.storage.slotCount} rna=${inventory.rna.imagePreviewProperties.length} absent=${noPreview.length} loaded=${inventory.runtimeStates.loadedPreview.imageSize.join("x")} next=${inventory.nextTask}\n`);

View File

@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const reportPath = path.join(root, "tests/golden/M12-03C/desktop-append-report.json");
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03C/manifest.json"), "utf8"));
const fixtureRoot = path.join(root, "tests/files/web/m12_library_append_v1");
const generator = path.join(root, "tools/web/generate-library-append-fixture.py");
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
assert.equal(manifest.task, "M12-03C");
assert.equal(manifest.nextTask, "M12-03D");
for (const artifact of Object.values(manifest.artifacts)) {
assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, artifact.path);
}
assert.equal(report.schemaVersion, 1);
assert.equal(report.task, "M12-03C");
assert.equal(report.operation, "APPEND");
assert.equal(report.blenderVersion, "5.2.0");
assert.equal(report.nextTask, "M12-03D");
assert.deepEqual(report.selectedRoots, ["Object/M12 Append Object"]);
assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.source.file))), report.source.sha256);
assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.target.file))), report.target.sha256);
assert.deepEqual(report.sourceGraph, report.appendedGraph);
assert.deepEqual(report.appendedGraph.edges, [
{ from: "Object/M12 Append Object", relation: "OBJECT_DATA", to: "Mesh/M12 Append Mesh" },
{ from: "Mesh/M12 Append Mesh", relation: "MATERIAL_SLOT[0]", to: "Material/M12 Append Material" },
{ from: "Material/M12 Append Material", relation: "NODE_IMAGE[M12 Append Image Node]", to: "Image/M12 Append Image" },
]);
assert.deepEqual(Object.keys(report.appendedGraph.ids).sort(), ["IMAGE", "MATERIAL", "MESH", "OBJECT"]);
for (const value of Object.values(report.appendedGraph.ids)) {
assert.equal(value.library, null);
assert.equal(value.isLibraryOverride, false);
}
assert.deepEqual(report.appendedGraph.geometry, {
edges: 4,
loops: 4,
materialSlots: ["M12 Append Material"],
polygons: 1,
uvLayers: ["UVMap"],
vertices: 4,
});
assert.deepEqual(report.appendedGraph.image.size, [2, 2]);
assert.equal(report.appendedGraph.image.channels, 4);
assert.equal(report.appendedGraph.image.colorspace, "sRGB");
assert.equal(report.appendedGraph.image.packed, true);
assert.match(report.appendedGraph.image.pixelFloat32Sha256, /^[a-f0-9]{64}$/);
assert.deepEqual(report.stableMapping.map((item) => [item.owner, item.readOnly]), [
["LOCAL_MAIN", false],
["LOCAL_MAIN", false],
["LOCAL_MAIN", false],
["LOCAL_MAIN", false],
]);
assert.deepEqual(report.stableMapping.map((item) => item.source), [
"Object/M12 Append Object",
"Mesh/M12 Append Mesh",
"Material/M12 Append Material",
"Image/M12 Append Image",
]);
assert.deepEqual(report.stableMapping.map((item) => item.local), report.stableMapping.map((item) => item.source));
const normalizeContainerHashes = (value) => ({
...value,
source: { ...value.source, sha256: "<SESSION_BOUND_BLEND_CONTAINER>" },
target: { ...value.target, sha256: "<SESSION_BOUND_BLEND_CONTAINER>" },
});
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-library-append-"));
try {
const generatedFixtureRoot = path.join(temporary, "files");
const generatedReportPath = path.join(temporary, "report.json");
const output = execFileSync(blender, [
"--background",
"--factory-startup",
"--python",
generator,
"--",
generatedFixtureRoot,
generatedReportPath,
], { cwd: root, encoding: "utf8" });
assert.match(output, /library-append-fixture-ok roots=1 mapping=4/);
assert.match(output, /next=M12-03D/);
const generated = JSON.parse(fs.readFileSync(generatedReportPath, "utf8"));
assert.deepEqual(normalizeContainerHashes(generated), normalizeContainerHashes(report));
assert.equal(sha256(fs.readFileSync(path.join(generatedFixtureRoot, generated.source.file))), generated.source.sha256);
assert.equal(sha256(fs.readFileSync(path.join(generatedFixtureRoot, generated.target.file))), generated.target.sha256);
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
process.stdout.write(
`library-append-fixture-check-ok roots=${report.selectedRoots.length} mapping=${report.stableMapping.length} edges=${report.appendedGraph.edges.length} pixels=${report.appendedGraph.image.pixelFloat32Sha256} next=${report.nextTask}\n`,
);

View File

@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const inventory = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03A/library-operation-inventory.json"), "utf8"));
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const sources = new Map();
assert.equal(inventory.schemaVersion, 1);
assert.equal(inventory.task, "M12-03A");
assert.equal(inventory.enablingTask, true);
assert.equal(inventory.parityStateChange, false);
assert.equal(inventory.blenderVersion, "5.2.0");
assert.equal(inventory.nextTask, "M12-03B");
assert.deepEqual(inventory.sources.map((source) => source.role), [
"LINK_APPEND_CONTEXT_API",
"LINK_APPEND_CLOSURE",
"LINK_APPEND_OPERATORS",
"PYTHON_LIBRARY_LOAD",
"BLEND_LIBRARY_READER",
"OVERRIDE_STORAGE",
"OVERRIDE_API",
"OVERRIDE_CLOSURE",
"CURRENT_WEB_LIBRARY_GATE",
]);
for (const source of inventory.sources) {
const bytes = fs.readFileSync(path.join(root, source.path));
assert.equal(sha256(bytes), source.sha256, `${source.role} source hash drifted`);
sources.set(source.role, bytes.toString("utf8"));
}
assert.equal(sha256(fs.readFileSync(path.join(root, inventory.runtimeFixture.path))), inventory.runtimeFixture.sha256);
assert.equal(inventory.dataBlockSurface.discoverableCollections.length, 36);
assert.equal(new Set(inventory.dataBlockSurface.discoverableCollections).size, 36);
assert.deepEqual(inventory.dataBlockSurface.onlyAppendableCollections, ["screens", "workspaces"]);
assert.deepEqual(inventory.operations.map((operation) => operation.operation), ["APPEND", "LINK", "LIBRARY_OVERRIDE"]);
for (const operation of inventory.operations) {
assert.ok(operation.dataBlockRoles.length >= 6, `${operation.operation} has an incomplete role inventory`);
assert.ok(operation.closure && Object.keys(operation.closure).length >= 5, `${operation.operation} has an incomplete closure inventory`);
}
const context = sources.get("LINK_APPEND_CONTEXT_API");
for (const token of [
"LINK_APPEND_ACT_KEEP_LINKED",
"LINK_APPEND_ACT_REUSE_LOCAL",
"LINK_APPEND_ACT_MAKE_LOCAL",
"LINK_APPEND_ACT_COPY_LOCAL",
"LINK_APPEND_TAG_INDIRECT",
"LINK_APPEND_TAG_LIBOVERRIDE_DEPENDENCY",
"LINK_APPEND_TAG_LIBOVERRIDE_DEPENDENCY_ONLY",
"ID *new_id",
"Library *source_library",
"ID *liboverride_id",
"ID *reusable_local_id",
"enum class ProcessStage",
]) assert.ok(context.includes(token), `link/append context token is missing: ${token}`);
const closure = sources.get("LINK_APPEND_CLOSURE");
for (const token of [
"BKE_library_foreach_ID_link",
"IDWALK_CB_EMBEDDED",
"IDWALK_CB_EMBEDDED_NOT_OWNING",
"IDWALK_CB_INTERNAL",
"IDWALK_CB_LOOPBACK",
"LINK_APPEND_TAG_LIBOVERRIDE_DEPENDENCY_ONLY",
"new_id->lib->runtime->parent",
]) assert.ok(closure.includes(token), `dependency closure token is missing: ${token}`);
assert.match(closure, /BKE_blendfile_append\(/);
assert.match(closure, /BKE_blendfile_override\(/);
const operators = sources.get("LINK_APPEND_OPERATORS");
for (const token of [
"BKE_idtype_idcode_is_linkable(idcode)",
"BKE_idtype_idcode_is_only_appendable(idcode)",
"BLO_LIBLINK_APPEND_RECURSIVE",
"BLO_LIBLINK_APPEND_LOCAL_ID_REUSE",
"void WM_OT_link",
"void WM_OT_append",
]) assert.ok(operators.includes(token), `operator inventory token is missing: ${token}`);
const pythonLoad = sources.get("PYTHON_LIBRARY_LOAD");
for (const argument of inventory.operatorSurface.pythonLoadArguments) {
assert.ok(pythonLoad.includes(`\"${argument}\"`), `Python load argument is missing: ${argument}`);
}
for (const token of [
"if (!is_library && !BKE_idtype_idcode_is_linkable(code))",
"if (!BKE_idtype_idcode_is_linkable(idcode) || (idcode == ID_WS && !do_append))",
"create_liboverrides",
"BKE_blendfile_link(lapp_context",
"BKE_blendfile_append(lapp_context",
"BKE_blendfile_override(lapp_context",
]) assert.ok(pythonLoad.includes(token), `Python library closure token is missing: ${token}`);
const reader = sources.get("BLEND_LIBRARY_READER");
for (const token of ["BLO_library_link_begin", "BLO_library_link_named_part", "BLO_library_link_end"]) {
assert.ok(reader.includes(token), `reader stage is missing: ${token}`);
}
const overrideStorage = sources.get("OVERRIDE_STORAGE");
for (const token of [
"struct IDOverrideLibraryPropertyOperation",
"struct IDOverrideLibraryProperty",
"struct IDOverrideLibrary",
"ID *reference",
"ID *hierarchy_root",
"ListBaseT<IDOverrideLibraryProperty> properties",
]) assert.ok(overrideStorage.includes(token), `override storage token is missing: ${token}`);
const overrideApi = sources.get("OVERRIDE_API");
for (const token of [
"BKE_lib_override_library_create(",
"BKE_lib_override_library_resync(",
"BKE_lib_override_library_operations_create(",
]) assert.ok(overrideApi.includes(token), `override API token is missing: ${token}`);
assert.match(sources.get("OVERRIDE_CLOSURE"), /BKE_library_foreach_ID_link/);
const web = sources.get("CURRENT_WEB_LIBRARY_GATE");
assert.match(web, /gateLibraryMutation\(operation:/);
assert.match(web, /LIBRARY_MUTATION_UNAVAILABLE/);
for (const gap of inventory.currentWebGap.missing) assert.ok(!inventory.currentWebGap.represented.includes(gap));
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
const script = [
"import bpy,json",
`fixture=${JSON.stringify(path.join(root, inventory.runtimeFixture.path))}`,
"result={}",
"result['appendProperties']=[p.identifier for p in bpy.ops.wm.append.get_rna_type().properties if p.identifier != 'rna_type']",
"result['linkProperties']=[p.identifier for p in bpy.ops.wm.link.get_rna_type().properties if p.identifier != 'rna_type']",
"ctx=bpy.data.libraries.load(fixture,link=True)",
"source,target=ctx.__enter__()",
"result['collections']=sorted([name for name in dir(source) if not name.startswith('_') and name not in {'libraries','version'}])",
"result['libraries']=[{'filepath':item.filepath,'isArchive':item.is_archive} for item in source.libraries]",
"ctx.__exit__(None,None,None)",
"result['overrideRNA']={name:[{'identifier':p.identifier,'type':p.type,'isReadonly':p.is_readonly} for p in getattr(bpy.types,name).bl_rna.properties if p.identifier != 'rna_type'] for name in ['IDOverrideLibrary','IDOverrideLibraryProperty','IDOverrideLibraryPropertyOperation']}",
"print('M12_03A='+json.dumps(result,separators=(',',':'),sort_keys=True))",
].join(";");
const runtimeOutput = execFileSync(blender, ["--background", "--factory-startup", "--python-expr", script], { cwd: root, encoding: "utf8" });
const runtimeLine = runtimeOutput.split(/\r?\n/).find((line) => line.startsWith("M12_03A="));
assert.ok(runtimeLine, "Blender link/append/override runtime inventory is missing");
const runtime = JSON.parse(runtimeLine.slice("M12_03A=".length));
assert.deepEqual(runtime.appendProperties, inventory.operatorSurface.appendProperties);
assert.deepEqual(runtime.linkProperties, inventory.operatorSurface.linkProperties);
assert.deepEqual(runtime.collections, inventory.dataBlockSurface.discoverableCollections);
assert.deepEqual(runtime.libraries, inventory.runtimeFixture.nestedLibraries);
assert.deepEqual(runtime.overrideRNA, inventory.overrideRNA);
process.stdout.write(
`library-operation-inventory-ok roots=${runtime.collections.length} appendOnly=${inventory.dataBlockSurface.onlyAppendableCollections.length} operations=${inventory.operations.length} overrideFields=${Object.values(runtime.overrideRNA).reduce((sum, values) => sum + values.length, 0)} gaps=${inventory.currentWebGap.missing.length} next=${inventory.nextTask}\n`,
);

View File

@@ -0,0 +1,105 @@
import json
import pathlib
import sys
import bpy
NIL_UUID = "00000000-0000-0000-0000-000000000000"
def catalog_records(path):
version = None
records = []
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if version is None:
if not line.startswith("VERSION "):
raise RuntimeError(f"catalog definition line {line_number} has no version marker")
version = int(line.removeprefix("VERSION "))
continue
catalog_id, catalog_path, simple_name = line.split(":", 2)
records.append({
"catalogId": catalog_id,
"path": catalog_path,
"simpleName": simple_name.strip(),
"parentPath": catalog_path.rpartition("/")[0] or None,
})
return version, records
def custom_property_value(value):
if isinstance(value, (bool, int, float, str)):
return value
if hasattr(value, "to_list"):
return value.to_list()
if isinstance(value, (list, tuple)):
return list(value)
raise RuntimeError(f"unsupported asset custom property type {type(value).__name__}")
def asset_record(id_type, asset):
metadata = asset.asset_data
custom_properties = [
{
"name": name,
"type": type(metadata[name]).__name__.upper(),
"value": custom_property_value(metadata[name]),
}
for name in sorted(metadata.keys())
]
return {
"idType": id_type,
"name": asset.name,
"catalogId": metadata.catalog_id or NIL_UUID,
"catalogSimpleName": metadata.catalog_simple_name,
"author": metadata.author,
"description": metadata.description,
"copyright": metadata.copyright,
"license": metadata.license,
"tags": [tag.name for tag in metadata.tags],
"activeTag": metadata.active_tag,
"usePreferredImportMethod": metadata.use_preferred_import_method,
"preferredImportMethod": metadata.preferred_import_method,
"customProperties": custom_properties,
}
def main(catalog_file, output_file):
catalog_path = pathlib.Path(catalog_file).resolve()
output_path = pathlib.Path(output_file).resolve()
version, catalogs = catalog_records(catalog_path)
assets = []
for id_type, collection in (
("MATERIAL", bpy.data.materials),
("OBJECT", bpy.data.objects),
("WORLD", bpy.data.worlds),
):
assets.extend(asset_record(id_type, asset) for asset in collection if asset.asset_data is not None)
assets.sort(key=lambda item: (item["idType"], item["name"]))
report = {
"schemaVersion": 1,
"task": "M12-01B",
"enablingTask": True,
"parityStateChange": False,
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
"catalogDefinition": {
"fileName": catalog_path.name,
"version": version,
"records": catalogs,
},
"assets": assets,
"nextTask": "M12-01C",
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(report, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n")
print(f"m12-asset-catalog-v1-canonical catalogs={len(catalogs)} assets={len(assets)} output={output_path}")
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(arguments) != 2:
raise SystemExit("usage: blender --background FIXTURE --python export-asset-catalog-v1.py -- CATALOG_FILE OUTPUT_JSON")
main(*arguments)

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const [sourceArg, outputArg] = process.argv.slice(2);
if (!sourceArg || !outputArg) throw new Error("usage: node generate-asset-catalog-compatibility.mjs V2_JSON OUTPUT_JSON");
const root = path.resolve(import.meta.dirname, "../..");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-catalog-compatibility-generator-"));
const sources = ["asset-path.ts", "capability-gates.ts", "asset-library-io.ts", "asset-catalog-v2.ts", "asset-catalog-compatibility.ts"];
try {
for (const sourceName of sources) {
const sourcePath = path.join(root, "web/protocol", sourceName);
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(result.diagnostics, []);
fs.writeFileSync(path.join(temporary, sourceName.replace(/\.ts$/, ".mjs")), result.outputText.replaceAll(/from "\.\/([a-z0-9-]+)"/g, 'from "./$1.mjs"'));
}
const compatibility = await import(pathToFileURL(path.join(temporary, "asset-catalog-compatibility.mjs")));
const value = JSON.parse(fs.readFileSync(path.resolve(sourceArg), "utf8"));
const output = {
read: await compatibility.inspectAssetCatalogForLegacyReader(value, "READ"),
catalogWrite: await compatibility.inspectAssetCatalogForLegacyReader(value, "CATALOG_WRITE"),
assetWrite: await compatibility.inspectAssetCatalogForLegacyReader(value, "ASSET_WRITE"),
save: await compatibility.inspectAssetCatalogForLegacyReader(value, "SAVE"),
future: await compatibility.inspectAssetCatalogForLegacyReader({ schemaVersion: 3 }, "READ"),
};
fs.mkdirSync(path.dirname(path.resolve(outputArg)), { recursive: true });
fs.writeFileSync(path.resolve(outputArg), `${JSON.stringify(output, null, 2)}\n`);
process.stdout.write("asset-catalog-compatibility-generated read=READ_ONLY writes=3/BLOCKED future=BLOCKED\n");
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const [sourceArg, targetArg, reportArg] = process.argv.slice(2);
if (!sourceArg || !targetArg || !reportArg) throw new Error("usage: node generate-asset-catalog-migration.mjs V1_JSON V2_JSON REPORT_JSON");
const root = path.resolve(import.meta.dirname, "../..");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-catalog-migration-generator-"));
const sources = ["asset-path.ts", "capability-gates.ts", "asset-library-io.ts", "asset-catalog-v2.ts", "asset-catalog-migration.ts"];
try {
for (const sourceName of sources) {
const sourcePath = path.join(root, "web/protocol", sourceName);
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(result.diagnostics, []);
const output = result.outputText.replaceAll(/from "\.\/([a-z0-9-]+)"/g, 'from "./$1.mjs"');
fs.writeFileSync(path.join(temporary, sourceName.replace(/\.ts$/, ".mjs")), output);
}
const migration = await import(pathToFileURL(path.join(temporary, "asset-catalog-migration.mjs")));
const value = JSON.parse(fs.readFileSync(path.resolve(sourceArg), "utf8"));
const result = await migration.migrateAssetCatalogV1ToV2(value);
fs.mkdirSync(path.dirname(path.resolve(targetArg)), { recursive: true });
fs.writeFileSync(path.resolve(targetArg), `${JSON.stringify(result.manifest, null, 2)}\n`);
fs.writeFileSync(path.resolve(reportArg), `${JSON.stringify(result.report, null, 2)}\n`);
process.stdout.write(`asset-catalog-migration-generated catalogs=${result.manifest.catalogs.length} assets=${result.manifest.assets.length} libraries=${result.manifest.libraries.length}\n`);
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,111 @@
import pathlib
import sys
import bpy
CATALOGS = (
("11111111-1111-4111-8111-111111111111", "Characters", "Characters"),
("22222222-2222-4222-8222-222222222222", "Characters/Heroes", "Heroes"),
("33333333-3333-4333-8333-333333333333", "Materials/Metal", "Metal"),
)
def set_metadata(asset, *, catalog_id, author, description, copyright_text, license_text,
tags, active_tag, preferred_import_method, properties):
metadata = asset.asset_data
metadata.catalog_id = catalog_id
metadata.author = author
metadata.description = description
metadata.copyright = copyright_text
metadata.license = license_text
for tag in tags:
metadata.tags.new(tag, skip_if_exists=False)
metadata.active_tag = active_tag
metadata.use_preferred_import_method = preferred_import_method is not None
if preferred_import_method is not None:
metadata.preferred_import_method = preferred_import_method
for name, value in properties.items():
metadata[name] = value
def write_catalog_definition(path):
lines = [
"# This is an Asset Catalog Definition file for Blender.",
"#",
"# Generated by M12-01B from a locked Blender 5.2 runtime.",
"",
"VERSION 1",
"",
]
lines.extend(f"{catalog_id}:{catalog_path}:{simple_name}" for catalog_id, catalog_path, simple_name in CATALOGS)
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
def main(output_directory):
output = pathlib.Path(output_directory).resolve()
output.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("M12 Hero Mesh")
mesh.from_pydata(((-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (0.0, 1.0, 0.0)), (), ((0, 1, 2),))
hero = bpy.data.objects.new("M12 Hero", mesh)
bpy.context.scene.collection.objects.link(hero)
hero.asset_mark()
set_metadata(
hero,
catalog_id=CATALOGS[1][0],
author="M12 Artist",
description="Desktop catalog v1 object fixture",
copyright_text="Copyright 2026 M12 Fixture Authors",
license_text="CC0-1.0",
tags=("character", "hero", "rig-ready"),
active_tag=1,
preferred_import_method="APPEND",
properties={"approved": True, "rating": 5, "source": "M12-01B"},
)
material = bpy.data.materials.new("M12 Brushed Metal")
material.diffuse_color = (0.25, 0.3, 0.35, 1.0)
material.asset_mark()
set_metadata(
material,
catalog_id=CATALOGS[2][0],
author="",
description="",
copyright_text="",
license_text="",
tags=("metal",),
active_tag=0,
preferred_import_method=None,
properties={"roughness": 0.35},
)
world = bpy.data.worlds.new("M12 Uncataloged World")
world.color = (0.02, 0.03, 0.04)
world.asset_mark()
set_metadata(
world,
catalog_id="",
author="M12 Artist",
description="Asset without a catalog assignment",
copyright_text="",
license_text="CC0-1.0",
tags=(),
active_tag=0,
preferred_import_method="LINK",
properties={},
)
catalog_path = output / "blender_assets.cats.txt"
fixture_path = output / "m12_asset_catalog_v1.blend"
write_catalog_definition(catalog_path)
bpy.ops.wm.save_as_mainfile(filepath=str(fixture_path), compress=True)
print(f"m12-asset-catalog-v1 fixture={fixture_path} catalogs={len(CATALOGS)} assets=3")
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(arguments) != 1:
raise SystemExit("usage: blender --background --factory-startup --python generate-asset-catalog-v1.py -- OUTPUT_DIRECTORY")
main(arguments[0])

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const [sourceArg, outputArg] = process.argv.slice(2);
if (!sourceArg || !outputArg) throw new Error("usage: node generate-asset-catalog-v2.mjs SOURCE_CANONICAL OUTPUT_JSON");
const root = path.resolve(import.meta.dirname, "../..");
const sourcePath = path.join(root, "web/protocol/asset-catalog-v2.ts");
const temporaryPath = path.resolve(outputArg, "../asset-catalog-v2.generated.mjs");
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
fs.mkdirSync(path.dirname(outputArg), { recursive: true });
fs.writeFileSync(temporaryPath, transpiled.outputText);
try {
const protocol = await import(`${pathToFileURL(temporaryPath)}?v=${Date.now()}`);
const desktop = JSON.parse(fs.readFileSync(path.resolve(sourceArg), "utf8"));
const manifest = await protocol.createAssetCatalogManifestV2FromDesktop(desktop, 1);
fs.writeFileSync(path.resolve(outputArg), `${JSON.stringify(manifest, null, 2)}\n`);
process.stdout.write(`asset-catalog-v2-generated catalogs=${manifest.catalogs.length} assets=${manifest.assets.length}\n`);
}
finally {
fs.rmSync(temporaryPath, { force: true });
}

View File

@@ -0,0 +1,33 @@
import pathlib
import sys
import bpy
def main() -> None:
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1 :]) != 2:
raise SystemExit("usage: blender --background --factory-startup --python generate-asset-preview-identity.py -- INPUT OUTPUT")
source_arg, output_arg = sys.argv[sys.argv.index("--") + 1 :]
source = pathlib.Path(source_arg).resolve()
output = pathlib.Path(output_arg).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
image = bpy.data.images.load(str(source), check_existing=False)
width, height = image.size[:]
scene = bpy.context.scene
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "8"
scene.render.image_settings.compression = 0
scene.display_settings.display_device = "sRGB"
scene.view_settings.view_transform = "Standard"
scene.view_settings.look = "None"
scene.view_settings.exposure = 0
scene.view_settings.gamma = 1
image.save_render(str(output), scene=scene)
if not output.is_file() or output.stat().st_size == 0:
raise RuntimeError("asset preview output was not written")
print(f"asset-preview-generated width={width} height={height} output={output}")
main()

View File

@@ -0,0 +1,181 @@
import hashlib
import json
import pathlib
import struct
import sys
import bpy
ROOT_OBJECT = "M12 Append Object"
MESH_NAME = "M12 Append Mesh"
MATERIAL_NAME = "M12 Append Material"
IMAGE_NAME = "M12 Append Image"
IMAGE_NODE_NAME = "M12 Append Image Node"
def sha256_file(path: pathlib.Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def pixel_sha256(image: bpy.types.Image) -> str:
values = list(image.pixels)
return hashlib.sha256(struct.pack(f"<{len(values)}f", *values)).hexdigest()
def reset() -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
def create_source(path: pathlib.Path) -> None:
reset()
image = bpy.data.images.new(IMAGE_NAME, width=2, height=2, alpha=True, float_buffer=False)
image.colorspace_settings.name = "sRGB"
image.pixels = [
1.0, 0.0, 0.0, 1.0,
0.0, 1.0, 0.0, 1.0,
0.0, 0.0, 1.0, 1.0,
1.0, 1.0, 1.0, 0.5,
]
image.pack()
material = bpy.data.materials.new(MATERIAL_NAME)
material.use_nodes = True
node_tree = material.node_tree
principled = node_tree.nodes.get("Principled BSDF")
image_node = node_tree.nodes.new("ShaderNodeTexImage")
image_node.name = IMAGE_NODE_NAME
image_node.label = IMAGE_NODE_NAME
image_node.image = image
image_node.interpolation = "Closest"
image_node.extension = "REPEAT"
node_tree.links.new(image_node.outputs["Color"], principled.inputs["Base Color"])
mesh = bpy.data.meshes.new(MESH_NAME)
mesh.from_pydata(
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
[],
[(0, 1, 2, 3)],
)
mesh.materials.append(material)
uv_layer = mesh.uv_layers.new(name="UVMap")
for loop, uv in zip(uv_layer.data, [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]):
loop.uv = uv
mesh.update()
obj = bpy.data.objects.new(ROOT_OBJECT, mesh)
obj["m12_source_marker"] = "M12-03C"
bpy.context.scene.collection.objects.link(obj)
bpy.ops.wm.save_as_mainfile(filepath=str(path), check_existing=False)
def data_block(id_type: str, value: object) -> dict:
return {
"idType": id_type,
"name": value.name,
"nameFull": value.name_full,
"library": None if value.library is None else value.library.filepath,
"isLibraryOverride": value.override_library is not None,
}
def inspect_local_graph() -> dict:
obj = bpy.data.objects[ROOT_OBJECT]
mesh = obj.data
material = mesh.materials[0]
image_node = material.node_tree.nodes[IMAGE_NODE_NAME]
image = image_node.image
ids = {
"OBJECT": data_block("OBJECT", obj),
"MESH": data_block("MESH", mesh),
"MATERIAL": data_block("MATERIAL", material),
"IMAGE": data_block("IMAGE", image),
}
return {
"root": ids["OBJECT"],
"ids": ids,
"edges": [
{"from": "Object/M12 Append Object", "relation": "OBJECT_DATA", "to": "Mesh/M12 Append Mesh"},
{"from": "Mesh/M12 Append Mesh", "relation": "MATERIAL_SLOT[0]", "to": "Material/M12 Append Material"},
{"from": "Material/M12 Append Material", "relation": f"NODE_IMAGE[{IMAGE_NODE_NAME}]", "to": "Image/M12 Append Image"},
],
"geometry": {
"vertices": len(mesh.vertices),
"edges": len(mesh.edges),
"polygons": len(mesh.polygons),
"loops": len(mesh.loops),
"uvLayers": [layer.name for layer in mesh.uv_layers],
"materialSlots": [item.name for item in mesh.materials],
},
"image": {
"size": list(image.size),
"channels": image.channels,
"colorspace": image.colorspace_settings.name,
"packed": image.packed_file is not None,
"pixelFloat32Sha256": pixel_sha256(image),
},
"sourceMarker": obj["m12_source_marker"],
}
def append_object(source: pathlib.Path, target: pathlib.Path) -> dict:
reset()
with bpy.data.libraries.load(str(source), link=False) as (data_from, data_to):
if ROOT_OBJECT not in data_from.objects:
raise RuntimeError("source root object is missing")
data_to.objects = [ROOT_OBJECT]
if len(data_to.objects) != 1 or data_to.objects[0] is None:
raise RuntimeError("desktop append did not return one object")
bpy.context.scene.collection.objects.link(data_to.objects[0])
before_save = inspect_local_graph()
if any(item["library"] is not None or item["isLibraryOverride"] for item in before_save["ids"].values()):
raise RuntimeError("appended dependency closure is not fully local")
bpy.ops.wm.save_as_mainfile(filepath=str(target), check_existing=False)
bpy.ops.wm.open_mainfile(filepath=str(target), load_ui=False)
reopened = inspect_local_graph()
if reopened != before_save:
raise RuntimeError("appended dependency mapping drifted after save/reopen")
return reopened
def main() -> None:
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1 :]) != 2:
raise SystemExit("usage: blender --background --factory-startup --python generate-library-append-fixture.py -- OUTPUT_DIR REPORT")
output_arg, report_arg = sys.argv[sys.argv.index("--") + 1 :]
output_dir = pathlib.Path(output_arg).resolve()
report_path = pathlib.Path(report_arg).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
report_path.parent.mkdir(parents=True, exist_ok=True)
source = output_dir / "m12_append_source.blend"
target = output_dir / "m12_append_target.blend"
create_source(source)
source_graph = inspect_local_graph()
appended_graph = append_object(source, target)
report = {
"schemaVersion": 1,
"task": "M12-03C",
"operation": "APPEND",
"blenderVersion": "5.2.0",
"source": {"file": source.name, "sha256": sha256_file(source)},
"target": {"file": target.name, "sha256": sha256_file(target)},
"selectedRoots": ["Object/M12 Append Object"],
"sourceGraph": source_graph,
"appendedGraph": appended_graph,
"stableMapping": [
{"source": "Object/M12 Append Object", "local": "Object/M12 Append Object", "owner": "LOCAL_MAIN", "readOnly": False},
{"source": "Mesh/M12 Append Mesh", "local": "Mesh/M12 Append Mesh", "owner": "LOCAL_MAIN", "readOnly": False},
{"source": "Material/M12 Append Material", "local": "Material/M12 Append Material", "owner": "LOCAL_MAIN", "readOnly": False},
{"source": "Image/M12 Append Image", "local": "Image/M12 Append Image", "owner": "LOCAL_MAIN", "readOnly": False},
],
"nextTask": "M12-03D",
}
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(
f"library-append-fixture-ok roots={len(report['selectedRoots'])} "
f"mapping={len(report['stableMapping'])} source={report['source']['sha256']} "
f"target={report['target']['sha256']} next={report['nextTask']}"
)
main()