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

@@ -31,8 +31,9 @@ npm --prefix web run test:browser
See `docs/CURRENT_EXECUTION_PLAN.md` for the current task order,
`docs/PROJECT_STATUS_AND_NEXT_WORK.md` for the implemented scope, and
`docs/BLENDER_5_2_WEB_FEATURE_PARITY.md` for the long-term Blender parity ledger.
The atomic full-product work breakdown is in
`docs/BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` for the normative M12-M23
full-parity execution plan. The domain overview and coverage cross-check are in
`docs/BLENDER_5_2_WEB_FEATURE_PARITY.md` and
`docs/BLENDER_5_2_FULL_PARITY_WBS.md`.
The release contract and explicit non-goals are defined in

View File

@@ -157,7 +157,7 @@ if(WITH_WEB)
# runtime only needs model/animation RNA needed by depsgraph evaluation.
# Trim after generation so the generator itself remains source-compatible.
list(FILTER GENSRC INCLUDE REGEX
"rna_(ID|action|animation|armature|collection|constraint|curve|depsgraph|fcurve|grease_pencil|image|key|lattice|layer|main|material|mesh|object|packedfile|pose|scene|world)_gen\\.cc$")
"rna_(ID|action|animation|armature|blendfile_import|collection|constraint|curve|depsgraph|fcurve|grease_pencil|image|key|lattice|layer|main|material|mesh|object|packedfile|pose|scene|world)_gen\\.cc$")
list(APPEND GENSRC
"${CMAKE_CURRENT_BINARY_DIR}/rna_prototypes_gen.hh"
"${CMAKE_CURRENT_BINARY_DIR}/../RNA_prototypes.hh"

View File

@@ -129,7 +129,7 @@ target_link_options(web_engine PRIVATE
"-sINITIAL_MEMORY=67108864"
"-sMAXIMUM_MEMORY=2147483648"
"-sSTACK_SIZE=8388608"
"-sEXPORTED_FUNCTIONS=['_malloc','_free','_web_engine_create','_web_engine_destroy','_web_engine_get_memory_stats','_web_engine_get_live_handles','_web_engine_get_allocated_bytes','_web_engine_open_blend','_web_engine_apply_command','_web_engine_undo','_web_engine_redo','_web_engine_get_scene_snapshot','_web_engine_get_scene_metadata','_web_engine_get_scene_geometry','_web_engine_get_scene_delta','_web_engine_get_packed_asset','_web_engine_evaluate_depsgraph','_web_engine_save_blend','_web_engine_free_buffer','_web_engine_last_error_code','_web_engine_last_error_message','_web_engine_decimate_apply']"
"-sEXPORTED_FUNCTIONS=['_malloc','_free','_web_engine_create','_web_engine_destroy','_web_engine_get_memory_stats','_web_engine_get_live_handles','_web_engine_get_allocated_bytes','_web_engine_open_blend','_web_engine_apply_command','_web_engine_append_library_object','_web_engine_undo','_web_engine_redo','_web_engine_get_scene_snapshot','_web_engine_get_scene_metadata','_web_engine_get_scene_geometry','_web_engine_get_scene_delta','_web_engine_get_packed_asset','_web_engine_evaluate_depsgraph','_web_engine_save_blend','_web_engine_free_buffer','_web_engine_last_error_code','_web_engine_last_error_message','_web_engine_decimate_apply']"
"-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString']"
)
if(WEB_ENGINE_ENABLE_UNSAFE_DEPSGRAPH)

View File

@@ -2145,6 +2145,139 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle,
}
}
EMSCRIPTEN_KEEPALIVE int web_engine_append_library_object(const int handle,
const uint8_t *source_data,
const uint32_t source_length,
const uint8_t *metadata,
const uint32_t metadata_length)
{
EngineState *engine = find_engine(handle);
if (engine == nullptr) return last_error_code;
if (source_data == nullptr || source_length == 0 ||
source_length > 64u * 1024u * 1024u || metadata == nullptr || metadata_length == 0)
{
set_error(WEB_ENGINE_INVALID_ARGUMENT, "ASSET_BUDGET_EXCEEDED: library append input is empty or exceeds 64 MiB");
return last_error_code;
}
if (!engine->authoritative_commands || engine->authoritative_main == nullptr) {
set_error(WEB_ENGINE_NOT_IMPLEMENTED,
"COMMAND_REQUIRES_MAIN_AUTHORITY: library append requires an authoritative Main");
return last_error_code;
}
try {
const json request = json::parse(reinterpret_cast<const char *>(metadata),
reinterpret_cast<const char *>(metadata) + metadata_length);
if (!request.is_object() || request.size() != 5 || request.value("schemaVersion", 0) != 1 ||
!request.contains("baseRevision") || !request["baseRevision"].is_number_unsigned() ||
!request.contains("sourceLocator") || !request["sourceLocator"].is_string() ||
!request.contains("sourceDataBlockId") || !request["sourceDataBlockId"].is_string() ||
!request.contains("expectedClosure") || !request["expectedClosure"].is_object())
{
set_error(WEB_ENGINE_INVALID_ARGUMENT,
"ASSET_MANIFEST_INVALID: native library append metadata is malformed");
return last_error_code;
}
for (const char *key : {"schemaVersion", "baseRevision", "sourceLocator", "sourceDataBlockId", "expectedClosure"}) {
if (!request.contains(key)) {
set_error(WEB_ENGINE_INVALID_ARGUMENT,
"ASSET_MANIFEST_INVALID: native library append metadata fields are not exact");
return last_error_code;
}
}
const uint64_t base_revision = request["baseRevision"].get<uint64_t>();
if (base_revision != engine->revision) {
set_error(WEB_ENGINE_INVALID_ARGUMENT,
"REVISION_CONFLICT: library append base revision does not match the current SceneIR");
return last_error_code;
}
const json &closure = request["expectedClosure"];
if (closure.size() != 4) {
set_error(WEB_ENGINE_INVALID_ARGUMENT, "ASSET_MANIFEST_INVALID: append closure fields are not exact");
return last_error_code;
}
auto closure_name = [&](const char *field, const char *prefix) -> std::string {
if (!closure.contains(field) || !closure[field].is_string()) return {};
const std::string value = closure[field].get<std::string>();
const size_t prefix_length = strlen(prefix);
if (value.size() <= prefix_length || value.compare(0, prefix_length, prefix) != 0) return {};
return value.substr(prefix_length);
};
const std::string object_name = closure_name("object", "Object/");
const std::string mesh_name = closure_name("mesh", "Mesh/");
const std::string material_name = closure_name("material", "Material/");
const std::string image_name = closure_name("image", "Image/");
if (object_name.empty() || mesh_name.empty() || material_name.empty() || image_name.empty() ||
request["sourceDataBlockId"].get<std::string>() != "Object/" + object_name)
{
set_error(WEB_ENGINE_INVALID_ARGUMENT,
"ASSET_SOURCE_HASH_MISMATCH: native append root does not match the expected closure");
return last_error_code;
}
HistoryEntry previous_state;
std::string history_error;
if (!capture_history_entry(*engine, previous_state, history_error)) {
set_error(WEB_ENGINE_BLEND_WRITE_FAILED, history_error.c_str());
return last_error_code;
}
const uint64_t previous_revision = engine->revision;
const std::string previous_snapshot = engine->scene_snapshot.empty() ?
std::string(empty_scene_snapshot_json()) :
engine->scene_snapshot;
const std::string previous_delta = engine->scene_delta;
const auto previous_packed_assets = engine->packed_assets;
auto rollback = [&]() {
std::string rollback_error;
if (restore_history_entry(*engine, previous_state, rollback_error)) {
engine->revision = previous_revision;
engine->scene_snapshot = previous_snapshot;
engine->scene_delta = previous_delta;
engine->packed_assets = previous_packed_assets;
engine->binary_revision = std::numeric_limits<uint64_t>::max();
}
};
std::string object_id;
std::string append_error;
if (!web_engine_blend_main_append_object(engine->authoritative_main,
source_data,
source_length,
request["sourceLocator"].get_ref<const std::string &>().c_str(),
object_name.c_str(),
mesh_name.c_str(),
material_name.c_str(),
image_name.c_str(),
object_id,
append_error))
{
rollback();
set_error(append_error.rfind("BLEND_READ_FAILED:", 0) == 0 ?
WEB_ENGINE_BLEND_READ_FAILED : WEB_ENGINE_INVALID_ARGUMENT,
append_error.c_str());
return last_error_code;
}
json snapshot;
if (!refresh_scene_from_main(*engine, snapshot, object_id, append_error)) {
rollback();
set_error(WEB_ENGINE_BLEND_WRITE_FAILED, append_error.c_str());
return last_error_code;
}
engine->authoritative_dirty = true;
push_history(engine->undo_history, std::move(previous_state));
engine->redo_history.clear();
engine->scene_snapshot = snapshot.dump();
engine->revision++;
engine->scene_delta = scene_delta_json(previous_snapshot, engine->scene_snapshot);
set_error(WEB_ENGINE_OK, "ok");
return WEB_ENGINE_OK;
}
catch (const std::exception &exception) {
set_error(WEB_ENGINE_INVALID_ARGUMENT, exception.what());
return last_error_code;
}
}
EMSCRIPTEN_KEEPALIVE int web_engine_undo(const int handle)
{
EngineState *engine = find_engine(handle);

View File

@@ -28,6 +28,11 @@ uint32_t web_engine_get_live_handles(void);
uint32_t web_engine_get_allocated_bytes(void);
int web_engine_open_blend(int handle, const uint8_t *data, uint32_t length);
int web_engine_apply_command(int handle, const uint8_t *data, uint32_t length);
int web_engine_append_library_object(int handle,
const uint8_t *source_data,
uint32_t source_length,
const uint8_t *metadata,
uint32_t metadata_length);
int web_engine_undo(int handle);
int web_engine_redo(int handle);
int web_engine_get_scene_snapshot(int handle, const uint8_t **data, uint32_t *length);

View File

@@ -72,6 +72,13 @@ static void register_web_geometry_node_types()
}
namespace seq {
/* The browser engine does not load desktop sequencer playback state. */
void doversion_250_sound_proxy_update(Main * /*bmain*/, Editing * /*editing*/) {}
} // namespace seq
void WEB_headless_node_system_init()
{
static bool initialized = false;
@@ -411,4 +418,5 @@ const ComputeContext *compute_context_for_viewer_path_elem(
namespace blender {
void WM_main_add_notifier(unsigned int /*type*/, void * /*reference*/) {}
void WM_msg_publish_rna(wmMsgBus * /*mbus*/, PointerRNA * /*ptr*/, PropertyRNA * /*prop*/) {}
} // namespace blender

View File

@@ -20,6 +20,7 @@
#include "BKE_anim_data.hh"
#include "BKE_attribute.hh"
#include "BKE_attribute.h"
#include "BKE_blendfile_link_append.hh"
#include "BKE_layer.hh"
#include "BKE_collection.hh"
#include "BKE_curve.hh"
@@ -1120,6 +1121,117 @@ bool web_engine_blend_main_create_primitive(WebBlendMainState *state,
return true;
}
bool web_engine_blend_main_append_object(WebBlendMainState *state,
const uint8_t *source_data,
const uint32_t source_length,
const char *source_locator,
const char *object_name,
const char *mesh_name,
const char *material_name,
const char *image_name,
std::string &object_id,
std::string &error)
{
if (state == nullptr || state->main == nullptr || state->scene == nullptr ||
state->view_layer == nullptr || source_data == nullptr || source_length == 0 ||
source_length > 64u * 1024u * 1024u || source_locator == nullptr ||
source_locator[0] == '\0' || object_name == nullptr || mesh_name == nullptr ||
material_name == nullptr || image_name == nullptr || object_name[0] == '\0' ||
mesh_name[0] == '\0' || material_name[0] == '\0' || image_name[0] == '\0')
{
error = "ASSET_MANIFEST_INVALID: append requires a bounded source and complete Object closure";
return false;
}
if (find_object(state->main, ("object:" + std::string(object_name)).c_str()) != nullptr ||
find_material(state->main, ("material:" + std::string(material_name)).c_str()) != nullptr ||
find_image(state->main, ("image:" + std::string(image_name)).c_str()) != nullptr)
{
error = "ASSET_MANIFEST_INVALID: append closure collides with an existing local ID";
return false;
}
for (const Mesh &mesh : state->main->meshes) {
if (id_name(mesh.id) == mesh_name) {
error = "ASSET_MANIFEST_INVALID: append closure collides with an existing local Mesh";
return false;
}
}
BlendHandle *blend_handle = BLO_blendhandle_from_memory(
source_data, int(source_length), nullptr);
if (blend_handle == nullptr) {
error = "BLEND_READ_FAILED: Blender rejected the append source buffer";
return false;
}
GlobalMainScope global_main_scope(state->main);
BKE_main_id_tag_all(state->main, ID_TAG_PRE_EXISTING, true);
LibraryLink_Params parameters{};
BLO_library_link_params_init_with_context(&parameters,
state->main,
BLO_LIBLINK_APPEND_RECURSIVE,
0,
state->scene,
state->view_layer,
nullptr);
BlendfileLinkAppendContext *context = BKE_blendfile_link_append_context_new(&parameters);
if (context == nullptr) {
BLO_blendhandle_close(blend_handle);
BKE_main_id_tag_all(state->main, ID_TAG_PRE_EXISTING, false);
error = "BLEND_READ_FAILED: Blender could not create the append context";
return false;
}
BKE_blendfile_link_append_context_library_add(context, source_locator, blend_handle);
BlendfileLinkAppendContextItem *item = BKE_blendfile_link_append_context_item_add(
context, object_name, ID_OB, nullptr);
BKE_blendfile_link_append_context_item_library_index_enable(context, item, 0);
BKE_blendfile_link_append_context_init_done(context);
BKE_blendfile_link(context, nullptr);
BKE_blendfile_append(context, nullptr);
BKE_blendfile_link_append_instantiate_loose(context, nullptr);
BKE_blendfile_link_append_context_finalize(context);
ID *new_id = BKE_blendfile_link_append_context_item_newid_get(context, item);
BKE_blendfile_link_append_context_free(context);
BKE_main_id_tag_all(state->main, ID_TAG_PRE_EXISTING, false);
Object *object = new_id != nullptr && GS(new_id->name) == ID_OB ?
reinterpret_cast<Object *>(new_id) :
nullptr;
Mesh *mesh = object != nullptr && object->type == OB_MESH && object->data != nullptr ?
reinterpret_cast<Mesh *>(object->data) :
nullptr;
Material *material = mesh != nullptr && mesh->totcol == 1 && mesh->mat != nullptr ?
mesh->mat[0] :
nullptr;
Image *image = find_image(state->main, ("image:" + std::string(image_name)).c_str());
bool material_uses_image = false;
if (material != nullptr && material->nodetree != nullptr && image != nullptr) {
for (const bNode &node : material->nodetree->nodes) {
if (node.id == &image->id) {
material_uses_image = true;
break;
}
}
}
if (object == nullptr || mesh == nullptr || material == nullptr || image == nullptr ||
id_name(object->id) != object_name || id_name(mesh->id) != mesh_name ||
id_name(material->id) != material_name || id_name(image->id) != image_name ||
object->id.lib != nullptr || mesh->id.lib != nullptr || material->id.lib != nullptr ||
image->id.lib != nullptr || object->id.override_library != nullptr ||
mesh->id.override_library != nullptr || material->id.override_library != nullptr ||
image->id.override_library != nullptr || !material_uses_image)
{
error = "ASSET_SOURCE_HASH_MISMATCH: appended Object closure is incomplete or not fully local";
return false;
}
BKE_view_layer_synced_ensure(*state->main, state->scene, state->view_layer);
object->id.recalc |= ID_RECALC_TRANSFORM | ID_RECALC_GEOMETRY;
mesh->id.recalc |= ID_RECALC_GEOMETRY;
material->id.recalc |= ID_RECALC_SYNC_TO_EVAL;
image->id.recalc |= ID_RECALC_SOURCE;
object_id = "object:" + id_name(object->id);
return true;
}
bool web_engine_blend_main_create_curve(WebBlendMainState *state,
const char *curve_type,
const char *name,

View File

@@ -128,6 +128,16 @@ bool web_engine_blend_main_create_primitive(WebBlendMainState *state,
const std::vector<float> &location,
std::string &object_id,
std::string &error);
bool web_engine_blend_main_append_object(WebBlendMainState *state,
const uint8_t *source_data,
uint32_t source_length,
const char *source_locator,
const char *object_name,
const char *mesh_name,
const char *material_name,
const char *image_name,
std::string &object_id,
std::string &error);
bool web_engine_blend_main_duplicate_object(WebBlendMainState *state,
const char *object_id,
const std::vector<float> &offset,

View File

@@ -2,19 +2,23 @@
更新时间2026-08-15
> 本文保留为 Blender 全产品覆盖检查表和 F00-F24 taxonomy 参考。M12-M23 的执行分类、
> 原子任务、证据合同与发布门以 `BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` 为准;
> 本文的 F 任务不能直接作为当前 `nextTask`,也不能独立改变机器状态。
## 1. 目标与事实边界
本文把“对标 Blender 全部功能”定义为:以仓库内 `blender-5.2.0` 的 RNA、operator、
data-block、modifier、node、editor、文件格式和运行时后端为冻结基线逐项给出
`LOCAL_EXACT``LOCAL_BOUNDED``SERVER``EXCLUDED` 结论,并为结论保存可复验的正例、
阻断例和数据保真证据。
本文以仓库内 `blender-5.2.0` 的 RNA、operator、data-block、modifier、node、editor、
文件格式和运行时后端为覆盖基线。本文原有 `LOCAL_EXACT/LOCAL_BOUNDED/SERVER/EXCLUDED`
V1 盘点术语;完整对标结论必须按新计划转换为 `LOCAL_EXACT/LOCAL_EQUIVALENT/SERVER_EXACT`
和独立 `parityStatus`,并保存可复验的正例、阻断例和数据保真证据。
当前 `Web Blender Modeler V1` 已达到其有界发布范围,但不等于完整 Blender。机器账本中
N-015 至 N-026 的 12 个 family 全部仍为 `parityStatus=BLOCKED`。因此以下任务默认均为未完成;
只有 `docs/status/parity-ledger.json` 和对应专项证据同时更新后才允许改变状态。
短周期唯一领取顺序仍以 `docs/CURRENT_EXECUTION_PLAN.md` 为准。本文负责完整产品范围和细粒度
工作包,不取代当前执行队列。
短周期唯一领取顺序仍以 `docs/CURRENT_EXECUTION_PLAN.md` 为准。本文负责验证新计划没有遗漏
产品域;与新计划冲突时按新计划解释,不取代当前执行队列或长期实施事实源
## 2. 原子任务完成定义
@@ -481,7 +485,8 @@ N-015 至 N-026 的 12 个 family 全部仍为 `parityStatus=BLOCKED`。因此
- [ ] `F23-09` 完成 user preferences schema、migration 和项目无关 persistence。
- [ ] `F23-10` 完成 locale 切换、数字/日期格式和翻译 fallback。
- [ ] `F23-11` 完成 theme、font scale、reduced motion 和高对比偏好。
- [ ] `F23-12` native window/file watcher/CUDA/Metal/HIP/OptiX 明确 EXCLUDED。
- [ ] `F23-12` native window/file watcher/CUDA/Metal/HIP/OptiX 实现细节明确不复制,其用户能力
按新计划映射到 platform-equivalent 或 server exact 路径。
- [ ] `F23-13` 每个新增浏览器加入 quick/P0/full 三层 CI。
- [ ] `F23-14` 1280x720、1440x900、4K 和窄屏无重叠截图门。
@@ -501,18 +506,22 @@ N-015 至 N-026 的 12 个 family 全部仍为 `parityStatus=BLOCKED`。因此
- [ ] `F24-12` CI 报告绑定 commit、lockfile、inventory、ledger、engine、archive hash。
- [ ] `F24-13` binary/source archive 在两个空临时目录独立复验。
- [ ] `F24-14` 完成 SBOM、license、source offer 和供应链审计。
- [ ] `F24-15` 每个 parity ID 都有 LOCAL 正例、SERVER 闭环或 EXCLUDED 保真证据。
- [ ] `F24-15` 每个 parity ID 都有 LOCAL_EXACT、LOCAL_EQUIVALENT 或 SERVER_EXACT 完整证据。
- [ ] `F24-16` inventory 未分类、隐式 loss、proxy-success 三项计数均为 0。
- [ ] `F24-17` 独立机器复验全部构件和关键证据 hash。
- [ ] `F24-18` 发布说明逐项列出支持、受限、server 和 excluded 能力。
- [ ] `F24-18` 发布说明逐项列出 local exact、platform-equivalent 和 server exact 能力。
里程碑 F24不能由聚合 release gate 反推 family 完成。只有 inventory 的每个单项都达到自身
退出条件,N-015 至 N-026 才能逐项从 `BLOCKED` 改为 `COMPLETE`
退出条件,全部生成的 owner family 才能逐项从 `BLOCKED` 改为 `COMPLETE`
## 29. 当前领取点
截至 2026-08-15M6 已完成 35/71single/pthread 选择与回退、真实 HTTP MIME/range、
releaseId/hash 升级负例和缓存合同均有专项证据。HTML、manifest、stable engine 未变化时均返回
`304`,换代后均返回 `200` 且 ETag 变化production build 的 1 个入口 JS、1 个 CSS、3 个
Worker 均使用 8 位内容 hash 文件名并返回 `immutable`。当前唯一下一任务为 `M6-09A`:从
binary archive 解包后使用合同服务器启动,不读取工作区文件。
截至 2026-08-17M6-M11 已按当前计划完成M11 最终以 Render、Compositor、Media 三域故障
生命周期专项门收口M12-01A-I 已完成 catalog schema/migrationM12-02A-H 又完成 PreviewImage
inventory、identity、decode budget、OPFS commit、dedupe、quarantine、project-scoped reference GC
与 desktop/browser 双显示路径像素闭环。本文的 F00-F24
条目只作为覆盖检查表不能直接领取M12-03A 又冻结 Append/Link/Override 的 36 类 root 与
实际 ID pointer dependency closureM12-03B 固定 source/owner/read-only/invalidation identity
M12-03C 冻结 Blender 5.2 单 Object append 的 Object/Mesh/Material/Image local stable mapping
M12-03D 已通过 WASM Main 单 transaction 创建同一 local dependency closure。当前唯一下一任务为
`BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` 中的 `M12-03E`

View File

@@ -0,0 +1,615 @@
# Blender 5.2 完整 Web 对标实施计划
更新时间2026-08-17
本文是 M12 及以后完整 Blender 5.2 Web 迁移的长期规范事实源,负责执行分类、原子任务、
依赖、证据和完整发布门。它不覆盖 `WEB_BLENDER_MODELER_V1_SCOPE.md` 的 V1 产品契约,也不
改写 V1 已冻结的 `LOCAL_EXACT/LOCAL_BOUNDED/SERVER/EXCLUDED` 发布分类。
文档职责固定如下:
| 事实 | 权威来源 |
| --- | --- |
| V1 承诺、非目标和发布标准 | `WEB_BLENDER_MODELER_V1_SCOPE.md` |
| 当前唯一可领取任务 | `CURRENT_EXECUTION_PLAN.md` 与机器 `nextTask` |
| M12-M23 完整对标规则和长期任务 | 本文 |
| 当前实现事实和 family/slice 状态 | `status/parity-ledger.json` 与对应 evidence |
| Blender 全域功能说明和覆盖参考 | `BLENDER_5_2_WEB_FEATURE_PARITY.md``BLENDER_5_2_FULL_PARITY_WBS.md` |
描述与机器证据冲突时不得用文字把失败改成成功。M12-01A-I 已收口M12-02A-G preview
inventory/identity/decode budget/OPFS commit/dedupe/quarantine/GC 已完成且未改变 parity 状态;
desktop/browser 主线程与 Offscreen preview 像素闭环也已完成Append/Link/Override 数据块与
依赖闭包 inventory、source/owner/read-only/invalidation 合同、desktop 单 Object append fixture
及 WASM Main 单 transaction append 已完成,当前唯一 `nextTask``M12-03E`
## 1. 目标与边界
目标不是制作一个外观类似 Blender 的网页,而是让用户从浏览器完成 Blender 5.2 的可观察
功能,并且每项声明都能用真实 Blender 5.2 执行结果复核。浏览器本地不适合承载的 Cycles、
完整 Python、部分模拟和硬件后端必须由隔离的 Blender 5.2 server job 执行Three.js、
WebGPU 或 JavaScript 近似结果不能冒充 Blender 结果。
“完整 Web Blender”只在以下条件同时满足后成立
1. Blender 5.2 的 data-block、operator、node、modifier、editor workflow、import/export 和
render/compute family 全部进入机器清单。
2. 每个用户可观察能力被标为 `LOCAL_EXACT``LOCAL_EQUIVALENT``SERVER_EXACT`,且有成功
证据;`BLOCKED``UNINVENTORIED` 或未声明项数量必须为零。
3. `LOCAL_EXACT` 使用同一 fixture 比较 Blender desktop 与 WASM/Main 的结构、数值或像素。
4. `LOCAL_EQUIVALENT` 只用于浏览器交互外壳;最终 Main、Depsgraph、保存文件和导出语义仍须
与 Blender 一致。
5. `SERVER_EXACT` 必须真的启动锁定版本 Blender验证提交、进度、取消、资源隔离、结果 hash
和项目绑定,不允许只返回“应走服务端”。
6. 完整项目通过保存、关闭、Worker/页面重启、重新打开和 Blender desktop 再打开;未知数据
不丢失,不支持的写操作不污染原文件。
7. Chromium、Firefox、WebKit 的支持声明分别由自己的 quick/P0/full 证据决定,不互相推断。
Native window、CUDA/Metal/HIP/OptiX 实现细节不需要在浏览器复制,但它们提供的用户能力必须
由 WebGPU、CPU 或 `SERVER_EXACT` 路径覆盖。若没有等价执行路径,就保持 `BLOCKED`,项目不得
使用“完整对标”表述。
### 1.1 完整对标状态不是 V1 发布分类
全量盘点为每个 `parityId` 使用两个独立轴,禁止把 V1 family 的 `releaseClass` 直接升级为
全量完成:
| 轴 | 允许值 | 含义 |
| --- | --- | --- |
| `implementationClass` | `LOCAL_EXACT` | Blender 语义在浏览器 Main/WASM 本地执行并经同 fixture 对标 |
| `implementationClass` | `LOCAL_EQUIVALENT` | 仅浏览器平台或交互外壳不同,最终 Main、文件、导出和可见语义精确 |
| `implementationClass` | `SERVER_EXACT` | 锁定版本的真实 Blender server 执行权威求值,浏览器负责提交和消费 |
| `parityStatus` | `UNINVENTORIED` | Blender 基线项尚未进入机器清单 |
| `parityStatus` | `BLOCKED` | 已盘点但缺实现、证据或存在超阈值差异 |
| `parityStatus` | `IN_PROGRESS` | 唯一原子任务正在实现;仍按未完成计 |
| `parityStatus` | `VERIFIED` | 对应 class 的全部强制证据通过 |
`LOCAL_EQUIVALENT` 不能用于 modifier、node、solver、render、import/export 或其他计算结果的
近似实现。它只适用于 native window、文件选择器、菜单布局、输入映射等浏览器平台差异
若操作后的 Main 或文件语义不同,状态只能是 `BLOCKED`
### 1.2 本地与 Blender server 路由规则
执行 class 在写实现前确定,不能根据某次运行是否方便临时切换:
| 能力特征 | 必选 class | 最低证据 |
| --- | --- | --- |
| Main/ID 读取写入、selection/context、undo/redo、增量编辑、保存恢复 | `LOCAL_EXACT` | desktop/WASM 同 fixture、单事务、保存重开、Chromium 用户路径 |
| native window、系统文件对话框、菜单/快捷键表现等平台外壳 | `LOCAL_EQUIVALENT` | desktop 最终状态、Web Main/文件最终状态、可访问交互和恢复一致 |
| WASM 已包含所需 Blender evaluator且在声明预算内可确定性完成 | `LOCAL_EXACT` | desktop/WASM 语义或像素阈值、浏览器实际执行、故障恢复 |
| Cycles、复杂 Eevee、Blender 完整 Compositor、重模拟/烘焙、tracking solve | `SERVER_EXACT`,除非该项另有完整本地证据 | 真实隔离 Blender job、版本/输入/设置/输出绑定、取消和恢复 |
| 任意 Python/add-on、需原生库的 USD/Alembic/codec 或 OS/GPU 后端能力 | `SERVER_EXACT` | 权限策略、无隐式网络、资源限制、真实执行和结果复验 |
| Three.js/WebGPU 预览、summary、proxy、capability probe、仅路由结果 | 不能单独领取成功 class | 只能作为已分类能力的中间证据;权威结果缺失时保持 `BLOCKED` |
同一 workflow 可以由多个 `parityId` 组成,例如本地编辑后提交服务端 Cycles 渲染;每个 ID
只能有一个权威执行 class。server 路径必须向用户明确上传内容、版本、预算、进度和取消状态;
未配置 server、用户不同意上传或 server receipt 失效时必须 fail-closed不能回退到近似结果。
### 1.3 阻止“完整对标”声明的差异
以下任一项存在时产品、README、发布说明和 UI 都不得使用“完整 Web Blender”或等价表述
- inventory 有未映射、重复映射、版本漂移或 `UNINVENTORIED` 条目;
- 任一 `parityId``BLOCKED/IN_PROGRESS`,或只有 summary/proxy/probe/route/mock 证据;
- 计算结果超过已批准的数值、拓扑、时序、音频或像素阈值;
- 不支持或未知数据在打开、编辑、保存、导出、append/link/override 中丢失或被重写;
- writer 缺 undo/redo、保存重开、desktop 再打开,或失败路径改变 Main/项目 revision
- server 能力没有真实 Blender process、隔离、取消、预算、结果 hash 和项目绑定证据;
- 用户可进入没有 capability gate 的 UI或本地近似被显示为 Blender 权威结果;
- 已声明浏览器、设备档位、输入方式或离线/server 模式缺自己的通过证据;
- clean build、依赖/SBOM/license、安全、性能、故障或独立机器复验未通过。
## 2. 当前基线
- 当前交付物是可审计的 Chromium `Web Blender Modeler V1`,不是完整 Blender。
- M6 至 M11 已完成M12-01A-I、M12-02A-G enabling tasks 与 M12-02H preview display
parity slice 及 M12-03A-D library-operation inventory/identity/desktop fixture/WASM Main append
已完成,当前唯一下一任务为 M12-03E。
- N-015 至 N-026 的 V1 `releaseStatus``READY`,但 12 个 family 的全域
`parityStatus` 仍全部为 `BLOCKED`
- 这 12 个 family 是当前 post-V1 账本范围,不覆盖完整 Blender inventory不能把它们未来的
聚合状态当作 Main、Mesh、Modifier 等全域完成证明。M15 必须生成完整 owner family 集合。
- 当前完成项只证明已声明的有界切片;任何 summary、proxy、capability probe 或路由结果都不能
自动升级为 Blender 功能完成。
## 3. 真实 Blender 对标流水线
### 3.1 固定运行时
- [ ] `FULL-PARITY-RUNTIME-01` 固定官方 Blender 5.2.0 LTS archive SHA-256。
- [ ] `FULL-PARITY-RUNTIME-02` 记录 `blender --version`、build hash、平台、Python、USD、
Alembic、OpenVDB、Cycles device inventory。
- [ ] `FULL-PARITY-RUNTIME-03` desktop runner 拒绝版本或 build hash 漂移。
- [ ] `FULL-PARITY-RUNTIME-04` WASM manifest 绑定 Blender source commit、Emscripten、CMake
cache、JS/WASM SHA-256。
- [ ] `FULL-PARITY-RUNTIME-05` server image 绑定 Blender binary、启动参数、依赖和 image digest。
- [ ] `FULL-PARITY-RUNTIME-06` fixture generator 只能在临时目录写文件,运行后逐项 hash。
- [ ] `FULL-PARITY-RUNTIME-07` 禁止从开发机未声明插件、偏好设置或环境变量读取能力。
- [ ] `FULL-PARITY-RUNTIME-08` CI 在每次 runner 启动时执行 runtime preflight 并写机器报告。
### 3.2 每项能力的固定证据链
能力原子执行单元固定为一个 `parityId × 单一用户可观察行为 × implementationClass`,内部走完
本节的完整证据链。纯 runtime、inventory、generator 或 schema 基建任务必须标记
`enablingTask=true`,列出它解除的依赖,但自身不能把任何 `parityId` 改为 `VERIFIED`
M12 的字母 ID 只有在单一性检查通过后才能进入 `nextTask`;任务文字中出现“每种/全部/逐项”
时,先按稳定 `parityId` 增加后缀并拆成多个任务。M16-M22 的“差距清零”项只是波次容器,
必须先由 M15 展开,不能直接勾选容器。
每个原子功能任务必须按以下顺序执行,任一步失败都不能领取下一功能:
- [ ] `PARITY-TEMPLATE-01` 从 Blender RNA/operator/node 清单选取一个最小行为。
- [ ] `PARITY-TEMPLATE-02` 写 capability 条目、输入 schema、预算、稳定错误码和保存策略。
- [ ] `PARITY-TEMPLATE-03` 写 Blender 5.2 Python fixture generator不手工伪造期望 JSON。
- [ ] `PARITY-TEMPLATE-04` desktop Blender 执行操作并导出 canonical semantic report。
- [ ] `PARITY-TEMPLATE-05` desktop 保存 `.blend`,新 Blender 进程重开并再次导出报告。
- [ ] `PARITY-TEMPLATE-06` WASM/Main 对同一输入执行同一行为并导出同结构报告。
- [ ] `PARITY-TEMPLATE-07` 比较 stable ID、Main revision、数据值、依赖和错误语义。
- [ ] `PARITY-TEMPLATE-08` 对 writer 验证一次 transaction、undo、redo、save/reopen。
- [ ] `PARITY-TEMPLATE-09` Chromium 主线程和 Offscreen 只消费权威结果并验证可见输出。
- [ ] `PARITY-TEMPLATE-10` 加入 unsupported、malformed、stale revision 和预算超限负例。
- [ ] `PARITY-TEMPLATE-11` 加入取消、Worker restart、资源释放和小任务恢复。
- [ ] `PARITY-TEMPLATE-12` 将 command、duration、stdout 摘要和构件 SHA-256 写入 evidence。
- [ ] `PARITY-TEMPLATE-13` 更新 family status、机器 ledger、实施记录和用户可见限制。
- [ ] `PARITY-TEMPLATE-14` 独立运行 `typecheck`、lint、unit、专项 E2E 和状态一致性门。
### 3.3 原子任务与 evidence 机器合同
每个原子任务进入队列前必须含以下字段;字段不得留空,确实不适用时写 `NOT_APPLICABLE`
可校验理由,不能直接删除:
| 字段 | 必填内容 |
| --- | --- |
| identity | `taskId`、parent ID、`parityId``enablingTask`、Blender source anchor、owner、dependency IDs |
| classification | `implementationClass``parityStatus`、用户可观察行为、非目标 |
| fixture | generator command/hash、`.blend`/asset hash、输入预算、license/source |
| desktop | Blender command、runtime/build hash、canonical report/hash、保存后新进程重开报告 |
| wasm | build manifest、command、Main revision、semantic report/hashserver 项记录本地拒绝/提交门 |
| browser | 浏览器/OS/GPU、主线程/Offscreen 适用性、Chromium 用户动作和可见结果 |
| persistence | undo/redo、save/close/reopen、Worker/page restart、desktop reopen、unknown-data report |
| faults | malformed、unsupported、stale、cancel、timeout、OOM/quota、crash/restart、resource-zero |
| server | image/binary digest、policy、job/request/result ID、progress/cancel、resource receipt、output hash |
| comparison | comparator version、阈值来源、实际 max/RMS/P95/hash、pass/fail |
| provenance | commit、dirty flag、commands、exit code、duration、stdout/stderr 摘要、artifact hashes |
evidence 只接受一次从干净输入开始的完整成功运行。重试前的失败保留为诊断,不得拼接不同
commit、runtime 或 fixture 的局部成功。`parityStatus=VERIFIED` 必须由状态校验器重新计算,
不能由 Markdown checkbox 单独决定。
### 3.4 class 对应的强制执行矩阵
| 证据 | `LOCAL_EXACT` | `LOCAL_EQUIVALENT` | `SERVER_EXACT` |
| --- | --- | --- | --- |
| Blender desktop 同 fixture | 必须 | 必须,比较最终语义 | 必须,作为 job 结果基线 |
| WASM/Main 实际执行 | 必须 | 必须完成最终 Main/保存语义 | 必须完成读取、校验、提交和结果绑定;权威求值不冒充本地 |
| Chromium 实际用户路径 | 必须 | 必须 | 必须,含提交/进度/取消/取回 |
| 主线程与 Offscreen | 有可见 viewport 输出时都必须 | 有 viewport 输出时都必须 | 浏览器消费可见结果时按适用路径必须 |
| undo/redo/save/reopen | writer 必须 | 改 Main/项目时必须 | 本地准备和结果绑定改变项目时必须 |
| Worker/page restart | 必须 | 必须 | 必须,且不得重复提交或绑定迟到结果 |
| OOM/quota/cancel/crash 后小任务恢复 | 必须 | 必须 | 浏览器与 server 两侧都必须 |
| 真实 Blender server | 禁止作为本地成功替代 | 禁止作为等价外壳成功替代 | 必须 |
Firefox/WebKit 不在每个早期原子任务的默认完成门中M14 声明某浏览器支持后,该浏览器对
所有已验证 `parityId` 的适用 browser evidence 立即成为强制项,不能由 Chromium 结果代替。
### 3.5 比较器
- [ ] `COMPARE-01` 整数、枚举、布尔、ID、拓扑计数和离散索引使用精确比较。
- [ ] `COMPARE-02` Float32/Float64 分别记录 max、RMS、P95、NaN/Inf 和阈值来源。
- [ ] `COMPARE-03` transform 同时比较 location/rotation/scale 和 world matrix处理旋转表示等价。
- [ ] `COMPARE-04` Mesh 比较 topology hash、position/normal/UV/color/weight 和 attribute domain。
- [ ] `COMPARE-05` 动画逐关键帧和区间采样,比较 FCurve、pose matrix、constraint 和 evaluated mesh。
- [ ] `COMPARE-06` 图结构比较 node type、socket、default、link、group boundary 和未知节点保留。
- [ ] `COMPARE-07` 图像固定色彩空间/alpha输出 MAE、RMS、P95、坏像素和前景 IoU。
- [ ] `COMPARE-08` 音频先统一 sample rate/channel layout再比较时长、峰值、RMS 和 sample drift。
- [ ] `COMPARE-09` 视频比较帧时间戳、关键帧映射、RGBA frame hash、音视频同步和丢帧。
- [ ] `COMPARE-10` simulation 比较 frame/source/settings/cache hash 与每帧 canonical state。
- [ ] `COMPARE-11` import/export 输出 loss report未知或不可逆语义禁止静默丢弃。
- [ ] `COMPARE-12` UI workflow 比较最终 Main/selection/context而不是像素位置模仿 desktop UI。
## 4. 里程碑依赖
```text
M11-14 跨域故障闭环
-> M12 Asset / Library / IO / Editors
-> M13 Scripting / Security / Server isolation
-> M14 Firefox / WebKit / Device input
-> M15 全域自动盘点
-> M16-M22 按机器差距逐 family 清零
-> M23 完整 Web Blender 候选发布
```
M12 至 M15 是当前确定队列。M16 至 M22 的具体领取顺序由 M15 生成的机器差距决定;文档中的
顺序是依赖上限,不得用它跳过最新 `nextTask`
## 5. M11-14 Render、Compositor、Media 故障收口
- [x] `M11-14A` schema 固定三域 cancellation code、budget code 和资源归零字段。
- [x] `M11-14B` Render 使用真实 `.blend` 验证 open 取消不提交、Worker generation 2 重开、
texture aggregate budget 不替换已加载资源、dispose 回执和非空 WebGL 恢复。
- [x] `M11-14C` Compositor 使用真实 Main graph 验证周期取消、超尺寸分配前阻断、LRU clear、
Worker 重开和 Float32 output hash 恢复。
- [x] `M11-14D` Media 使用真实 H.264 验证 proxy decode 取消、cache budget、clear、会话重建和
RGBA8 payload hash 恢复。
- [x] `M11-14E` Blender 5.2 Eevee 与 Compositor desktop golden、协议负例和 Chromium 三域门
由一个专项命令串联。
本节已由 `npm --prefix web run test:render-compositor-media-recovery``M11-14` status、专项
evidence 和 family ledger 联合收口。M12-03D 完成后,当前唯一 `nextTask``M12-03E`
## 6. M12 Asset、Library、IO 与 Editor
### M12.1 Catalog schema 与迁移
- [x] `M12-01A` 盘点 Blender 5.2 AssetMetaData、catalog、tag、author、license、description 字段;
machine inventory 绑定 8 个源码 hash 和真实 Blender 5.2 RNA覆盖 14 DNA/10 RNA/3 catalog
字段并记录 7 个 Web v1 缺口。本项为 enabling task不改变 parity slice。
- [x] `M12-01B` 生成 desktop catalog v1 fixture 和 canonical JSON锁定 Blender 5.2 生成
3 条 CDF v1 catalog、3 类资产及完整 metadata独立新进程重开后 canonical JSON 精确一致。
- [x] `M12-01C` 定义 Web catalog schema、stable catalog/asset ID 和文本/条目预算schema v2
使用 Blender UUID/path 与 AssetWeakReference identity全部文本预算按 UTF-8 字节执行。
- [x] `M12-01D` 定义 schema v1→v2 前向迁移revision、source/preview/library graph 全部保留,
legacy catalog/asset ID 生成确定性 mapping 和 source/target hash report。
- [x] `M12-01E` 定义新版数据由旧版读取时的只读/阻断策略v2 对 v1 reader 只开放
hash-bound metadata snapshotcatalog/asset/save 写入统一阻断,未来 schema 完全拒绝。
- [x] `M12-01F` 重复 ID、循环父 catalog、超长文本和未知字段负例9 类 v1/v2 负例均返回
稳定 code失败后原始小正例继续通过。
- [x] `M12-01G` IndexedDB migration 失败保持旧 transaction 和旧索引;两个真实 abort 点重开后
v1 index、无关 migration 行、数据库版本均不变,成功重试才原子提交 v2 index 与 receipt。
- [x] `M12-01H` 页面/Worker 重启后 catalog 顺序和 asset identity 不变初始页面、reload、两个
顺序启动的独立 Worker 返回相同 catalog/asset order、weak-reference identity 和 manifest hash。
- [x] `M12-01I` schema fixture、migration report 和 hash 写入 M12-01 evidence聚合 checker
递归核对 A-H 任务链、desktop/Web fixture、canonical 双 hash、runtime fault/restart artifact。
### M12.2 Preview 与 content-addressed asset
- [x] `M12-02A` 盘点 Blender preview image 尺寸、颜色空间和无 preview 状态;锁定 DNA/RNA、
ICON/PREVIEW 双槽、RGBA8 premultiplied alpha、无颜色空间字段及 3 个 null preview runtime 状态。
- [x] `M12-02B` 定义 preview source/content SHA-256、MIME、width/height 和生成器 identity
Blender 5.2 确定性重编码绑定 executable/script/settingssource/content 独立且像素 exact。
- [x] `M12-02C` 解码前检查 MIME、像素、字节和压缩比预算schema 1 在任何 decoder/
decoded allocation 前完成 identity、16 MiB encoded bytes、SHA-256、PNG/WebP header、尺寸、
16,777,216 像素、64 MiB RGBA8 和 100:1 compression ratio 门8 类负例后小正例仍可恢复。
- [x] `M12-02D` preview 写 OPFS 后才提交 catalog 引用production coordinator 先写入并回读复验
content-addressed payload再以一次 revision/full-row guarded IndexedDB transaction 提交 v2
preview reference 与 hash receipt三处 fault 均不发布引用,重开后 revision 8/payload 复验通过。
- [x] `M12-02E` 相同内容去重,不同 metadata 不覆盖 payload两个不同 asset/identity 在 revision
8/9 绑定同一 content hash第二次真实 OPFS write 去重且目录只有一份 payload两条完整
non-preview metadata 不变,重开后 head/reference/hash 一致。
- [x] `M12-02F` 损坏 preview 隔离且 asset metadata 仍可读取;同长度 hash drift 从 content-addressed
namespace 移入已复验 quarantine 文件catalog revision/reference/两条 metadata 均不变;页面重开
从 hash-bound receipt 返回 `QUARANTINED` 和完整 asset metadata不再发布 bytes。
- [x] `M12-02G` 删除引用只回收无其他项目引用的 payload同项目首删在仍有一个 reference 时
RETAINED末删才 COLLECTEDOPFS project namespace 使另一项目的同 hash payload/reference
保持 READYrevision 11 重开后两条 metadata 不变。
- [x] `M12-02H` desktop/browser preview 指标与主线程/Offscreen 显示通过Blender 5.2
确定性 RGBA8 reference 与两条 Canvas2D 路径逐字节一致,错误像素由同一指标门阻断。
### M12.3 Append、Link、Override 语义
- [x] `M12-03A` 分别盘点 append、link、library override 涉及的数据块和依赖闭包;冻结 36 类
selectable root、2 类 append-only、direct/indirect/transitive/embedded/override dependency
角色及 17 个 override RNA 字段。
- [x] `M12-03B` 为三种操作定义 source library ID、owner、read-only 和 invalidation token
locator/hash 绑定 sourceoperation/root/owner/generation/revision/closure 共同绑定 token。
- [x] `M12-03C` desktop append 单 Object fixture记录依赖 material/mesh/image stable mapping
Blender 5.2 append 后 Object/Mesh/Material/Image 全部成为可写 local owner保存重开后依赖图、
几何、UV、材质槽和 packed image Float32 像素 hash 保持一致。
- [x] `M12-03D` WASM Main append 以一次 transaction 创建本地 owner 数据Worker 先校验 source
hash、closure 和 revisionBlender Main 递归导入 Object/Mesh/Material/packed Image四个 ID
均验证为可写 local owner并只推进一个 SceneIR revision。
- [ ] `M12-03E` append undo/redo/save/reopen 与 desktop canonical report 一致。
- [ ] `M12-03F` desktop link fixture 保留 source library 和只读 ownership。
- [ ] `M12-03G` linked data writer 全部返回 `LINKED_DATA_MUTATION_BLOCKED`
- [ ] `M12-03H` library reload 只替换匹配 generation 的 linked snapshot。
- [ ] `M12-03I` missing library 保留 placeholder 和原始 source不删除引用。
- [ ] `M12-03J` desktop override fixture 记录 reference、local owner 和 property override path。
- [ ] `M12-03K` 首个 override writer 只开放一个已验证属性。
- [ ] `M12-03L` override stale source/revision 在 Main commit 前阻断。
- [ ] `M12-03M` dependency cycle、ID collision、跨库环和重复 reload 负例。
- [ ] `M12-03N` append/link/override 各有独立 desktop/WASM/Chromium 命令。
### M12.4 Origin、路径与 archive
- [ ] `M12-04A` library source schema 只接受声明的 HTTPS origin、项目 asset 或用户选择文件。
- [ ] `M12-04B` 规范化 POSIX/Windows separator、`.``..`、percent encoding 和 Unicode 名称。
- [ ] `M12-04C` 拒绝绝对路径、UNC、drive path、NUL、控制字符和 origin 逃逸。
- [ ] `M12-04D` 符号链接/hardlink entry 在写入前解析并限制在临时根。
- [ ] `M12-04E` archive 先读取 central directory/manifest不先解压 payload。
- [ ] `M12-04F` 单 entry 字节、总字节、entry 数、目录深度和文件名长度预算。
- [ ] `M12-04G` 压缩比、重叠 range、重复路径、文件/目录前缀冲突负例。
- [ ] `M12-04H` 解压取消删除 staging不修改已提交项目。
- [ ] `M12-04I` quota/OOM 后释放临时文件并允许小 archive 恢复。
- [ ] `M12-04J` 恶意 ZIP/TAR fixture 进入长期安全回归。
### M12.5 格式 capability matrix
- [ ] `M12-05A` 从 Blender 5.2 build/runtime 生成 glTF/GLB、OBJ、STL、PLY、USD、Alembic 清单。
- [ ] `M12-05B` 每种格式分别声明 import/export、local/server、geometry/material/animation 支持。
- [ ] `M12-05C` matrix 未声明组合在文件选择器和 operator search 中不可执行。
- [ ] `M12-05D` 能力由 runtime receipt 决定,不按扩展名推断。
- [ ] `M12-05E` 每个 receipt 绑定 source/settings/runtime hash。
- [ ] `M12-05F` 伪造、过期或跨版本 receipt 在使用前拒绝。
### M12.6 GLB round-trip
- [ ] `M12-06A` desktop 生成 Mesh/PBR/UV/skin/animation GLB fixture 组。
- [ ] `M12-06B` Web import 比较 topology、attributes、materials、nodes 和 animations。
- [ ] `M12-06C` import 后保存 `.blend`、重开并比较 stable ID。
- [ ] `M12-06D` Web export 生成 machine loss report。
- [ ] `M12-06E` desktop Blender 再导入 Web GLB 并比较 canonical report。
- [ ] `M12-06F` sparse accessor、Draco/extension、外部 URI 和超预算负例。
- [ ] `M12-06G` import/export 取消、Worker restart 和 OPFS quota 恢复。
### M12.7 OBJ、STL、PLY round-trip
- [ ] `M12-07A` OBJ 单 Mesh 正例position/normal/UV/material group。
- [ ] `M12-07B` OBJ 多对象、负索引、MTL/texture origin 和坏 face 负例。
- [ ] `M12-07C` OBJ Web→desktop round-trip 与 loss report。
- [ ] `M12-07D` STL binary/ASCII capability 分开声明。
- [ ] `M12-07E` STL normal、unit、degenerate triangle 和 trailing bytes 对标。
- [ ] `M12-07F` STL Web→desktop round-trip 与材质缺失 loss report。
- [ ] `M12-07G` PLY ASCII/binary little-endian capability 分开声明。
- [ ] `M12-07H` PLY vertex/face/color/custom property 映射与未知 property loss report。
- [ ] `M12-07I` PLY big-endian/坏 list/超大 count 稳定阻断。
- [ ] `M12-07J` 三格式分别执行取消、OOM、重启和小文件恢复。
### M12.8 USD 与 Alembic
- [ ] `M12-08A` 固定有 USD/Alembic 的 Blender 5.2 server runtime identity。
- [ ] `M12-08B` 本地 runtime 缺能力时只能路由 `SERVER_EXACT`,不能静默降级 GLB。
- [ ] `M12-08C` USD fixture 覆盖 hierarchy、xform、mesh、material、camera、animation。
- [ ] `M12-08D` Alembic fixture 覆盖 topology sample、transform sample 和 frame range。
- [ ] `M12-08E` server request 绑定 source/settings/build hash。
- [ ] `M12-08F` server output 由新 Blender 进程重开并导出 canonical report。
- [ ] `M12-08G` server cancel/timeout/OOM 不产生可绑定 output。
- [ ] `M12-08H` 外部引用和不可映射语义进入 loss report。
### M12.9 统一 Editor context
- [ ] `M12-09A` 冻结 Workspace/Area/Region/Editor/Mode/Tool context schema。
- [ ] `M12-09B` View3D、Outliner、Properties 读取同一 selection revision。
- [ ] `M12-09C` 任一 editor selection 更新只提交一次共享 store transaction。
- [ ] `M12-09D` stale editor event 不覆盖较新 Main/selection revision。
- [ ] `M12-09E` hidden/locked/library object 在三 editor 中保持相同可选规则。
- [ ] `M12-09F` frame、active object、mode 和 tool 在 Worker restart 后恢复。
- [ ] `M12-09G` editor dispose 移除 DOM listener、pending request、GPU resource 和 timer。
- [ ] `M12-09H` workspace/layout 保存只改 UI revision不改 Blender scene revision。
### M12.10 专用 Editor 逐项开放
- [ ] `M12-10A` UV Editor 先只读显示 Main UV 与 selection。
- [ ] `M12-10B` UV 单个 writer 通过 desktop/Main/undo/save 后开放。
- [ ] `M12-10C` Shader Node Editor 只显示完整图unsupported node 不可编辑但不得丢失。
- [ ] `M12-10D` Geometry Node Editor 只开放已有 Main writer 的 allowlist node。
- [ ] `M12-10E` Graph Editor 显示 FCurve/key/handle单 operator 单独验收。
- [ ] `M12-10F` Dope Sheet 与 NLA 共享 frame/selection/action revision。
- [ ] `M12-10G` Sequencer editor 只开放已完成 Main writer 的 strip operation。
- [ ] `M12-10H` Compositor editor 保留 unsupported graph 并显示执行 gate。
- [ ] `M12-10I` Spreadsheet 只读取有预算的 evaluated attributes。
- [ ] `M12-10J` 每个 editor 各有 context、dispose、键盘和窄屏 E2E。
### M12.11 Operator、keymap 与 workflow
- [ ] `M12-11A` operator search 直接由 capability registry 生成。
- [ ] `M12-11B` registry、UI、协议和 Main writer 不维护第二份成功列表。
- [ ] `M12-11C` operator poll 使用当前 area/region/mode/selection/tool context。
- [ ] `M12-11D` keymap 按 editor→modal→tool→global 确定性解析。
- [ ] `M12-11E` 冲突显示来源和胜出规则,不随机采用注册顺序。
- [ ] `M12-11F` Escape、Enter、undo、redo 在所有 modal tool 保持一致。
- [ ] `M12-11G` 建模 workflowimport→select→edit→undo/redo→save/reopen→export。
- [ ] `M12-11H` 动画 workflowkey→curve→NLA→frame seek→save/reopen→export。
- [ ] `M12-11I` asset workflowcatalog→append/link→override→reload→package。
- [ ] `M12-11J` 三 workflow 各覆盖取消、Worker crash、quota 和诊断导出。
M12 退出条件所有上述任务有独立报告Asset/IO/Editor family 仍有未盘点项时不得宣称完整。
## 7. M13 Scripting、安全与 Server
### M13.1 默认拒绝与只读盘点
- [ ] `M13-01A` 盘点 Text、Python Console、autorun、driver expression、handler 和 add-on 入口。
- [ ] `M13-01B` `.blend` 打开时只读取脚本 metadata不执行任意内容。
- [ ] `M13-01C` autorun、register、install、driver execution 默认返回稳定 policy code。
- [ ] `M13-01D` UI 不提供绕过协议直接 eval 的入口。
- [ ] `M13-01E` 保存/重开保留原 Text 数据块和未知脚本,不重写源码。
- [ ] `M13-01F` malicious text、driver、handler 和 embedded module fixture 进入负例。
### M13.2 Script manifest 与签名
- [ ] `M13-02A` manifest 限制文本数量、总字节、module、path、dependency 和 permission。
- [ ] `M13-02B` canonical serialization 固定签名输入。
- [ ] `M13-02C` 定义 signer identity、key rotation、revocation 和 timestamp policy。
- [ ] `M13-02D` signature 只批准声明内容source hash 变化立即失效。
- [ ] `M13-02E` permission 默认最小化,未知 permission 阻断。
- [ ] `M13-02F` replay、key confusion、过期、撤销和多签顺序负例。
### M13.3 浏览器 sandbox
- [ ] `M13-03A` sandbox scope 不暴露 DOM、主 Worker、OPFS、IndexedDB 或网络。
- [ ] `M13-03B` CPU、wall time、memory、message 和 output byte budget 固定。
- [ ] `M13-03C` host call 使用显式 allowlist 和结构化参数。
- [ ] `M13-03D` sandbox crash/timeout 终止当前 job不污染 Main revision。
- [ ] `M13-03E` cancellation 后不得发布迟到 message 或 cache。
- [ ] `M13-03F` dispose 后 Worker、port、timer 和 buffer 归零。
- [ ] `M13-03G` 小脚本在同会话恢复,审计链保持连续。
### M13.4 Blender server job isolation
- [ ] `M13-04A` 每个 job 创建不可预测的一次性目录。
- [ ] `M13-04B` source 只读挂载output 写独立目录。
- [ ] `M13-04C` CPU、内存、进程、文件、时间和 output budget 由 OS/container 强制。
- [ ] `M13-04D` 默认无网络;声明 origin 使用单独 policy。
- [ ] `M13-04E` Blender 只以 background/factory-startup 和固定 startup script 启动。
- [ ] `M13-04F` stdout/stderr 截断并过滤凭据/绝对内部路径。
- [ ] `M13-04G` cancel 终止 Blender process tree 并清理临时目录。
- [ ] `M13-04H` timeout/OOM/exit signal 转换为稳定错误码。
- [ ] `M13-04I` result 验证 source/settings/build/output hash 后才进入 OPFS。
- [ ] `M13-04J` 同一 request 重试保持幂等,不绑定两个冲突结果。
### M13.5 CSP、供应链与恶意输入
- [ ] `M13-05A` CSP 禁止 inline script、eval、data script 和未声明 origin。
- [ ] `M13-05B` Worker、WASM、font、image、media 的 CSP 分别验证。
- [ ] `M13-05C` 生产依赖、构建依赖、测试依赖分别生成 inventory。
- [ ] `M13-05D` severity 门和例外包含 owner、期限、理由和替代控制。
- [ ] `M13-05E` SBOM、license、source offer 与 archive/commit hash 绑定。
- [ ] `M13-05F` malicious blend/image/font/media/archive/node graph/manifest 全矩阵。
- [ ] `M13-05G` fuzz crash 先保存最小样本,再修复,再进入长期回归。
- [ ] `M13-05H` audit record 使用严格时间顺序、request ID 和防篡改 hash chain。
M13 退出条件:任意 Python/add-on 能力只有在本地 sandbox 或 server isolation 实跑后才可从
`BLOCKED` 改为成功;仅签名验证通过不等于执行安全。
## 8. M14 跨浏览器与设备
### M14.1 Capability probe
- [ ] `M14-01A` 冻结当前 Chromium release/engine/archive hash。
- [ ] `M14-01B` Firefox probeWASM、Worker、OPFS、IndexedDB、WebGL2、WebGPU、Offscreen、isolation。
- [ ] `M14-01C` WebKit probe采用相同字段与错误码。
- [ ] `M14-01D` probe 记录浏览器/OS/GPU adapter不按 user-agent 猜测能力。
- [ ] `M14-01E` 缺 WebGPU 只阻断依赖 WebGPU 的功能,不影响可验证 WebGL2/Main。
### M14.2 Firefox
- [ ] `M14-02A` Firefox quicktype/protocol/static asset 启动。
- [ ] `M14-02B` Firefox P0open/edit/undo/redo/save/reopen/export。
- [ ] `M14-02C` Firefox 主线程 WebGL2 像素和 context loss。
- [ ] `M14-02D` Firefox Offscreen 只在主线程通过后领取。
- [ ] `M14-02E` Firefox OPFS quota、Worker crash、OOM 和 network interruption。
- [ ] `M14-02F` Firefox full family 命令逐项 PASS/BLOCKED禁止静默 fallback。
- [ ] `M14-02G` Firefox quick/P0/full 进入 CI 后才更新支持声明。
### M14.3 WebKit
- [ ] `M14-03A` WebKit quick 启动和本地 asset 完整性。
- [ ] `M14-03B` WebKit P0 用户闭环。
- [ ] `M14-03C` WebKit 主线程 viewport。
- [ ] `M14-03D` WebKit Offscreen 独立 gate。
- [ ] `M14-03E` WebKit storage/fault/recovery。
- [ ] `M14-03F` WebKit full family matrix。
- [ ] `M14-03G` WebKit CI 和发布说明。
### M14.4 设备档位与输入
- [ ] `M14-04A` GPU/内存预算按声明设备档位选择,不自动扩容。
- [ ] `M14-04B` DPR 1/1.5/2/3 下 canvas、raycast、gizmo 和截图一致。
- [ ] `M14-04C` pointer mouse、touch、pen 分开记录 pressure/tilt/button/cancel。
- [ ] `M14-04D` IME composition 不触发未完成 operator。
- [ ] `M14-04E` US、非 US、dead key 和 modifier keymap fixture。
- [ ] `M14-04F` 触控 modal cancel、双指导航和笔 stroke 只提交一次 Main transaction。
- [ ] `M14-04G` 1440x900、1280x720、平板、手机无重叠/溢出。
- [ ] `M14-04H` keyboard-only、screen reader name/role 和 focus restore。
## 9. M15 全域自动盘点
- [ ] `M15-01A` 从 Blender 5.2 source/RNA 生成 data-block type 清单。
- [ ] `M15-01B` 生成 operator idname、poll context 和主要 property 清单。
- [ ] `M15-01C` 生成 modifier、constraint、shader/GN/compositor node 清单。
- [ ] `M15-01D` 生成 sequencer strip/effect、physics family 和 import/export 清单。
- [ ] `M15-01E` 生成 editor/space/region/workspace/keymap 清单。
- [ ] `M15-01F` 为 Main/Scene/Mesh/Depsgraph 和其余现有 V1 core slice 生成同级全量清单,
不把“当前范围完成”映射为全量完成。
- [ ] `M15-02A` 将每项映射到 owner family、implementation class、tests 和 evidence。
- [ ] `M15-02B` 找出未映射、重复映射、只有 summary、只有 proxy 和只有 route 的条目。
- [ ] `M15-02C` `LOCAL_EXACT` 缺 desktop/WASM 同 fixture 时降为 `BLOCKED`
- [ ] `M15-02D` `LOCAL_EQUIVALENT` 缺 Main/save 证据时降为 `BLOCKED`
- [ ] `M15-02E` `SERVER_EXACT` 缺真实 job/cancel/isolation/result binding 时降为 `BLOCKED`
- [ ] `M15-02F` unknown data 保存破坏原文件时对应 reader/writer family 降为 `BLOCKED`
- [ ] `M15-03A` 为每个 gap 生成一个最小 `nextTask`,不生成“完成整个 family”大任务。
- [ ] `M15-03B` nextTask 包含 fixture、desktop command、Web command、比较器和退出条件。
- [ ] `M15-03C` 依赖图无缺失、无环、每次只允许一个 active task。
- [ ] `M15-03D` 机器统计分别输出 inventoried/completed/blocked/uninventoried。
- [ ] `M15-03E` 所有生成的 owner family包括当前 N-015 至 N-026分别达到零 gap禁止由
12-family 或 release 聚合 gate 反推未纳入当前账本的 core family 已完成。
## 10. M16-M22 全域差距清零波次
这些里程碑只定义 owner 和顺序。具体任务由 M15 生成,每个任务仍使用第 3 节的 14 步模板。
### M16 Main、Mesh、Modifier、Sculpt
- [ ] `M16-01` `.blend` reader/writer unknown-data preservation 差距清零。
- [ ] `M16-02` Object/Collection/parent/transform operator 差距清零。
- [ ] `M16-03` Mesh create/delete/select/topology/attribute operator 差距清零。
- [ ] `M16-04` UV/normal/data-transfer operator 差距清零。
- [ ] `M16-05` Modifier family 与 Depsgraph 求值差距清零。
- [ ] `M16-06` PBVH Sculpt brush/mask/face-set 差距清零。
### M17 Rigging、Animation、Constraint、NLA
- [ ] `M17-01` Armature edit/pose/bone collection 差距清零。
- [ ] `M17-02` Constraint family 与 dependency order 差距清零。
- [ ] `M17-03` Shape key、driver 安全执行和 property animation 差距清零。
- [ ] `M17-04` FCurve/key/handle/modifier/extrapolation 差距清零。
- [ ] `M17-05` NLA track/strip/blend/transition/meta/time 差距清零。
- [ ] `M17-06` animation export/import loss 差距清零。
### M18 Non-mesh、Grease Pencil、Paint
- [ ] `M18-01` Curve/Surface/Text/Metaball 完整 data/operator 差距清零。
- [ ] `M18-02` Curves/Hair/PointCloud/Volume data/operator 差距清零。
- [ ] `M18-03` Grease Pencil layer/frame/drawing/material/modifier/editor 差距清零。
- [ ] `M18-04` Vertex/Weight/Texture Paint 与 PBVH/UDIM 差距清零。
- [ ] `M18-05` desktop/browser/export golden 差距清零。
### M19 Geometry Nodes、Shader、Simulation
- [ ] `M19-01` Geometry Nodes node/socket/field/domain/group 清单差距清零。
- [ ] `M19-02` Geometry Nodes external resource/instance/lazy-function 差距清零。
- [ ] `M19-03` Shader node/texture/color/sampler/compiler 差距清零。
- [ ] `M19-04` Rigid/Soft/Cloth/Fluid/Dynamic Paint/Particle/Hair simulation 差距清零。
- [ ] `M19-05` local/server bake、cache、playback 和 fault 差距清零。
### M20 Render、Compositor、Sequencer、Tracking
- [ ] `M20-01` Camera/Light/World/Color Management 完整字段差距清零。
- [ ] `M20-02` Eevee/WebGPU/Cycles/server render 差距清零。
- [ ] `M20-03` Compositor node/resource/HDR/tile/server 差距清零。
- [ ] `M20-04` Sequencer strip/effect/modifier/proxy/mix/encode 差距清零。
- [ ] `M20-05` Audio device/mix/sync/waveform 差距清零。
- [ ] `M20-06` Tracking/Mask editor/solve/compositor binding 差距清零。
### M21 Asset、IO、Editors、Scripting
- [ ] `M21-01` Asset/Library/Override 全清单差距清零。
- [ ] `M21-02` Blender 启用的 import/export format 差距清零。
- [ ] `M21-03` Editor/context/operator/keymap/workspace 差距清零。
- [ ] `M21-04` Python/Text/Add-on/driver 的 local/server 安全能力差距清零。
- [ ] `M21-05` help/i18n/accessibility/preferences 差距清零。
### M22 平台与跨浏览器
- [ ] `M22-01` Chromium 全矩阵零 gap。
- [ ] `M22-02` Firefox 声明矩阵零 gap。
- [ ] `M22-03` WebKit 声明矩阵零 gap。
- [ ] `M22-04` touch/pen/HiDPI/IME/keymap 零 gap。
- [ ] `M22-05` local/server platform capability 零未声明项。
## 11. M23 完整候选发布
- [ ] `M23-01` clean checkout 重建 WASM、Web app、server image 和所有 fixture。
- [ ] `M23-02` 运行全部 desktop/WASM semantic golden。
- [ ] `M23-03` 运行 Chromium/Firefox/WebKit quick、P0、full。
- [ ] `M23-04` 运行 geometry/texture/volume/simulation/media 性能矩阵。
- [ ] `M23-05` 运行 OOM/quota/network/device/Worker/server fault 矩阵。
- [ ] `M23-06` 运行 malicious/fuzz/CSP/dependency/security 矩阵。
- [ ] `M23-07` inventory 中每个 `parityId` 均为 `VERIFIED`,所有生成的 owner family 均为
`COMPLETE`blocked/in-progress/uninventoried 均为零。
- [ ] `M23-08` binary/source/server archives 在独立机器复验。
- [ ] `M23-09` SBOM、license、source offer、runtime 和 archive hash 联合校验。
- [ ] `M23-10` 发布说明逐项列出 local/server 实现,不隐去架构差异。
- [ ] `M23-11` 从空环境部署、升级、回滚并恢复真实项目。
- [ ] `M23-12` 只有上述报告绑定同一 commit 后才允许“完整 Web Blender”声明。
## 12. 每轮执行规则
1. 从机器队列领取唯一 `nextTask`
2. 先运行现有正例,确认不是在旧失败上继续扩展。
3. 写 fixture/golden 时实际启动锁定 Blender 5.2,不手工填写运行结果。
4. 先完成协议和 fail-closed再接 Main writer再接浏览器 UI。
5. 失败结果记录到工作日志,但不写成成功 evidence。
6. 代码完成后运行专项;专项通过后运行全量 Node、typecheck、lint、build、status/evidence。
7. 更新 ledger 时只增加本轮真实完成的 slicefamily 全域状态保持 `BLOCKED` 直到 gap 为零。
8. 每轮保留下一任务的可执行入口,不提前实现无依赖保证的后续功能。

View File

@@ -3,7 +3,9 @@
更新时间2026-08-13
本文件以仓库内 `blender-5.2.0/source/blender/` 为基线,记录 Blender 功能域在 Web
项目中的迁移方式和唯一领取顺序。`completed_current_scope` 只表示声明子集通过验收,
项目中的迁移方式和当前差距。M12-M23 的唯一长期实施规则和原子任务位于
`BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md`;本文不再定义领取顺序。`completed_current_scope`
只表示声明子集通过验收,
不表示桌面 Blender 全量等价。任何未列入白名单的 operator、node、strip、physics
类型或数据块都必须返回机器可读阻断,不能由 Three.js 静默近似。
@@ -206,8 +208,8 @@ operator search这不等价于 Blender 全量 operator registry、context men
## N-026 全域发布门
1. 生成 machine-readable parity manifest:每个 Blender family 为
`LOCAL_EXACT``LOCAL_BOUNDED``SERVER``BLOCKED`
1. 当前 V1 machine-readable manifest 保留 `LOCAL_EXACT``LOCAL_BOUNDED``SERVER`
`BLOCKED`;完整对标 inventory 按新计划转换为独立 `implementationClass/parityStatus`
2. 全部正例和阻断 fixture 在 Chromium 主线程/OffscreenCanvas、离线包和 Worker restart 下运行。
3. 100k/1M/10M 几何、4k/8k texture、100/1000 frame、长媒体和 simulation cache 基准。
4. `.blend`/image/media/script fuzz、OPFS quota、OOM、设备丢失、网络中断和恢复验证。

View File

@@ -18,9 +18,9 @@ React 工作区和编辑器
-> OPFS 大文件 + IndexedDB 元数据
```
本计划是当前唯一的短周期领取队列。长期 Blender 功能对标仍记录在
`BLENDER_5_2_WEB_FEATURE_PARITY.md``status/parity-ledger.json`,但不以长期全域差距
阻断已明确限定范围的 V1。
本计划是当前唯一的短周期领取队列。M12-M23 的长期 Blender 功能实施规范以
`BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` 为准;当前实现状态仍由
`status/parity-ledger.json` 和对应 evidence 给出。长期全域差距不阻断已明确限定范围的 V1。
## 2. 文档与事实源
@@ -29,7 +29,9 @@ React 工作区和编辑器
| `WEB_BLENDER_MODELER_V1_SCOPE.md` | V1 产品契约、支持矩阵、非目标、发布标准 |
| `CURRENT_EXECUTION_PLAN.md` | 当前任务顺序、最小任务、依赖和退出条件 |
| `PROJECT_STATUS_AND_NEXT_WORK.md` | 已实现能力、风险、验证命令总览 |
| `BLENDER_5_2_WEB_FEATURE_PARITY.md` | Blender 5.2 全域长期差距 |
| `BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` | M12-M23 完整 Web 对标的长期规范事实源 |
| `BLENDER_5_2_WEB_FEATURE_PARITY.md` | Blender 5.2 功能域说明和当前差距参考 |
| `BLENDER_5_2_FULL_PARITY_WBS.md` | 完整产品覆盖检查表和历史 F 域映射参考 |
| `status/parity-ledger.json` | 功能域和 V1 slice 的机器事实 |
| `status/release-evidence.json` | 最近一次可审计发布证据快照 |
@@ -156,8 +158,8 @@ loss、恶意 blend、zip bomb、离线包和 V1 用户闭环均已在最终 M5
| M8 VDB 自动分页 | GPU 缺页反馈、range/OPFS、LRU、双视口恢复 | 已完成20/20 | 联合重开与 desktop/main/Offscreen 三轴 golden 通过 |
| M9 非 Mesh/GP/Paint | 字体、Curve、Grease Pencil、Paint 增量闭环 | 已完成14/14 | 每个新增 writer 独立通过 Main/undo/save/golden三域故障恢复闭环通过 |
| M10 GN/Shader/NLA/Simulation | 白名单求值、cache、编译与阻断 | 已完成15/15 | 四域分别通过 desktop/WASM/fault 门 |
| M11 Render/Compositor/Media | 灯光、渲染、合成、媒体执行边界 | 进行中13/14 | 本地白名单server 边界均可审计 |
| M12 Asset/IO/Editors | 资产、格式、编辑器和上下文工作流 | 未开始 | 每个格式/editor 有独立 round-trip 或稳定阻断 |
| M11 Render/Compositor/Media | 灯光、渲染、合成、媒体执行边界 | 已完成14/14 | 本地白名单server 边界与三域故障恢复均可审计 |
| M12 Asset/IO/Editors | 资产、格式、编辑器和上下文工作流 | 进行中M12-01A-I、M12-02A-H 与 M12-03A-D 完成 | 每个格式/editor 有独立 round-trip 或稳定阻断 |
| M13 Scripting/Security | 脚本默认拒绝、服务端隔离、CSP、供应链 | 未开始 | 恶意输入矩阵和 release 安全门通过 |
| M14 跨浏览器/设备 | Firefox、WebKit、触控、笔、HiDPI、IME | 未开始 | 新浏览器进入 quick/P0/full CI 后才声明支持 |
| M15 全域审计 | Blender 5.2 全域差距和下一发布 | 未开始 | 逐 family 审计,不由聚合 release gate 反推完成 |
@@ -790,7 +792,7 @@ CI 可从 lockfile 和对应源码重现当前 binary/source hash。
- [x] `M10-14` 每类 cache 验证 source hash、frame range、字节预算和版本。
- [x] `M10-15` GN/Shader/NLA/Simulation 分别建立浏览器性能、OOM 和恶意图输入门。
### M11 Lighting、Render、Compositor 与 Sequencer13/14
### M11 Lighting、Render、Compositor 与 Sequencer14/14
- [x] `M11-01` Camera/Light/World/Scene color management 建立字段级 parity 表。
- [x] `M11-02` 每个支持字段通过 Main edit、undo、save/reopen 和 viewport 映射。
@@ -820,10 +822,16 @@ CI 可从 lockfile 和对应源码重现当前 binary/source hash。
- [x] `M11-13` audio context suspend/resume、设备缺失和静音恢复有专项测试schema 1
区分 context/output 状态,真实 Chromium `AudioContext` 通过用户手势、挂起、静音恢复、
二次挂起/恢复和 close缺设备与 resume failure 保持结构化静音阻断。
- [ ] `M11-14` render/compositor/media 全部覆盖取消、重启、预算释放和恢复
- [x] `M11-14` render/compositor/media 全部覆盖取消、重启、预算释放和恢复schema 1
固定三域 cancellation/budget code 和资源归零字段,真实 `.blend`、H.264 与 WebGL 输出通过
generation 2 重开、失败不提交、显式 clear/dispose 和恢复后 output SHA-256 门。
### M12 Asset、IO、Editor 与工作流
本节 M12-M15 仅保留短周期计划形成时的里程碑摘要不再作为原子任务定义。M12-03D
机器证据完成后,唯一下一任务为 `BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` 中的
`M12-03E`;后续只能领取该文档或机器差距生成器给出的精确字母任务,不得领取下列整行摘要。
- [ ] `M12-01` asset catalog schema migration 有向前/向后兼容 fixture。
- [ ] `M12-02` append/link/override 分别定义 stable ID、所有权和失效语义。
- [ ] `M12-03` library source 只允许声明 origin路径穿越和符号链接逃逸被拒绝。
@@ -872,13 +880,15 @@ CI 可从 lockfile 和对应源码重现当前 binary/source hash。
- [ ] `M15-01` 每季度从 Blender 5.2 operator/data-block 清单重新生成 parity 差距。
- [ ] `M15-02` 每个 family 的 `COMPLETE` 必须没有 summary-only、proxy-success 或隐式 loss。
- [ ] `M15-03` LOCAL_EXACT 必须有相同 fixture 的 desktop/WASM 语义或像素 golden。
- [ ] `M15-04` LOCAL_BOUNDED 必须同时验证白名单正例和非白名单稳定阻断
- [ ] `M15-05` SERVER 必须验证提交、取消、隔离、预算、hash 和结果绑定。
- [ ] `M15-06` EXCLUDED 必须验证 UI 不开放、文件数据不丢失、保存不破坏原项目
- [ ] `M15-07` 12 个 family 分别达到自身退出条件,禁止由 N-026 聚合状态反推完成。
- [ ] `M15-04` LOCAL_EQUIVALENT 只用于平台外壳,必须验证最终 Main/文件语义与 desktop 一致
- [ ] `M15-05` SERVER_EXACT 必须验证真实 Blender 提交、取消、隔离、预算、hash 和结果绑定。
- [ ] `M15-06` BLOCKED/UNINVENTORIED 必须保持 UI fail-closed、文件保真和可诊断状态
- [ ] `M15-07` 全部生成的 owner family 分别达到自身退出条件,禁止由当前 12-family 或 N-026
聚合状态反推完整 Blender core 已完成。
- [ ] `M15-08` 全域发布候选重新跑 clean build、acceptance、性能、fault 和安全矩阵。
- [ ] `M15-09` 在独立机器复验 binary/source archive 和所有 hash。
- [ ] `M15-10` 发布说明逐项列出支持、受限、server 和 excluded 能力。
- [ ] `M15-10` 发布说明逐项列出 local exact、platform-equivalent 和 server exact 能力。
实际领取顺序固定为:先完成 M5随后 M6、M7;再按产品需求在 M8M13 中一次选择一个
最小闭环M14 只在 Chromium RC 稳定后开始;M15 是状态审计,不是把剩余功能一次性打包。
历史领取顺序先完成 M5随后 M6、M7,再在 M8-M11 中一次选择一个最小闭环。当前只领取
机器 `nextTask`M12 起使用完整对标计划的原子任务,M15 是状态审计和差距生成,不是把剩余
功能一次性打包。

View File

@@ -4,7 +4,8 @@
当前短周期任务、领取顺序和阶段退出条件统一维护在
`docs/CURRENT_EXECUTION_PLAN.md`。本文件保留实现事实、长期能力台账和完整验收命令,不再作为
V1 的逐项领取顺序V1 范围以 `docs/WEB_BLENDER_MODELER_V1_SCOPE.md` 为准
V1 的逐项领取顺序V1 范围以 `docs/WEB_BLENDER_MODELER_V1_SCOPE.md` 为准M12-M23 全功能
实施规则以 `docs/BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` 为准。
## 1. 当前结论
@@ -29,19 +30,21 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM
| --- | --- | --- | --- |
| V1 release family | 12/12 `READY` | 0 | V1 已声明功能闭环完成 |
| Blender 5.2 全域 parity | 0/12 complete | 12/12 `BLOCKED` | 不是完整 Blender Web 移植 |
| slice 台账 | 191 completed | 58 blocked | blocked 项均留在 M9-M15 长期路线 |
| slice 台账 | 195 completed | 58 blocked | blocked 项均留在 M9-M15 长期路线 |
| acceptance | 50 declarations / 49 unique passed | 0 failed | 本地 V1 RC 证据完整 |
| M6 可部署 RC | 71/71 原子任务 | 0 | 可部署 RC 已冻结 |
| M7 核心体验硬化 | 18/18 原子任务 | 0 | 已完成并进入持续回归 |
| M8 VDB 自动分页 | 20/20 原子任务 | 0 | 联合重开与 desktop/main/Offscreen 三轴 golden 通过 |
| M9 非 Mesh/GP/Paint | 14/14 原子任务 | 0 | `TOGGLE_CYCLIC` 已通过 Main/undo/save/reopen 与 Blender 5.2 goldenGP current-drawing marquee、2D/3D 共享 selection revision 及 layer/frame reorder 已通过 Main/undo/save/reopen 门Paint 主线程/Offscreen 真实 GPU depth、单 undo 分块 pointer session、normalize/limit/mirror 权重与 Blender 5.2 golden、packed/UDIM dirty tile 原子资产绑定,以及 46 项 PBVH brush 的 WASM 入口显式阻断已通过Curve、Grease Pencil、Paint 三域的 Worker restart、OOM、GPU release、小场景恢复已通过PBVH/桌面 brush 求值仍 BLOCKED |
| M10 GN/Shader/NLA/Simulation | 15/15 原子任务 | 0 | GN/Simulation cache、Shader、NLA 和 Physics 有界闭环全部完成M10-15 以四个隔离 Chromium Worker 分别通过性能、超预算/OOM-prevention、恶意输入和同会话小输入恢复门 |
| M11 Lighting/Render/Compositor/Media | 13/14 原子任务 | 1 | M11-01/02 已冻结字段 parity 并完成支持字段闭环M11-03 已让双 viewport 共用资源预算M11-04 已完成 Blender Eevee reference 图像指标M11-05/06 已完成最终渲染路由/provenanceM11-07/08 已完成有限 Compositor golden 与 Unsupported 全图阻断M11-09/10/11/12 已完成 codec/proxy/revision/export gateM11-13 已完成实时 AudioContext 恢复门 |
| M11 Lighting/Render/Compositor/Media | 14/14 原子任务 | 0 | M11-01/02 已冻结字段 parity 并完成支持字段闭环M11-03 已让双 viewport 共用资源预算M11-04 已完成 Blender Eevee reference 图像指标M11-05/06 已完成最终渲染路由/provenanceM11-07/08 已完成有限 Compositor golden 与 Unsupported 全图阻断M11-09/10/11/12 已完成 codec/proxy/revision/export gateM11-13 已完成实时 AudioContext 恢复门M11-14 已完成 Render/Compositor/Media 三域取消、重启、预算、释放和恢复门 |
| M12 Asset/IO/Editors | M12-01A-I + M12-02A-H + M12-03A-D 共 21 项 | Append undo/redo/save/reopen、Link/Override 与后续 IO/Editor 仍未完成 | WASM Main 已有单 transaction append slice但 N-023 全域 parity 仍阻断 |
当前优先级不是扩 Blender 全域功能。single/pthread、真实 HTTP、缓存升级、离线闭环、
独立归档复验、运维 runbook、RC 文档和最终三条 CI lane 均已通过M7 核心项目体验硬化
18/18 已完成并进入持续回归M9 已完成 14/14M10 已完成 15/15 并进入持续回归M11 当前
13/14后续领取点只读取机器队列最新 `nextTask`
18/18 已完成并进入持续回归M9 已完成 14/14M10 已完成 15/15 并进入持续回归M11 已完成
14/14M12-01A-I、M12-02A-H 和 M12-03A-D 已完成,机器队列的当前唯一 `nextTask` 为完整对标计划中的
`M12-03E`
## 2. 已完成并有测试覆盖的能力

64
docs/status/M11-14.md Normal file
View File

@@ -0,0 +1,64 @@
# M11-14 Status
status: done
task: close render, compositor, and media cancellation/restart/resource recovery
updated: 2026-08-17 America/New_York
## Scope
Schema 1 evidence fixes one report for each of `RENDER`, `COMPOSITOR`, and `MEDIA`.
Every report binds real source bytes, a domain-specific cancellation code, a domain-specific
budget code, generation 1/2 identity and output hashes, positive release receipts, zero
owned resources after release, and a non-empty recovered output. The suite parser rejects
undeclared fields, missing or duplicate domains, published cancellation results, restart
drift, budget mutation, and incomplete release.
The Chromium production-path case opens real Blender Main fixtures for Render and
Compositor and decodes a real H.264 fixture for Media. Render reads an actual WebGL RGBA8
frame, Compositor executes the real Main GraphIR into Float32 output, and Media generates
the initial RGBA8 proxy frame. This task closes only bounded lifecycle behavior; it does not
claim full Eevee/Cycles parity, arbitrary compositor nodes, frame-exact long-video decode,
or complete audio/video rendering.
## Evidence
- `WEB_TEST_PORT=5592 npm --prefix web run test:render-compositor-media-recovery` passed:
protocol negatives 3/3, Blender 5.2 Eevee reference checker, Blender 5.2 Compositor
golden checker, and Chromium 1/1.
- Render used `m11_render_reference.blend`, rejected a pre-cancelled open with
`OPEN_CANCELLED`, preserved the active Main, restarted WebEngine at generation 2, and
reproduced the same scene identity and WebGL output SHA-256. Aggregate texture overflow
returned `GPU_TEXTURE_BUDGET_EXCEEDED` without replacing the retained texture; dispose
reported positive released bytes/resources followed by zero owned textures.
- Compositor used `m11_compositor_allowlist.blend`, returned `COMPOSITOR_CANCELLED` from the
periodic executor check, rejected width 8193 with `COMPOSITOR_BUDGET_EXCEEDED` before
cache mutation, reproduced GraphIR/Float32 output after Worker restart, and cleared the
LRU to zero.
- Media probed and decoded `sequencer-probe.mp4`, rejected a pre-cancelled proxy decode with
`SEQUENCER_CANCELLED`, rejected a cache one byte below the frame size with
`SEQUENCER_BUDGET_EXCEEDED`, cleared the first cache, then re-probed and re-decoded the
H.264 source before reproducing the source/decode/profile identity and RGBA8 payload
SHA-256 in a rebuilt session.
- Regression passed: full Node 193/193, typecheck, lint, production build (75 modules),
render resource budget unit 3/3 plus Chromium main/Offscreen 3/3, Compositor golden unit
3/3 plus Chromium 1/1, media proxy cache unit 3/3 plus Chromium 1/1, status consistency,
release evidence, and `git diff --check`.
## Artifact Hashes
- evidence protocol: `a1b0b36cf93173347d55a955ff2e02f3666f7c28ef7f06442b86f0ee2b272286`
- Chromium production suite: `ef9cd58ce3bbd522c72075e82d6423387b61039509996851d8d5ebf3a0d86789`
- media proxy cache: `7a1dbf8e838c47e2b99bc8932997e79d3c3cf2636b6285ace083800d833e2e64`
- GPU texture store: `a87adefa67b243cfd0cfcad0573a1b646f1e8be8abd0a187e1dc29cab2954d44`
- compositor cache: `3ce4819dadb2d6de7305c3676b2708689cd8be95e6fa7894651d6a02c4658b31`
- unit: `3cec4a33f28a76423a9678bcb61244cc463f5f2f5939c3f0b6865d34063e1f55`
- Chromium spec: `1578848eda7cb26cdf1584fa13f4cc2e252e782959d489dc4454e45c8d06595a`
- golden manifest: `87f17b0437d819e00c4a0902146d6549cf713482667ead627a45ba2b46050247`
- package: `0e731a5c826a999eef372f875c6c5ce9d697fd369a929025370a74cf18a93582`
## Rollback
Remove the recovery protocol, browser suite, unit/Chromium tests, M11-14 golden manifest,
and package command. Restore the media proxy generator signature, Compositor cache clear,
and GPU texture release receipt only if no later caller depends on them. Remove the three
family lifecycle slices, restore M11 to 13/14, and move the queue back from M12-01A.

59
docs/status/M12-01A.md Normal file
View File

@@ -0,0 +1,59 @@
# M12-01A Status
status: done
task: inventory Blender 5.2 AssetMetaData, tag, and catalog fields
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The machine inventory freezes the Blender 5.2 storage and public API surface needed before
designing a full-parity Web catalog schema. It covers every `AssetMetaData` DNA member,
the `AssetTag` storage and collection operations, actual `AssetMetaData` RNA properties,
Blend read/write participation, `AssetCatalog` semantic/runtime fields, catalog definition
file version/record order, path hierarchy rules, and the current Web v1 interface snapshot.
This task deliberately does not create the desktop catalog fixture, Web schema v2,
migrations, IndexedDB behavior, or a new verified parity slice. In particular, a source
inventory is not evidence that Web preserves or writes the inventoried fields.
## Findings
- `AssetMetaData` has 14 DNA members: 10 persisted semantic/UI members, custom
`IDProperty` metadata, one runtime type pointer, one derived tag count, and ABI padding.
The real Blender runtime exposes 10 RNA properties.
- `author`, `description`, `copyright`, and `license` are optional dynamic strings. Empty
values are valid Blender state; the existing Web v1 manifest currently requires non-empty
author/license and has no description/copyright fields.
- `catalog_id` is the authoritative RFC4122 identity. `catalog_simple_name` is a read-only
recovery copy and cannot replace the UUID-to-path mapping.
- Catalog hierarchy is implicit in a cleaned UTF-8 slash path. CDF v1 records
`UUID:path:simple_name`; no parent UUID is stored. Duplicate paths are legal and resolve
deterministically by first-loaded state and UUID.
- Existing Web v1 directly represents catalog ID, tags, author, and license, but misses seven
inventoried metadata semantics and models hierarchy as an explicit `parentId` graph.
## Evidence
- `npm --prefix web run test:asset-catalog-inventory` passed on the first complete run:
`dna=14 rna=10 tag=3 catalog=3 gaps=7 next=M12-01B`.
- The checker binds eight source SHA-256 values, extracts the RNA property declarations,
launches `Blender 5.2.0 LTS --factory-startup --background`, compares the actual runtime
property identifiers/types/read-only flags, verifies Blend IO markers and CDF v1 format,
and uses the TypeScript AST to freeze the current Web interfaces.
- The inventory manifest SHA-256 is
`194d5fd0fef5044f5e82a51931a061e226445f0f53410af9dc0594c70a79e936`;
the checker SHA-256 is
`38839f47dbe82ad7931e5e444e124e87b64a604d9358be6d71cce7769439e57b`.
## Next Task
`M12-01B`: generate a desktop Blender 5.2 catalog v1 fixture and canonical JSON from the
inventoried fields. Do not begin Web schema v2 until that desktop artifact is verified.
## Rollback
Remove the inventory manifest, checker, package command, and this status entry; restore
M12-01A to pending and move `nextTask` back from M12-01B. No parity ledger rollback is
needed because this enabling task did not change a family slice.

55
docs/status/M12-01B.md Normal file
View File

@@ -0,0 +1,55 @@
# M12-01B Status
status: done
task: generate a Blender 5.2 catalog v1 fixture and canonical desktop report
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The locked Blender 5.2 runtime generates a catalog definition v1 file and a `.blend`
containing Object, Material, and World assets. A separate Blender process reopens the
fixture and exports one canonical JSON report from the inventoried AssetMetaData fields.
The report covers catalog hierarchy, assigned and nil catalog UUIDs, optional empty text,
ordered tags, active tag, preferred import method, custom ID properties, and Blender's
automatically generated Object `dimensions` metadata.
This is an enabling desktop baseline. It does not define Web schema v2, migration behavior,
stable Web asset IDs, or catalog mutation parity, and it does not change a ledger slice.
The read-only `catalog_simple_name` remains empty because Blender's RNA catalog UUID setter
clears it outside an Asset Browser context; the fixture records that behavior rather than
inventing a recovery name.
## Evidence
- `npm --prefix web run test:asset-catalog-v1-fixture` passed from a fresh temporary
directory: `catalogs=3 assets=3 metadata=complete next=M12-01C`.
- The checker verified the locked Blender version and every artifact hash, regenerated the
CDF and `.blend`, opened the generated file in a second Blender process, and compared its
canonical report exactly with the checked-in report. It then reopened the checked-in
fixture in another Blender process and repeated the exact comparison.
- Catalog definition v1 contains three deterministic UUID/path/simple-name records, including
one nested path. The canonical report contains three assets across MATERIAL, OBJECT, and
WORLD, preserves tag order and custom metadata, and records an uncataloged nil UUID.
## Artifact Hashes
- generator: `4156ec6293562d44f87ad5a268de52fbfa31a65da8263a67a2bc45bf22999307`
- canonical exporter: `3dbccd8df54d8686aa526f7967f58fd1008336c4c5b1deed01a136ba15aa3e41`
- checker: `c2813aa36482eff0b23ca0209f488cfe70ea11e4f39cc6c6036d6c47bbc8e17b`
- desktop `.blend`: `8facfe82e3ca0a0605c139ef5bdcbb82d27a6953a6add4e008b68af90bc1211f`
- catalog definition: `a72f542acb2d2239951a2b20cda0f92305e1d6d8222f84072ad1aab52e3e4c98`
- canonical report: `6fc6def0b9d0e4f51ef40c1d87a1f17074f6340c126a01aee49e5e53271c961e`
- manifest: `1ee0abf961b82195e5fb586eb9e59302e4f7f839615f1ebbdf5cdcba8b60accd`
## Next Task
`M12-01C`: define Web catalog schema v2, stable catalog/asset identity, and bounded text and
collection limits from the verified desktop baseline.
## Rollback
Remove the M12-01B generator, exporter, checker, fixture, CDF, canonical report, manifest,
package command, and this status entry. Restore M12-01B to pending and `nextTask` to
M12-01B. No parity ledger rollback is required.

50
docs/status/M12-01C.md Normal file
View File

@@ -0,0 +1,50 @@
# M12-01C Status
status: done
task: define Web catalog schema v2, stable identities, and budgets
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
Schema v2 represents Blender catalogs with canonical RFC4122 UUIDs and cleaned slash paths;
hierarchy is derived from `parentPath`, matching CDF semantics instead of retaining the Web v1
`parentId` graph. Asset identity binds Blender's `AssetWeakReference` fields
(`asset_library_identifier`, `relative_asset_identifier`) into a SHA-256 Web ID. A Blender
rename changes the relative identifier and therefore the stable ID, matching Blender's weak
reference behavior rather than promising an identity Blender does not provide.
All text budgets use UTF-8 bytes. Catalog/tag recovery strings keep Blender's 63-byte payload
limit, dynamic metadata permits empty strings, and catalog, asset, tag, custom-property,
custom-array, per-string, and aggregate-text counts are bounded. The positive schema fixture
is generated from the verified M12-01B desktop canonical report through production protocol
code. This task does not implement v1 migration, legacy-reader policy, IndexedDB migration, or
catalog mutation.
## Evidence
- `npm --prefix web run test:asset-catalog-v2` passed 4/4.
- The suite binds the desktop canonical report and Blender 5.2 weak-reference source, regenerates
schema v2 from the desktop report, parses the generated golden, and verifies deterministic
local/external identity hashes and exposed UTF-8/collection budgets.
- `npm --prefix web run typecheck`, `npm --prefix web run lint`, and `git diff --check` passed.
## Artifact Hashes
- protocol: `0949838efb2f69466368185b80bb1d2e851672d78311fcd5270eb0814576e5d7`
- generator: `b20e7dbd8508ca8ecf17d7da507b45b1225315bac7759c55c7b324683d660881`
- unit suite: `73038022124ddc7e683602b47a4804297b3a64ecd4819160c7ec086d2f6190ae`
- schema golden: `2abe279cebbffd1341dc61b1fdc5366dd1f6f42769ba36171782019ca61fb929`
- manifest: `612892b03a689ebe19804e495bd29744634ab43a3b29fff41adc2d7a5d0b67b9`
- Blender weak-reference source: `ace3d6468bcdb2f6190431e967495794b7afa35758fd82fc3941b1ce7d374635`
## Next Task
`M12-01D`: define and verify schema v1 to v2 forward migration.
## Rollback
Remove the schema v2 protocol, generator, unit suite, M12-01C golden/manifest, package command,
and this status entry. Restore M12-01C to pending and `nextTask` to M12-01C. No parity ledger
rollback is required.

48
docs/status/M12-01D.md Normal file
View File

@@ -0,0 +1,48 @@
# M12-01D Status
status: done
task: define and verify schema v1 to v2 forward migration
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The forward migration first validates the current production schema v1 manifest and rejects
undeclared fields before conversion. Canonical UUIDs are preserved; legacy catalog IDs map
deterministically through RFC 4122 UUIDv5 with a fixed namespace. The v1 parent graph becomes
Blender cleaned slash paths, and assets receive SHA-256 IDs from Blender weak-reference identity.
Logical revision is preserved. Author/license/tag/source bindings, optional source paths,
preview receipts, and the complete library dependency graph survive migration. Metadata absent
from v1 receives explicit defaults recorded in the migration report. The report binds canonical
source/target hashes and every legacy-to-v2 catalog/asset identity mapping.
## Evidence
- `npm --prefix web run test:asset-catalog-migration` passed 3/3.
- A schema v1 fixture with three catalogs, two asset kinds, one preview, two source bindings,
and two dependent libraries migrated to the checked-in schema v2 golden and exact report.
- Repeating the migration on a cloned source produced byte-equivalent semantic objects and the
same source/target SHA-256 values. A canonical source UUID remained unchanged.
- M12-01C regression, typecheck, lint, and `git diff --check` passed.
## Artifact Hashes
- migration protocol: `6d74bc5605f94e05208eda82830192d38f88066dfc409058555a8876c785f226`
- generator: `954d15c02d68e3f644b57c8c4c136ce09bd037a0af2012bf74b97f681c77dd60`
- unit suite: `6b927e7a8238e4e0a77cc0fdf783b099d8add22c5f875df2299012eb32730186`
- v1 fixture: `1cc43d181afefd63b53cb9c3626bb58195fad20187d6e534adc1ecbe65f75da5`
- v2 fixture: `38e5d1771b3d2d63ce5e10268eac4513f2043b0cbf71ab1799c1868bc2aa97fb`
- migration report: `57b972c1bbb9ae326c3f264c225c21546682442ff3194fbc343e1c46a2cd410a`
- manifest: `fea9e01b51f64a7e9c15dcfc521b3e84636525ffbe0a849f7a6f1b0be2b02e9d`
## Next Task
`M12-01E`: define the read-only/block policy when a schema v1 reader encounters schema v2.
## Rollback
Remove the migration protocol, generator, unit suite, M12-01D fixtures/report/manifest, package
command, and this status entry. Restore M12-01D to pending and `nextTask` to M12-01D. No parity
ledger rollback is required.

45
docs/status/M12-01E.md Normal file
View File

@@ -0,0 +1,45 @@
# M12-01E Status
status: done
task: define schema v2 behavior for a schema v1 reader
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
A native schema v1 document remains `READY`. A valid schema v2 document exposed to a v1
reader yields a bounded `READ_ONLY` snapshot for metadata inspection, while catalog writes,
asset writes, and save all return `ASSET_SCHEMA_DOWNGRADE_BLOCKED`. Unknown future schema
versions return non-recoverable `PROTOCOL_MISMATCH` with no snapshot.
Every result binds a canonical source SHA-256. Inspection does not mutate or downgrade the
source document, and no write path can serialize the lossy read-only projection. This task
defines compatibility behavior only; it does not implement IndexedDB migration or catalog
mutation parity.
## Evidence
- `npm --prefix web run test:asset-catalog-legacy-reader` passed 4/4.
- Generated evidence covers v2 read-only metadata, all three write/save blocks, native v1
readiness, future-schema rejection, equal source hashes, and source object immutability.
- M12-01C/D regressions, typecheck, lint, and `git diff --check` passed.
## Artifact Hashes
- compatibility protocol: `5d51029b232e03f08fc0778d4933910b2a54818b4d653fbcc185c8d764641349`
- error contract: `309ab84d5c755a69466ddb73e4b54e87caf62cbcac0f10f34d904e9f6f4a5f34`
- generator: `208f568db092e1712095d77ab28db92d5a1eb11fe62813b237ce466f9268fd4e`
- unit suite: `f6477222412044e6784b64c1be83dc9bd6050fe6a955ba5430a4ea963b20dbb4`
- compatibility report: `3c2f9241b56ad5bd742dc4152dec703bdc56eef4e6d8d60e6edf163c4571ad54`
- manifest: `cf55ea1f9430df04fb55bf07c68c1b2099b044b0da0fdb07a7251b13c459724b`
## Next Task
`M12-01F`: add duplicate ID, cyclic parent catalog, overlong text, and unknown-field negatives.
## Rollback
Remove the compatibility protocol, generator, unit suite, M12-01E report/manifest, error code,
package command, and this status entry. Restore M12-01E to pending and `nextTask` to M12-01E.
No parity ledger rollback is required.

40
docs/status/M12-01F.md Normal file
View File

@@ -0,0 +1,40 @@
# M12-01F Status
status: done
task: reject duplicate IDs, catalog cycles, oversized text, and unknown fields
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The negative matrix executes production schema v2 and migration parsers against duplicate
catalog/asset IDs, a legacy parent cycle, an inconsistent v2 `parentPath`, 64-byte UTF-8
simple-name/tag values, and unknown top-level/nested fields in both v1 and v2. The byte cases
use multi-byte text to prove limits are not JavaScript character counts.
Every failure returns a stable asset error code. The valid v2 fixture is parsed after each
negative case to prove the failed input did not poison parser state. This task adds negative
evidence only and does not change catalog parity status.
## Evidence
- `npm --prefix web run test:asset-catalog-negatives` passed 2/2 with 9/9 negative cases.
- Duplicate/cycle/parent/unknown cases returned `ASSET_MANIFEST_INVALID`; both 64-byte UTF-8
payloads returned `ASSET_BUDGET_EXCEEDED`.
- Typecheck, lint, and `git diff --check` passed.
## Artifact Hashes
- unit suite: `3a11605ac8666c8c4e1e2e85ccfd423115915820d0de6f92bdf72dff1f01ce41`
- negative cases: `74672fa0aec248fd5d9a265743ef4dfe4d417fce8a24622b8b3d6be2f95c8874`
- manifest: `127b2b605ac79a891cd6ab516ee031c02624a512e1043b111bfac2ebf1d8278d`
## Next Task
`M12-01G`: prove an IndexedDB migration failure preserves the old transaction and index.
## Rollback
Remove the M12-01F negative suite, golden/manifest, package command, and this status entry.
Restore M12-01F to pending and `nextTask` to M12-01F. No parity ledger rollback is required.

47
docs/status/M12-01G.md Normal file
View File

@@ -0,0 +1,47 @@
# M12-01G Status
status: done
task: preserve the old catalog index when an IndexedDB migration fails
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The production IndexedDB migration prepares schema v2 with the existing schema v1 parser and
migrator before opening a write transaction. It then rechecks the source row and atomically
writes the v2 index and migration receipt while deleting the v1 row. A concurrent source change
returns `REVISION_CONFLICT`; a transaction failure returns `STORAGE_TRANSACTION`.
The browser suite injects failures after the v2 target write and after the v1 source delete.
After each abort it closes and reopens the database, proving the database version, complete v1
index, unrelated legacy migration row, absent v2 row, and absent receipt are byte-for-byte
equivalent to the baseline. A subsequent retry commits the v2 index and receipt together.
## Evidence
- `WEB_TEST_PORT=5593 npm --prefix web run test:asset-catalog-indexeddb-migration` passed the
M12-01D production migration unit suite 3/3, the M12-01G artifact binding unit 1/1, and
Chromium 1/1.
- Both `AFTER_TARGET_PUT` and `AFTER_SOURCE_DELETE` returned `STORAGE_TRANSACTION`; reopening
IndexedDB after either fault produced the exact pre-migration snapshot.
- The successful retry preserved revision 7 and the M12-01D source/target hashes, removed only
the v1 row, and retained the unrelated migration row.
- Typecheck and `git diff --check` passed.
## Artifact Hashes
- production IndexedDB migration: `283a08bccb5ea301bd2aef8f01d4496972e3962f75e340479336c50e3539ba57`
- browser suite: `82c40f38898277b4053a9654537ec9c21bc01a784d1091831e139361575a81e9`
- artifact unit: `a4366a16f28b91b3ff213e0232308eaecd392731f27972cfa49a11568eb0feca`
- manifest: `2596067956211698f1b7a07d9423411e2db830b72e6b78593354953a568738e7`
## Next Task
`M12-01H`: prove catalog ordering and asset identity survive page and Worker restarts.
## Rollback
Remove the IndexedDB migration module, M12-01G unit/browser suites, manifest, package command,
and this status entry. Restore M12-01G to pending and `nextTask` to M12-01G. No parity ledger
rollback is required.

47
docs/status/M12-01H.md Normal file
View File

@@ -0,0 +1,47 @@
# M12-01H Status
status: done
task: preserve catalog order and asset identity across page and Worker restarts
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The production catalog index loader validates the stored schema v2 manifest, recomputes its
canonical SHA-256, and compares it with the committed migration receipt before publishing a
snapshot. The snapshot exposes persisted catalog order, asset order, stable asset IDs, Blender
weak-reference components, revision, and manifest identity.
A page migrates the M12-01D v1 fixture, reloads, and reads the same database again. Two separate
module Workers then open the database in sequence, with generation 1 terminated before
generation 2 starts. All four contexts must return the same complete snapshot.
## Evidence
- `WEB_TEST_PORT=5594 npm --prefix web run test:asset-catalog-restart` passed Node 5/5 and
Chromium 2/2, including the full M12-01G abort/reopen regression.
- Initial page, reloaded page, Worker generation 1, and Worker generation 2 returned exactly the
same 3 catalog IDs in order, 2 asset IDs in order, weak-reference components, revision 7, and
manifest SHA-256.
- A stored receipt whose target hash or revision drifts from the parsed v2 index is rejected as
`ASSET_MANIFEST_INVALID` before a snapshot is published.
- Typecheck, lint, and `git diff --check` passed.
## Artifact Hashes
- production IndexedDB reader: `283a08bccb5ea301bd2aef8f01d4496972e3962f75e340479336c50e3539ba57`
- restart Worker: `e6007d352555be046b89d0becd719692652ee0418cc97087bf7599155f2a538e`
- browser suite: `7945ff38dd474a74c265594b66534a280eb69407509137e60d41f1bea3b3c19b`
- artifact unit: `eb22b5cc852fa47d9bdd30afec2977facfca0dfa554e7ce738dc1bf8c4bf2122`
- manifest: `59138eb83d63ae0d397b152e587fbe005d02bc2d229ee484c279b452ea3f2eae`
## Next Task
`M12-01I`: bind the schema fixture, migration report, and hashes into M12-01 evidence.
## Rollback
Remove the restart snapshot reader, Worker/browser/unit suites, M12-01H manifest, package
command, and this status entry. Restore M12-01H to pending and `nextTask` to M12-01H. No parity
ledger rollback is required.

44
docs/status/M12-01I.md Normal file
View File

@@ -0,0 +1,44 @@
# M12-01I Status
status: done
task: bind the schema fixture, migration report, and hashes into M12-01 evidence
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The M12-01 aggregate evidence binds all eight predecessor manifests in exact queue order and
recursively verifies their declared source and artifact hashes. It separately binds the locked
Blender 5.2 desktop canonical fixture, Web schema v2 golden and protocol, schema v1/v2 migration
fixtures, production migration report, IndexedDB transaction implementation, and restart Worker.
The checker recomputes canonical source and target manifest SHA-256 values, matches them to the
migration report, verifies revision and preserved counts, and compares the persisted restart
catalog/asset order with the target fixture. This closes the schema/migration evidence section;
it does not claim catalog mutation or preview parity.
## Evidence
- `WEB_TEST_PORT=5595 npm --prefix web run test:asset-catalog-m12-evidence` passed the Blender
5.2 inventory and regenerated fixture checks, schema v2 4/4, migration 3/3, legacy-reader 4/4,
negatives 2/2, transaction/restart Node 5/5, Chromium 2/2, and final aggregate checker.
- The final checker reported `subtasks=8 catalogs=3 assets=2 next=M12-02A`.
- Source revision 7 and target revision 7 canonical hashes match the production migration report;
all A-H task links and recursive artifact hashes resolve without gaps.
- Typecheck, lint, status consistency, and `git diff --check` passed.
## Artifact Hashes
- M12-01 evidence: `2b99f64a207c2ffb966963be8a2150feef67d5bf78946939d3a49a6335b55bc3`
- aggregate checker: `7d0d9f5390648d4af79a8da6226627edb1bf99945bae1a7b1acfb2c5e2419a8b`
- package command: `a810fec6a9a24cad94cb01475f9529ac2f85da827d391a70ab7ddb57421003f8`
## Next Task
`M12-02A`: inventory Blender preview image dimensions, color space, and no-preview state.
## Rollback
Remove the M12-01 aggregate evidence, checker, package command, and this status entry. Restore
M12-01I to pending and `nextTask` to M12-01I. No parity ledger rollback is required.

43
docs/status/M12-02A.md Normal file
View File

@@ -0,0 +1,43 @@
# M12-02A Status
status: done
task: inventory Blender preview dimensions, color semantics, and no-preview state
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The machine inventory binds Blender 5.2 PreviewImage DNA, slot enum, BKE/RNA implementation,
Blend reader, asset preview generator, render-size constants, current Web v1/v2 preview protocols,
and two runtime fixtures. It records both ICON and PREVIEW slots, persisted dimensions/flags/pixel
arrays, runtime-only state, and absent-pointer behavior.
Preview pixels are packed 32-bit RGBA with four byte components and Blender's deferred load path
premultiplies alpha. PreviewImage has no persisted or RNA color-space field, and float access only
divides byte components by 255 without a color transform. The inventory therefore records color
space as unknown instead of assuming sRGB.
## Evidence
- `npm --prefix web run test:asset-preview-inventory` launched locked Blender 5.2 twice and
reported `slots=2 rna=9 absent=3 loaded=8x8 next=M12-02B`.
- The checked-in asset fixture reopened with Object, Material, and World `preview == null`.
- A real 8x8 PNG loaded through Blender's preview API produced an 8x8 PREVIEW slot, 32x32 ICON
slot, 64/256 image packed/float counts, and 1024 icon packed pixels.
- Typecheck, lint, status consistency, and `git diff --check` passed.
## Artifact Hashes
- inventory: `a4427426d94afdf1a2ccf5379d76d1797f8604a742a3f0d15bdca629272864bd`
- checker: `c9cc83d21bac5f34c6d68cb1225dffcd92a5ec76c158ddce909b620cbbcb245c`
- package command: `04f832a3c5773c8cc5294ecdcf175478eed9710496e48c63698d79043384e328`
## Next Task
`M12-02B`: define preview source/content SHA-256, MIME, dimensions, and generator identity.
## Rollback
Remove the M12-02A inventory, checker, package command, and this status entry. Restore M12-02A
to pending and `nextTask` to M12-02A. No parity ledger rollback is required.

48
docs/status/M12-02B.md Normal file
View File

@@ -0,0 +1,48 @@
# M12-02B Status
status: done
task: define preview source, content, dimensions, MIME, and generator identity
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The schema binds source bytes and encoded content as separate SHA-256/length/MIME identities.
Encoded content additionally binds width, height, RGBA8, explicit sRGB, and alpha mode. Generator
identity binds name, Blender version, executable hash, generator script hash, and canonical
settings hash. A domain-separated canonical hash covers the complete record.
The locked Blender 5.2 generator re-encodes a real 8x8 PNG with explicit PNG RGBA8, compression,
display, view-transform, exposure, and gamma settings. The source and content encodings have
different hashes and lengths while a second Blender process confirms decoded pixels are exact.
## Evidence
- `npm --prefix web run test:asset-preview-identity` passed 3/3 protocol tests and the locked
Blender generator checker.
- Two independent generation runs matched the checked-in 513-byte content SHA-256 exactly; the
261-byte source has a different SHA-256, and decoded pixel maximum error is zero.
- Source bytes, content bytes, generator settings, dimensions, identity hash, and unknown fields
each have fail-closed negative coverage.
- Typecheck and `git diff --check` passed.
## Artifact Hashes
- protocol: `2a1d877af42424014097c669e9d44c21b27e3171be185320554693cbefcd7438`
- generator: `753a26be90f7d97828737ebc3a4ab88275a7c655b4f9992406c7c35fba6535ce`
- checker: `4d8ece1a5440d202f4e79a48f1f26bc75b3fb11a02d821b8ebc5aee14b11e41a`
- unit suite: `5b7b3ccaf410d627365579fa82bf2b48fed9ef17144aad963cdbd4bb7fbd4b5a`
- identity golden: `1b1eb8416ce4aa4d83b0dc42b9cb375f1da965e3f0cb9650991289fc619a0a7b`
- content PNG: `ff139c4a1c5d388c78c5168dfd3272e80ea0d1b15b3743e29a38cd908e8c70e7`
- manifest: `096f125461b963ee2c414f98f6bb00f02034a380a0cc9c43e0d84754324adb17`
## Next Task
`M12-02C`: enforce MIME, pixel, byte, and compression-ratio budgets before preview decode.
## Rollback
Remove the preview identity protocol, generator/checker, unit suite, M12-02B goldens, package
command, error code, and this status entry. Restore M12-02B to pending and `nextTask` to M12-02B.
No parity ledger rollback is required.

46
docs/status/M12-02C.md Normal file
View File

@@ -0,0 +1,46 @@
# M12-02C Status
status: done
task: enforce MIME, pixel, byte, and compression-ratio budgets before preview decode
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The schema 1 pre-decode planner accepts only the M12-02B content identity and an exact
`ArrayBuffer` payload. It checks the 16 MiB encoded-byte limit before hashing, then binds the
content SHA-256, declared MIME to the PNG/WebP container header, header dimensions to the identity,
and dimensions to pixel and RGBA8 decoded-byte budgets. The final gate limits decoded-to-encoded
compression ratio to 100 before any browser image decoder or decoded allocation is invoked.
PNG dimensions come only from a valid signature and first IHDR header. WebP dimensions come only
from a size-bound RIFF/WEBP VP8X, VP8L, or VP8 header. Unsupported headers, content drift, and
dimension drift fail closed; a rejected input does not affect a following valid preview plan.
## Evidence
- `npm --prefix web run test:asset-preview-decode-budget` passed 5/5 M12-02B/C protocol tests.
- The positive 513-byte Blender-generated PNG plans 64 pixels and 256 decoded RGBA8 bytes without
calling a decoder.
- Eight negative cases cover payload hash/length drift, MIME mismatch, dimension mismatch,
over-width/height and decoded-byte budget, compression ratio, encoded-byte budget, and corrupt
container signature. The checked-in small preview passes immediately after each rejection.
- `npm --prefix web run typecheck`, `npm --prefix web run lint`, and `git diff --check` passed.
## Artifact Hashes
- decode protocol: `3d1c491af2503a7a6012541ed43400d680f8e666cc12a54db284aeb6ac18ff63`
- unit suite: `1a65df86d568ae8defd7cd9242f426ac900d92637eb3bed2cbb4a0fae2e86c20`
- package commands: `8563916637fc3cb29b53432ae9b80d1dcf6269f2f5c092b595e72c6b0ada55ab`
- manifest: `934b3ca29d905ad2f5c46f35acff4cf4b9ea291c423fb3aad4afe7e9f10e6244`
## Next Task
`M12-02D`: commit a catalog preview reference only after its content-addressed payload is durably
written to OPFS.
## Rollback
Remove the preview decode planner, unit suite, M12-02C manifest, package command, and this status
entry. Restore M12-02C to pending and `nextTask` to M12-02C. No parity ledger rollback is required.

49
docs/status/M12-02D.md Normal file
View File

@@ -0,0 +1,49 @@
# M12-02D Status
status: done
task: commit a catalog preview reference only after verified OPFS persistence
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The production coordinator first runs the M12-02C pre-decode gate, writes the content-addressed
preview to OPFS, reads it back, and repeats identity verification. Only then may one IndexedDB
transaction advance the catalog revision and write both the v2 preview reference and an M12-02D
head receipt. The receipt binds project, asset, base/current revision, base/current manifest hash,
preview identity, payload identity, and OPFS path.
The transaction rereads and compares the complete source catalog row before publication. A stale
revision or changed row returns `REVISION_CONFLICT`; OPFS and transaction faults return
`STORAGE_TRANSACTION`. An OPFS payload left by a failure is unreferenced, never presented as a
committed catalog preview.
## Evidence
- `WEB_TEST_PORT=5596 npm --prefix web run test:asset-preview-opfs-commit` passed the artifact unit
test 1/1 and the real Chromium OPFS/IndexedDB test 1/1.
- `BEFORE_OPFS_WRITE`, `AFTER_OPFS_WRITE`, and `AFTER_CATALOG_PUT` faults all preserved the complete
revision 7 catalog and left no commit receipt. Only the latter two left an unreferenced payload.
- Success persisted and reread the 513-byte payload before advancing the catalog to revision 8;
closing and reopening IndexedDB reproduced the same preview and receipt and rehashed OPFS bytes.
- `npm --prefix web run typecheck`, `npm --prefix web run lint`, and `git diff --check` passed.
## Artifact Hashes
- commit coordinator: `36418e480b108eb885b5d8da6633174ed1face1fa62742a64372a45280e727a2`
- Chromium suite: `043b9b06d4c0275938cc1d4f9837df95f58f281130f3c0f2b90c10540244592e`
- unit suite: `f489a1d9a4c5fb24a99bce6d31841534c7cf3981b53076a0d9e19d106e9d0d74`
- manifest: `34fe6c6ab097e58f7b648dd84cb919ecb407a5742e387c513bb3ed53db50eed6`
- package commands: `7363a8a03cb5f6b1d6bf40d8eabc90b9d48e91aeb4787b175deb4f242c676243`
## Next Task
`M12-02E`: deduplicate identical preview content without allowing different asset metadata to
overwrite the shared payload.
## Rollback
Remove the OPFS/catalog commit coordinator, Chromium and unit suites, M12-02D manifest, package
command, and this status entry. Restore M12-02D to pending and `nextTask` to M12-02D. No parity
ledger rollback is required.

45
docs/status/M12-02E.md Normal file
View File

@@ -0,0 +1,45 @@
# M12-02E Status
status: done
task: deduplicate identical preview content without merging asset metadata
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
Two catalog assets with distinct Blender weak-reference identities and distinct catalog metadata
may bind the same verified preview content. The M12-02D coordinator keys OPFS storage only by the
content SHA-256, while catalog entries and preview identity hashes remain asset-specific. A second
commit of identical bytes reuses the existing payload after a full readback/hash check.
No catalog metadata is derived from or stored inside the shared payload record. Each transaction
updates only its targeted asset's preview field and advances the guarded catalog revision.
## Evidence
- `WEB_TEST_PORT=5597 npm --prefix web run test:asset-preview-dedup` passed the artifact unit test
1/1 and real Chromium OPFS/IndexedDB test 1/1.
- Two distinct asset/identity hashes committed at revisions 8 and 9; the second commit reported
`deduplicated=true` and the OPFS content directory contained exactly one final file.
- Both catalog entries referenced the same 513-byte/hash payload while their complete non-preview
metadata remained byte-for-byte equal to their different baseline records.
- Database reopen reproduced revision 9, the second asset head receipt, and the shared preview;
stored payload bytes rehashed to the declared content digest.
## Artifact Hashes
- Chromium suite: `4e53b7a35eb109b152bd6dc0c3348dcb08879d17619968d37fc761c67aa38b40`
- unit suite: `e023fdd33e0f40c1331dfdd73cd287dc684256b9d134971ab17db8cfe2a83835`
- manifest: `7d25f1d8335481795257c643c8589d1607a865634b27a7e5d145a57ce2a92a62`
- package commands: `5a83676e821bb768f6d3e282ea4728413cf84c5f3d57672fc664fac3883873e5`
## Next Task
`M12-02F`: quarantine corrupt preview content while keeping asset metadata readable.
## Rollback
Remove the deduplication Chromium/unit suites, M12-02E manifest, package command, and this status
entry. Restore M12-02E to pending and `nextTask` to M12-02E. The shared coordinator and parity
ledger do not require rollback.

49
docs/status/M12-02F.md Normal file
View File

@@ -0,0 +1,49 @@
# M12-02F Status
status: done
task: quarantine corrupt preview content while keeping asset metadata readable
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The preview inspector validates payload byte length and SHA-256 independently from the catalog.
Healthy bytes return `READY`. Missing or mismatched bytes return a structured
`ASSET_SOURCE_HASH_MISMATCH` quarantine receipt; present corrupt bytes are copied, verified, and
then removed from the content-addressed namespace before the receipt is published.
The catalog row and its preview provenance are not rewritten on corruption. Consumers receive the
complete parsed asset metadata together with `preview=null/data=null`, so a broken optional image
cannot make the asset name, type, catalog, license, tags, or source metadata unreadable. A matching
quarantine receipt is stable across database reopen.
## Evidence
- `WEB_TEST_PORT=5598 npm --prefix web run test:asset-preview-quarantine` passed the artifact unit
test 1/1 and real Chromium OPFS/IndexedDB test 1/1.
- The checked-in 513-byte PNG first returned `READY`; same-length byte tampering then returned
`QUARANTINED` with a different actual SHA-256 and stable error code.
- The corrupt file was absent from the SHA-256 namespace and present as a verified 513-byte file in
the quarantine directory. Database reopen returned the exact same receipt without reading it.
- Catalog revision 8, the target asset metadata/reference, and the unrelated asset all remained
exactly unchanged.
## Artifact Hashes
- quarantine protocol: `a5f3330dbd16d9f578d85d9504c980fffea85b834b77340a307ce21ce9b1cb04`
- Chromium suite: `d8e1d0741765e8f8bfd47b45cf03213d8629efdbbf77dfd33846785a3ec80c27`
- unit suite: `79c836c7a3be4e9f305a962775444c766577cca87fa6d1698811e75eb21f2d9a`
- manifest: `d1a9feeb98c5332b1a14a14c5b813d75a29f172601590e1b5cde254c5059a7c5`
- package commands: `77b5cd3d3af68ac390d8fbdb9b2f64764fb4521a1d2bf22cd8a89e40d69424f6`
## Next Task
`M12-02G`: reclaim a preview payload only after its final project-scoped reference is removed,
without touching another project's copy.
## Rollback
Remove the quarantine protocol, Chromium/unit suites, M12-02F manifest, package command, and this
status entry. Restore M12-02F to pending and `nextTask` to M12-02F. Catalog and parity ledger data
do not require rollback.

47
docs/status/M12-02G.md Normal file
View File

@@ -0,0 +1,47 @@
# M12-02G Status
status: done
task: reclaim preview payload only after the final project-scoped reference is removed
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
Preview reference removal is a guarded catalog mutation: it validates the v2 row, checks the exact
base revision, clears only the target asset's preview, advances revision, and commits a manifest
hash receipt. Payload reclamation happens only after that reference transaction has completed and
only when the committed manifest has zero remaining references to the content hash.
OPFS payload ownership remains project-scoped (`projects/<projectId>/assets/sha256/...`). Therefore
collection can target only the requested project's file and cannot delete an equal-hash payload
owned and referenced by another project. Non-preview asset metadata is preserved.
## Evidence
- `WEB_TEST_PORT=5599 npm --prefix web run test:asset-preview-reference-gc` passed the artifact unit
test 1/1 and real Chromium OPFS/IndexedDB test 1/1.
- Project A began with two references to one deduplicated payload. Removing the first at revision
9->10 returned `RETAINED`, one remaining reference, and kept the file.
- Removing the final reference at revision 10->11 returned `COLLECTED` and removed project A's
payload. Project B's equal-hash payload remained present and its preview returned READY/513 bytes.
- Reopening project A reproduced revision 11, two null preview references, and unchanged metadata.
## Artifact Hashes
- reference GC: `76b9d8ccd3e7992356f33adc694001f9b0ff364788234389eacdb09198d1415e`
- Chromium suite: `2e9e9b47883997092c5afb0fb7859a484754764de19006e24a8ff2bdeb24f35a`
- unit suite: `c5de3122b5d8aab17daf0179cb4f5bb0b5d299408818849cbe7b18e40a47706e`
- manifest: `3449f2ea9488f66ea5e3f5f2423413e8e290d8ffed3b3d941bdeee3f76addd0a`
- package commands: `7d1db0932fbe7405ffdbd44b770ce7bf9815b9dfb829c1906a06bfff635ae702`
## Next Task
`M12-02H`: compare desktop/browser preview pixels with explicit metrics and pass both main-thread
and Offscreen display paths.
## Rollback
Remove the reference-GC protocol, Chromium/unit suites, M12-02G manifest, package command, and this
status entry. Restore M12-02G to pending and `nextTask` to M12-02G. Catalog and parity ledger data
do not require rollback.

51
docs/status/M12-02H.md Normal file
View File

@@ -0,0 +1,51 @@
# M12-02H Status
status: done
task: compare desktop/browser preview pixels and pass main-thread and Offscreen display paths
updated: 2026-08-17 America/New_York
enablingTask: false
parityStateChange: true
## Scope
The display path validates the content identity, encoded SHA-256, image header, dimensions, decoded
byte budget, and compression ratio before invoking a decoder. It then decodes with disabled browser
color conversion and alpha premultiplication, draws with copy composition and no smoothing, and
publishes a receipt bound to the identity, content, backend, dimensions, RGBA8 hash, and alpha count.
Blender 5.2 deterministically regenerates the 8x8 `SRGB/RGBA8/STRAIGHT` reference. Main-thread
Canvas2D and a dedicated Worker OffscreenCanvas execute the same production display implementation
and match all 256 reference bytes. This closes only the bounded preview display slice; arbitrary
profiles, Asset Browser UI, and library operations remain outside this task.
## Evidence
- `WEB_TEST_PORT=5600 npm --prefix web run test:asset-preview-display` passed the evidence unit 1/1,
Blender 5.2 desktop regeneration/hash checker, and Chromium main-thread/Offscreen test 1/1.
- Desktop, main-thread, and Offscreen output SHA-256 was
`c2ff81750b41193ce1a06d47d0ff18168a756136dbf0f2586cd0638d4bf0ef00`.
- Both browser paths reported 8x8, 256 RGBA8 bytes, 64 non-transparent pixels, and closed bitmap
ownership. Their MAE, RMS, P95, maximum channel error, bad-pixel ratio, and alpha coverage delta
were zero; foreground IoU was 1.
- A one-channel mutation returned `BLOCKED/RENDER_REFERENCE_MISMATCH` under the same metric contract.
## Artifact Hashes
- display runtime: `e4a46a40291427afc185caf016b02120ea71639ab4ab4ac250a5c3de28ee8d28`
- Offscreen Worker: `6322562f54b608b7eaac4ca3bc0f8d861f47bb8e9b37b3c2ae3d61163108724f`
- desktop checker: `d03d766e69ea0547be2c247b5d7ef8fd5ceab6b399dc996eba711fa0cac0ca54`
- desktop report: `cdf841f5d27362f6df380772ada8b7b1427d051a8b46b5c2c20aab0b597c25d0`
- Chromium suite: `340d386c6a854c44cd6f6207e3699f870b8ded2aada4747afd6ff1103c719520`
- unit suite: `f10a05900c4d1282251f7a76e7c413a18602910f55fe8400f1713f548eba3873`
- manifest: `30e6e4b6f53e07768d2ae8d853fe08a5c0782f8608320a72e41db77f6d87f2c4`
- package commands: `2510242fbc0c2da787f558365733a4db6faf9f490e2594f69dc978920ed09f21`
## Next Task
`M12-03A`: inventory the data-block and dependency closures for append, link, and library override.
## Rollback
Remove the display runtime, Worker, desktop checker/report, unit/Chromium suites, M12-02H manifest,
package command, completed ledger slice, and this status entry. Restore M12-02H to pending and the
queue to M12-02H.

55
docs/status/M12-03A.md Normal file
View File

@@ -0,0 +1,55 @@
# M12-03A Status
status: done
task: inventory append, link, and library override data-blocks and dependency closures
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
The machine inventory separately records Append, Link, and Library Override entry points,
selectable data-block roots, non-root ID roles, direct/indirect/transitive/embedded dependency
classes, terminal ownership actions, and override reference/hierarchy/property-operation fields.
The exact closure is an actual Blender Main ID pointer graph walked by
`BKE_library_foreach_ID_link`, not a hard-coded Object-to-Mesh table. The inventory therefore
preserves cross-library and override-only dependency distinctions needed by later ownership and
invalidation contracts. It does not perform a library mutation or add a parity slice.
## Findings
- Blender 5.2 exposes 36 selectable library data-block collections. Screen and WorkSpace are
append-only; Library is source/transitive metadata, while embedded Key IDs follow their owner.
- Append can keep linked, reuse local, make local, or copy local after classifying direct,
indirect, and liboverride-only dependencies. Recursive cross-library localization is explicit.
- Link retains the source `Library`, direct `ID_TAG_EXTERN`, indirect `ID_TAG_INDIRECT`, and
transitive parent-library relationships without localization.
- Override retains a linked reference alongside a local override and hierarchy root. Its 17 RNA
fields cover reference/root flags, property paths, operations, and local/reference subitems.
- Current Web metadata represents only library source, dependency IDs, and read-only state; eight
per-ID ownership, override, and invalidation semantics remain absent and mutation stays blocked.
## Evidence
- `npm --prefix web run test:library-operation-inventory` passed with
`roots=36 appendOnly=2 operations=3 overrideFields=17 gaps=8 next=M12-03B`.
- The checker binds nine Blender/Web source files and one real nested-library fixture by SHA-256,
verifies link/append action and dependency-walk tokens, launches Blender 5.2, and compares the
actual append/link operator properties, library collections, nested source, and override RNA.
## Artifact Hashes
- inventory: `0dcb16233b52b3c0be2373f489d6bf3c771c9d6564993ff98b5d63dcd322fef7`
- checker: `1581490d1923578293202d27a1c379eb2a8c5e13be418547d148cf09c6a783fd`
- package commands: `a7cf339b0cf6fab17ef63e9394b2e540af60327f7bf1f7fe95823d182e0d3e63`
## Next Task
`M12-03B`: define source library ID, owner, read-only state, and invalidation token for all three
operations.
## Rollback
Remove the library operation inventory, checker, package command, and this status entry. Restore
M12-03A to pending and the queue to M12-03A. No parity ledger rollback is required.

47
docs/status/M12-03B.md Normal file
View File

@@ -0,0 +1,47 @@
# M12-03B Status
status: done
task: define source library identity, owner, read-only state, and invalidation tokens
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
Schema 1 derives `sourceLibraryId` from a canonical source locator and source `.blend` SHA-256.
Each operation binding then fixes one source data-block, its operation-specific owner and read-only
state, source generation/revision, dependency-closure SHA-256, and a hash-derived invalidation token.
APPEND owns a writable local Main ID. LINK remains owned by the matching source library and is
fully read-only. LIBRARY_OVERRIDE owns a writable local override while retaining a read-only linked
reference and hierarchy root. This is a pure protocol task and does not mutate Blender Main.
## Evidence
- `npm --prefix web run test:library-operation-identity` passed 6/6.
- Stable source identity changes when either locator or source bytes change.
- APPEND/LINK/LIBRARY_OVERRIDE produce distinct invalidation tokens for the same source root and
reject owner substitution, read-only drift, reference substitution, unknown fields, and forgery.
- Source library/hash, generation, revision, and dependency closure drift each return
`REVISION_CONFLICT`; the unchanged state revalidates the original binding exactly.
- The one initial test expectation was corrected to the actual fail-fast exact-schema code, then
the complete suite was rerun successfully.
## Artifact Hashes
- protocol: `5371133bd98e05935cd75bab582abedec32a36559af8d212f0bc8a534ef19451`
- golden bindings: `b39c0f26697ac688c1203ae4e3e27d8a02686c7c9da9afc438c89a0b4d69370f`
- unit suite: `0e5b2c82d481180eb8eb22de16ad8cdef5e58fa7423c26011faaffbac788e547`
- manifest: `f388aedb9c0901932cf90c058f2f2b83259d9797cd6b144566a25e37a348c64b`
- package commands: `48cf41f0d0041bf4f497bc2a454cfa9469874eded1775cea50fe3249d4e0b59e`
## Next Task
`M12-03C`: create a desktop single-Object append fixture and record stable Object, Mesh, Material,
and Image mappings.
## Rollback
Remove the library operation identity protocol, golden, unit suite, manifest, package command, and
this status entry. Restore M12-03B to pending and the queue to M12-03B. No parity ledger rollback is
required.

50
docs/status/M12-03C.md Normal file
View File

@@ -0,0 +1,50 @@
# M12-03C Status
status: done
task: create a desktop single-Object append fixture and stable dependency mapping
updated: 2026-08-17 America/New_York
enablingTask: true
parityStateChange: false
## Scope
Blender 5.2 creates a source library containing one Object whose dependency closure is one Mesh,
one Material, and one packed Image. A clean desktop Main appends only the Object, saves the target,
reopens it, and exports the same canonical graph before and after reopen.
The fixture records stable source-to-local mappings, ownership, read-only state, dependency edges,
geometry/UV/material structure, and packed-image metadata and Float32 pixel SHA-256. Container hashes
remain session-bound Blender artifacts; the canonical semantic report must reproduce exactly.
## Evidence
- `npm --prefix web run test:library-append-desktop` passed.
- The generator appended one selected Object and resolved four stable local IDs with three dependency
edges.
- Object, Mesh, Material, and Image all have null library pointers, no library override, writable
`LOCAL_MAIN` ownership, and identical source/local stable names.
- Save/reopen preserved 4 vertices, 4 edges, 1 polygon, 4 loops, `UVMap`, the material slot, packed
2x2 sRGB RGBA image metadata, and Float32 pixel SHA-256
`6f0f8c231d65149e69e6ed12d370bcfa095ef90adc202065492ea8c8ef17e45a`.
- A new temporary source/target pair reproduced the canonical report after normalizing only the
session-bound `.blend` container hashes.
## Artifact Hashes
- generator: `b9d8e3bd24966b0d7d419382d7ef0b35ae8def9ee2c2236dfd3ad1a90aebe66f`
- checker: `6f8cc0d51be19423cf6fb7203c3c16f8d27e0fbb3b5b357bad5dec6c5ceb432d`
- source `.blend`: `5b60d02926efd588a6ca300ba31414cdf37dbc48786c17b383a70319057c0606`
- target `.blend`: `5cde4927f47e33f0338de38184ce5ba971825fcefd1d8dc6feb48d41a6cdb60c`
- desktop report: `b1d7b8b9e832d18d69081f63981062800e0f00f0c9aec025b18274510ed76e2a`
- manifest: `cbfbd8c919125108334dd24925b9ef8e69880983515e56841e6ead4a2b182aed`
- package commands: `b68f2bce48fcd42e4cc1399b47ba33c35495248a6c0405c2280b5b73e3ce581a`
## Next Task
`M12-03D`: append the same closure into WASM Main with one transaction and local ownership.
## Rollback
Remove the desktop append generator/checker, source and target fixtures, golden report/manifest,
package command, and this status entry. Restore M12-03C to pending and the queue to M12-03C. No
parity ledger rollback is required.

52
docs/status/M12-03D.md Normal file
View File

@@ -0,0 +1,52 @@
# M12-03D Status
status: done
task: append one dependency closure through WASM Main in one transaction
updated: 2026-08-17 America/New_York
enablingTask: false
parityStateChange: false
## Scope
The browser Worker validates a source-hash-bound APPEND binding and sends one Object closure to
the authoritative Blender Main. Blender's native link/append context recursively imports the
Object, Mesh, Material, and packed Image, verifies that every resulting ID is local and writable,
and publishes one SceneIR revision. The existing Main history transaction is used for rollback and
undo/redo; the source is never treated as a linked read-only snapshot.
## Evidence
- `EM_CACHE=/home/mes123456/workinf_Blender_Wasm/.emcache cmake --build build_web_blender6 --target web_engine -- -j2` passed;
the generated module exports `_web_engine_append_library_object`.
- `npm --prefix web run typecheck` passed.
- `WEB_TEST_PORT=5194 npm --prefix web run test:library-main-append` passed in Chromium.
- The test opens a clean `.blend`, appends the desktop fixture closure through `WebEngineClient`,
verifies `transactionCount=1`, four `LOCAL_MAIN` mappings, stable Object/Mesh/Material/Image
IDs, the material-to-image dependency, and a single revision increment.
- A stale base revision returns `REVISION_CONFLICT`; a second append colliding with the local IDs
returns `ASSET_MANIFEST_INVALID` without changing the revision. Undo removes the closure, redo
restores it, and a saved buffer reopens with the same local image and Object dependency.
- The Worker closure check accepts both the reader's `material.imageIds` representation and the
Blender node-level `imageId` representation; it does not infer ownership from a preview.
## Artifact hashes
- test: `7130453cbc81f22eb1bcf598ff3745672ac8177dccb11cef667f5b68b43e87c6`
- worker: `e3bb430e401144bf17cdcbd0de13cde0a4f904f1f9ff9d5b10a73d90f0d4c648`
- protocol: `bfd98561cbe797d7b8f07c92c53a25460c839a64ab7222b7795cf58d54dff8d7`
- source `.blend`: `5b60d02926efd588a6ca300ba31414cdf37dbc48786c17b383a70319057c0606`
- clean target `.blend`: `9b1ecbcc3d7f8079ee5469de64193ffcaa0a7b01e0f2ac340bbb18aa88734a63`
- WASM JS: `9c831d0351ac9d0714cce3b1da748cba416ced3086e3c59595cf24ac4da459ed`
- WASM binary: `7821f408687e455c6f4e185fc3741c199c1c60d409836a2fe1b32f521d81e0ea`
- Main append API: `5a71260c542fa559f73c6b82e23e72d39bd2844f5ebe12c6edc809a49d79791b`
- Main append implementation: `d0c484ab62e6ec051c8c2f67c49d750a456578aaccc2aa4f461026307af0db95`
## Next task
`M12-03E`: append undo/redo/save/reopen and the desktop canonical report must agree.
## Rollback
Remove the M12-03D Worker/e2e test, package command, WASM append export and Main append entrypoint,
restore the material closure check, and move the machine queue back to `M12-03D`. No V1 release
ledger rollback is required.

View File

@@ -1,8 +1,8 @@
# N-019 灯光与渲染
状态:`BLOCKED`Camera/Light/World/Scene metadata、Camera/Light/World 有界 Main 写回、
Three exposure/shadow 映射一条有界 Blender reference 图像指标闭环已落地Scene 颜色管理
writer、全域渲染等价和高级后端仍未实现
Three exposure/shadow 映射一条有界 Blender reference 图像指标和 Render 故障生命周期闭环
已落地Scene 颜色管理 writer、全域渲染等价和高级后端仍未实现
## 已验证切片
@@ -49,6 +49,10 @@ writer、全域渲染等价和高级后端仍未实现
绑定同一 provenance 并公开 output MIME/byte length/output SHA-256/result SHA-256。真实
loopback server 由 Blender 5.2 headless 打开 fixture 并输出 PNGsource、settings、build
和 output 篡改均在提交或消费前结构化拒绝。
14. M11-14 Render fault lifecycle真实 Eevee `.blend` 在 Chromium WebEngine/Main 打开后生成
非空 WebGL RGBA8 输出;预取消 open 返回 `OPEN_CANCELLED` 且不替换当前 MainWorker
generation 2 重开保持 scene identity 和输出 SHA-256。纹理 aggregate budget 超限不替换
已加载资源,`GPUTextureStore.dispose()` 返回正数 byte/resource 回执并归零所有 owned texture。
## 仍然阻断
@@ -75,5 +79,6 @@ WEB_TEST_PORT=5548 npm --prefix web run test:render-resource-budget
WEB_TEST_PORT=5552 npm --prefix web run test:render-reference
WEB_TEST_PORT=5556 npm --prefix web run test:render-routing
WEB_TEST_PORT=5560 npm --prefix web run test:server-render-job
WEB_TEST_PORT=5592 npm --prefix web run test:render-compositor-media-recovery
WEB_TEST_PORT=5319 npm --prefix web run test:e2e -- --grep "N-019 Scene exposure"
```

View File

@@ -1,7 +1,8 @@
# N-020 Compositor
状态:`BLOCKED`GraphIR、有界 CPU executor、真实 Main graph reader、四节点 WebGPU golden
Unsupported 全图执行门已落地;完整节点参数映射、通用 WebGPU、HDR golden 和服务端执行未实现)
状态:`BLOCKED`GraphIR、有界 CPU executor、真实 Main graph reader、四节点 WebGPU golden
Unsupported 全图执行门和故障生命周期闭环已落地;完整节点参数映射、通用 WebGPU、HDR golden
和服务端执行未实现)
## 已验证切片
@@ -35,6 +36,10 @@
完整 graph即使 Unsupported 节点未连接到 Composite也返回
`COMPOSITOR_NODE_UNSUPPORTED`。真实 Blender 5.2 Main fixture 的 Glare node name/type/
`blenderType`、graph JSON 与 revision 在失败前后完全不变;预置 cache 不能绕过该门。
10. M11-14 Compositor fault lifecycle真实 Main graph 的 CPU executor 周期取消返回
`COMPOSITOR_CANCELLED`8193 像素超尺寸输入在分配/缓存前返回
`COMPOSITOR_BUDGET_EXCEEDED`。generation 2 重开保持 GraphIR identity 与 Float32 output
SHA-256LRU `clear()` 返回已释放字节并把 entries/bytes 归零。
## 仍然阻断
@@ -54,4 +59,5 @@ WEB_TEST_PORT=5320 npm --prefix web run test:e2e -- --grep "N-020 CPU compositor
npm --prefix web run test:compositor-main-reader
WEB_TEST_PORT=5562 npm --prefix web run test:compositor-node-golden
WEB_TEST_PORT=5565 npm --prefix web run test:compositor-unsupported-gate
WEB_TEST_PORT=5592 npm --prefix web run test:render-compositor-media-recovery
```

View File

@@ -2,8 +2,8 @@
状态:`BLOCKED`strip schema、真实 Main reader、确定性时间编辑、长媒体有界索引/seek/cache、
三类实际 runtime codec probe、source/decode-bound 首帧 proxy cache、三类迟到结果 revision
gate、最终编码 server-export 路由实时 AudioContext 恢复门已落地;Main 写回、完整媒体
解码/渲染、音频波形、A/V sync、实际混音和真正服务端编码 job 仍未实现)
gate、最终编码 server-export 路由实时 AudioContext 恢复门和媒体故障生命周期闭环已落地;
Main 写回、完整媒体解码/渲染、音频波形、A/V sync、实际混音和真正服务端编码 job 仍未实现)
## 已验证切片
@@ -63,6 +63,10 @@ gate、最终编码 server-export 路由和实时 AudioContext 恢复门已落
mute 将 gain 归零unmute 恢复 0.75close 再归零并断开节点。API/构造器缺失与 resume
failure 分别稳定返回 `SEQUENCER_AUDIO_DEVICE_UNAVAILABLE`
`SEQUENCER_AUDIO_RESUME_FAILED`,不会误报输出启用。
17. M11-14 Media fault lifecycle真实 H.264 先通过 HTMLMedia probe 并解出 8x8 RGBA8 frame
预取消 proxy decode 返回 `SEQUENCER_CANCELLED` 且不发布 payload。低于一帧的 cache budget
返回 `SEQUENCER_BUDGET_EXCEEDED` 且 entries/bytes 保持 0首会话 clear 返回正数释放字节,
重建会话后 source/decode/profile identity 和 payload SHA-256 保持不变。
## 仍然阻断
@@ -86,4 +90,5 @@ WEB_TEST_PORT=5573 npm --prefix web run test:sequencer-media-cache
WEB_TEST_PORT=5576 npm --prefix web run test:sequencer-media-revision
WEB_TEST_PORT=5579 npm --prefix web run test:sequencer-final-export
WEB_TEST_PORT=5586 npm --prefix web run test:sequencer-audio-recovery
WEB_TEST_PORT=5592 npm --prefix web run test:render-compositor-media-recovery
```

View File

@@ -3,6 +3,97 @@
状态:`BLOCKED`asset catalog、来源/许可证元数据、真实 Main library inventory、库依赖与
IO 安全门已落地Append/Link/Override Main、非 GLB 本地导入和跨桌面重导入未实现)
## 完整对标盘点基线
M12-01A 已从 Blender 5.2 DNA、RNA、Blend IO、AssetCatalog/CDF 源码及真实 RNA runtime 盘点
14 个 `AssetMetaData` DNA 字段、10 个公开 RNA 属性、3 个 `AssetTag` 存储字段和 3 个 catalog
持久语义字段。M12-01B 随后用锁定 Blender 5.2 生成 3 条 CDF v1 catalog、Object/Material/World
资产和 canonical JSON并由独立 Blender 新进程精确重开复验。M12-01C 以 Blender UUID/path
和 AssetWeakReference 定义 Web schema v2 stable identity并冻结 UTF-8 文本及条目预算。三项均为
`enablingTask`。M12-01D 又完成 v1→v2 无损前向迁移,保留 revision、source、preview 与 library
graph并输出确定性 ID mapping/hash report。四项只冻结完整对标输入与迁移不增加本节已验证
slice。M12-01E 又固定 v2 对 v1 reader 的 read-only/write-block/future-reject 策略。五项仍不改变
`parityStatus=BLOCKED`
M12-01F 已补齐 duplicate catalog/asset ID、legacy parent cycle、v2 parentPath drift、UTF-8
simple-name/tag budget 和 v1/v2 unknown-field 共 9 类稳定负例;该 enabling evidence 不改变 slice。
M12-01G 已让 v1→v2 IndexedDB index migration 在同一 readwrite transaction 中提交 target、
receipt 和 source delete写 target 后或删 source 后注入失败均 abort关闭并重开后旧 v1 index、
无关 migration 行和数据库版本精确不变。该 enabling evidence 仍不改变 slice。
M12-01H 又证明初始页面、reload 和两个顺序重建的独立 Worker 读取相同 catalog/asset 顺序、
AssetWeakReference stable identity、revision 和 manifest SHA-256receipt/hash drift 在发布前阻断。
该 enabling evidence 仍不改变 slice。
M12-01I 将 A-H 八项任务链、Blender 5.2 desktop fixture、Web v2 fixture、production migration
report、canonical 双 hash 和 IndexedDB fault/restart artifact 递归绑定为一个 READY evidence。
M12.1 schema/migration 已收口,但仍是 enabling evidence不把 catalog mutation/preview 计为完成。
M12-02A 已盘点 PreviewImage 的 ICON/PREVIEW 双槽、持久尺寸/rect/flag、RGBA8 packed pixel、
premultiplied alpha、runtime deferred/invalid 状态和真正的 null pointer。原生结构/RNA 没有颜色空间
字段;现有 Web v1/v2 也缺 slot/colorSpace/alpha/generator 语义。本项仍是 inventory evidence。
M12-02B 定义 source/content 各自的 byte length、MIME、SHA-256并让 content 绑定尺寸、RGBA8、
sRGB/alpha profilegenerator 绑定 Blender version/executable/script/settings。真实 Blender 两次重编码
确定性相同source/content 编码不同但解码像素 exact本项仍是 enabling identity evidence。
M12-02C 在任何图像 decoder 或 decoded allocation 前依次检查 identity、16 MiB encoded bytes、
content SHA-256、PNG/WebP header、identity-bound dimensions、16,777,216 pixels、64 MiB RGBA8
decoded bytes 和 100:1 compression ratio。8 类失败均 fail-closed随后小 preview 可立即恢复;
本项仍是 enabling budget evidence不把 preview storage/display 计为完成。
M12-02D 先把 content-addressed preview 写入 OPFS 并回读复验,再用一次 revision/full-row guarded
IndexedDB transaction 提交 catalog v2 preview reference 和 hash receipt。OPFS 前后及 catalog put
后的三处 fault 都保持旧 catalog重开后 revision、receipt 和 payload hash 一致。本项仍不把
dedupe、损坏隔离或 viewport display 计为完成。
M12-02E 又让两个不同 Blender weak-reference identity 和不同 metadata 的 asset 共享相同 content
SHA-256第二次写入只复验并复用 OPFS payload物理文件计数为 1两个 catalog entry 的完整
non-preview metadata 不变revision 9 重开一致。本项仍不把损坏隔离或 display 计为完成。
M12-02F 将同长度但 SHA-256 漂移的 bytes 从 content-addressed namespace 移入复验后的 quarantine
文件,并写入与 catalog revision/reference 绑定的 receipt。catalog 本身及另一 asset 均不变;重开后
仍可读取完整 metadata只把 preview/data 返回 null。本项仍不把 reference GC/display 计为完成。
M12-02G 在 catalog reference transaction 完成后按 committed manifest 计数:同项目仍有一个引用时
保留 payload最后一个引用删除后才回收。OPFS project namespace 保证另一项目的同 hash payload
和 READY preview 不受影响revision 11 重开 metadata 不变。本项仍不把 display 计为完成。
M12-02H 已把 Blender 5.2 确定性重编码的 `SRGB/RGBA8/STRAIGHT` preview 固定为逐字节 reference
主线程 `HTMLCanvasElement` 与独立 Worker `OffscreenCanvas` 共用生产 decode/display 实现。两条路径
均先通过 M12-02C 的 identity/header/预算门,再以禁用色彩转换和预乘的 `ImageBitmap` 解码;输出
256-byte RGBA8 hash 与 desktop reference 完全一致MAE/RMS/P95/max/bad-pixel/alpha 指标均为零,
前景 IoU 为 1。该项新增一个已验证 preview display parity slice但不扩大到任意尺寸、色彩空间、
asset browser UI 或 Append/Link/Override。
M12-03A 已分别冻结 Append、Link 与 Library Override 的数据块/闭包边界。真实 Blender runtime
公开 36 类 selectable root其中 Screen/WorkSpace 为 append-onlyLibrary 是来源/传递依赖元数据,
Key 等 embedded ID 随 owner 进入闭包。Append 由实际 ID pointer graph 区分 direct、indirect、
cross-library、override dependency 与 reusable localLink 保留 `ID.lib`、EXTERN/INDIRECT 和
`Library.runtime.parent`Override 另记录 linked reference、local owner、hierarchy root、property/
operation 与 system dependency。该 inventory 仍不创建 ownership contract 或 Main transaction。
M12-03B 已定义 schema 1 library operation identity。`sourceLibraryId` 同时绑定 canonical source
locator 与 `.blend` SHA-256APPEND 只能由 `LOCAL_MAIN` owner 持有且可写LINK 只能由匹配的
`SOURCE_LIBRARY` owner 持有且 root/reference 均只读LIBRARY_OVERRIDE 由 `LOCAL_OVERRIDE`
持有、local 可写但 linked reference 只读。invalidation token 还绑定 operation、root、owner、
source generation/revision 与 dependency-closure hash任一漂移稳定返回 `REVISION_CONFLICT`
M12-03C 已生成真实 Blender 5.2 desktop 单 Object append fixture。选中的 Object 自动带入 Mesh、
Material 和 packed Image 依赖,四个 ID 在 target Main 中均无 source library/override 指针并映射为
可写 `LOCAL_MAIN` owner。新 Blender load 路径重开 target 后3 条依赖边、4/4/1 geometry、UVMap、
material slot、2x2 sRGB RGBA image 和 Float32 pixel SHA-256 均与 source canonical report 一致。
本项只冻结 desktop 权威 fixture不提前声明 WASM Main append、undo/redo 或完整 append parity。
该合同没有提前执行 Append 或开放 writer。
M12-03D 已把同一 source-hash-bound closure 送入 WASM Worker 的 authoritative Blender Main。Native
link/append context 递归导入 Object、Mesh、Material 和 packed ImageMain commit 前核对 source
locator、root、closure 与 base revision四个 ID 均无 `ID.lib`/override、映射为可写 `LOCAL_MAIN`
并只产生一个新的 SceneIR revision。stale revision 与 local ID collision 在 Main mutation 前阻断,
失败路径回滚完整 history state。该项只证明单 transaction Main appendundo/redo/save/reopen 与
desktop canonical report 的联合一致性仍由 M12-03E 验证。
## 已验证切片
1. N-023-A版本化 manifest 覆盖 catalog、asset kind/tag、preview、author、license、
@@ -29,11 +120,13 @@ IO 安全门已落地Append/Link/Override Main、非 GLB 本地导入和跨
已完成;大 bundle 性能仍未实现。
9. VDB 独立资源库包含 generated/official/source/derived/report/manifest/license14 条记录逐项绑定
byte length 与 SHA-256官方 sphere 使用 CC-BY-4.0 和 Git LFS 权威 hash未放入仓库工作树。
10. N-023-Apreview displayBlender 5.2 desktop RGBA8 reference、主线程 Canvas2D 与
OffscreenCanvas 的像素 SHA-256 精确一致;错误像素返回 `RENDER_REFERENCE_MISMATCH`
## 仍然阻断
- N-023-BAppend/Link/Library Override、reload/relocate 和真实 Main transaction只读 library
inventory 已完成
- N-023-BAppend undo/redo/save/reopen、Link/Library Override、reload/relocate 和完整真实 Main
parityM12-03D 的单 transaction append 已有独立证据,但不解除本项阻断
- N-023-C/DGLTF/OBJ/PLY/STL、USD/Alembic import/export/save/reopen/desktop reimport。
- N-023-E真实 zip decoder/fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件
流式性能archive 路径冲突、双向字节预算与确定性 range plan 已完成。
@@ -44,6 +137,23 @@ IO 安全门已落地Append/Link/Override Main、非 GLB 本地导入和跨
```bash
WEB_TEST_PORT=5323 npm --prefix web run test:e2e -- --grep "N-023 asset"
npm --prefix web run test:library-main-reader
npm --prefix web run test:asset-catalog-inventory
npm --prefix web run test:asset-catalog-v1-fixture
npm --prefix web run test:asset-catalog-v2
npm --prefix web run test:asset-catalog-migration
npm --prefix web run test:asset-catalog-legacy-reader
npm --prefix web run test:asset-catalog-negatives
npm --prefix web run test:asset-preview-inventory
npm --prefix web run test:asset-preview-identity
npm --prefix web run test:asset-preview-decode-budget
npm --prefix web run test:asset-preview-opfs-commit
npm --prefix web run test:asset-preview-dedup
npm --prefix web run test:asset-preview-quarantine
npm --prefix web run test:asset-preview-reference-gc
npm --prefix web run test:asset-preview-display
npm --prefix web run test:library-operation-inventory
npm --prefix web run test:library-operation-identity
npm --prefix web run test:library-main-append
npm --prefix web run test:vdb
npm --prefix web run test:vdb-native
```

View File

@@ -72,7 +72,7 @@
"v1RequiredSlices": ["B-world-scene-render-delta-roundtrip", "B-C-nanovdb-float32-wgsl-bounded-ray-integration"],
"v1ExcludedSlices": ["A", "B", "C", "D", "E", "B-C-nanovdb-production-viewport-paging-advanced-material", "E-volume-desktop-chromium-pixel-golden"],
"roadmapStatus": "planned",
"completedSlices": ["A-camera-light-world-scene-reader-partial", "A-main-camera-dof-properties-partial", "A-main-light-world-properties-partial", "A-white-balance-integrity-gate", "B-three-exposure-shadow-mapping-partial", "B-bounded-kelvin-linear-light-color", "B-world-scene-render-delta-roundtrip", "B-volume-material-manifest-boundary", "B-C-nanovdb-float32-wgsl-bounded-ray-integration", "D-final-render-server-routing-fail-closed", "D-server-render-source-build-settings-output-hash-binding", "E-bounded-realtime-blender-reference-image-metrics"],
"completedSlices": ["A-camera-light-world-scene-reader-partial", "A-main-camera-dof-properties-partial", "A-main-light-world-properties-partial", "A-white-balance-integrity-gate", "B-three-exposure-shadow-mapping-partial", "B-bounded-kelvin-linear-light-color", "B-world-scene-render-delta-roundtrip", "B-volume-material-manifest-boundary", "B-C-nanovdb-float32-wgsl-bounded-ray-integration", "D-final-render-server-routing-fail-closed", "D-server-render-source-build-settings-output-hash-binding", "E-bounded-realtime-blender-reference-image-metrics", "E-render-fault-lifecycle-recovery"],
"blockedSlices": ["A", "B", "C", "D", "E", "B-C-nanovdb-production-viewport-paging-advanced-material", "E-volume-desktop-chromium-pixel-golden"],
"acceptance": ["web:test:lighting-roundtrip", "web:e2e:N-019 Scene exposure"],
"dependencies": ["N-018"]
@@ -86,7 +86,7 @@
"v1RequiredSlices": ["B-real-main-exposure-invert-cpu-chain", "D-unsupported-node-preservation-gate"],
"v1ExcludedSlices": ["A", "B", "C", "D", "E"],
"roadmapStatus": "planned",
"completedSlices": ["A-graph-resource-cycle-schema", "A-main-graph-structure-reader-partial", "A-main-exposure-default-invert-parameter-reader", "B-bounded-cpu-executor-partial", "B-real-main-exposure-invert-cpu-chain", "B-webgpu-constant-exposure-invert-golden-partial", "B-D-unsupported-full-graph-execution-block", "C-image-operation-budget-cancel-partial", "C-content-addressed-frame-lru-cache", "D-unsupported-node-preservation-gate"],
"completedSlices": ["A-graph-resource-cycle-schema", "A-main-graph-structure-reader-partial", "A-main-exposure-default-invert-parameter-reader", "B-bounded-cpu-executor-partial", "B-real-main-exposure-invert-cpu-chain", "B-webgpu-constant-exposure-invert-golden-partial", "B-D-unsupported-full-graph-execution-block", "C-image-operation-budget-cancel-partial", "C-content-addressed-frame-lru-cache", "C-E-compositor-fault-lifecycle-recovery", "D-unsupported-node-preservation-gate"],
"blockedSlices": ["A", "B", "C", "D", "E"],
"acceptance": ["web:test:compositor-main-reader", "web:e2e:N-020 CPU compositor"],
"dependencies": ["N-019"]
@@ -100,7 +100,7 @@
"v1RequiredSlices": ["B-real-main-cross-still-frame-resolution", "C-runtime-codec-probe-gate"],
"v1ExcludedSlices": ["A", "B", "C", "D", "E"],
"roadmapStatus": "planned",
"completedSlices": ["A-strip-resource-schema", "A-main-strip-timeline-reader-partial", "B-deterministic-move-trim-split-partial", "B-source-frame-seek", "B-active-frame-dependency-resolution", "B-cross-transition-progress-input-resolution", "B-real-main-cross-still-frame-resolution", "C-runtime-codec-probe-gate", "C-runtime-image-sound-movie-byte-decode-probe", "C-source-decode-bound-movie-proxy-cache", "C-seek-scrub-decode-revision-gate", "C-audio-context-suspend-device-mute-recovery", "D-final-export-server-routing-fail-closed"],
"completedSlices": ["A-strip-resource-schema", "A-main-strip-timeline-reader-partial", "B-deterministic-move-trim-split-partial", "B-source-frame-seek", "B-active-frame-dependency-resolution", "B-cross-transition-progress-input-resolution", "B-real-main-cross-still-frame-resolution", "C-runtime-codec-probe-gate", "C-runtime-image-sound-movie-byte-decode-probe", "C-source-decode-bound-movie-proxy-cache", "C-seek-scrub-decode-revision-gate", "C-audio-context-suspend-device-mute-recovery", "C-media-fault-lifecycle-recovery", "D-final-export-server-routing-fail-closed"],
"blockedSlices": ["A", "B", "C", "D", "E"],
"acceptance": ["web:test:sequencer-main-reader", "web:e2e:N-021 sequencer"],
"dependencies": ["N-020"]
@@ -128,7 +128,7 @@
"v1RequiredSlices": ["C-glb-export-usd-analysis-gates", "E-archive-path-conflict-compressed-budget-range-plan", "E-nanovdb-opfs-tamper-rollback-quota-recovery"],
"v1ExcludedSlices": ["A", "B", "C", "D", "E", "E-nanovdb-large-bundle-performance"],
"roadmapStatus": "planned",
"completedSlices": ["A-catalog-asset-license-source-schema", "A-content-addressed-opfs-capability", "A-preview-content-signature-dimension-verification", "A-vdb-external-resource-license-sha-catalog", "B-library-dependency-order-partial", "B-main-library-inventory-reader", "C-glb-export-usd-analysis-gates", "E-archive-path-ratio-budget", "E-archive-path-conflict-compressed-budget-range-plan", "E-nanovdb-http206-range-hash-streaming-boundary", "E-nanovdb-http-retry-if-range-body-resume", "E-nanovdb-opfs-atomic-binding-worker-reopen-bundle-lru", "E-nanovdb-opfs-tamper-rollback-quota-recovery", "E-nanovdb-gpu-resident-page-lru"],
"completedSlices": ["A-catalog-asset-license-source-schema", "A-content-addressed-opfs-capability", "A-preview-content-signature-dimension-verification", "A-preview-desktop-browser-main-offscreen-display", "A-vdb-external-resource-license-sha-catalog", "B-library-dependency-order-partial", "B-main-library-inventory-reader", "C-glb-export-usd-analysis-gates", "E-archive-path-ratio-budget", "E-archive-path-conflict-compressed-budget-range-plan", "E-nanovdb-http206-range-hash-streaming-boundary", "E-nanovdb-http-retry-if-range-body-resume", "E-nanovdb-opfs-atomic-binding-worker-reopen-bundle-lru", "E-nanovdb-opfs-tamper-rollback-quota-recovery", "E-nanovdb-gpu-resident-page-lru"],
"blockedSlices": ["A", "B", "C", "D", "E", "E-nanovdb-large-bundle-performance"],
"acceptance": ["web:test:library-main-reader", "web:test:vdb-faults", "web:e2e:N-023 asset"],
"dependencies": ["N-022"]

View File

@@ -1,8 +1,8 @@
{
"schemaVersion": 4,
"source": "docs/status/parity-ledger.json",
"sourceSha256": "0a9b4c1026c42b5f77869f222b1531601ed229184a1d1737e74047c7b3406730",
"generatedAt": "2026-08-17T08:22:00.415Z",
"sourceSha256": "c8d73374c338f46c2016f6089d99efdd7b3331fc0100d64dad0f416fe66e3eb6",
"generatedAt": "2026-08-17T15:09:53.894Z",
"families": [
{
"id": "N-015",
@@ -277,7 +277,8 @@
"B-C-nanovdb-float32-wgsl-bounded-ray-integration",
"D-final-render-server-routing-fail-closed",
"D-server-render-source-build-settings-output-hash-binding",
"E-bounded-realtime-blender-reference-image-metrics"
"E-bounded-realtime-blender-reference-image-metrics",
"E-render-fault-lifecycle-recovery"
],
"blockedSlices": [
"A",
@@ -324,6 +325,7 @@
"B-D-unsupported-full-graph-execution-block",
"C-image-operation-budget-cancel-partial",
"C-content-addressed-frame-lru-cache",
"C-E-compositor-fault-lifecycle-recovery",
"D-unsupported-node-preservation-gate"
],
"blockedSlices": [
@@ -372,6 +374,7 @@
"C-source-decode-bound-movie-proxy-cache",
"C-seek-scrub-decode-revision-gate",
"C-audio-context-suspend-device-mute-recovery",
"C-media-fault-lifecycle-recovery",
"D-final-export-server-routing-fail-closed"
],
"blockedSlices": [
@@ -456,6 +459,7 @@
"A-catalog-asset-license-source-schema",
"A-content-addressed-opfs-capability",
"A-preview-content-signature-dimension-verification",
"A-preview-desktop-browser-main-offscreen-display",
"A-vdb-external-resource-license-sha-catalog",
"B-library-dependency-order-partial",
"B-main-library-inventory-reader",

View File

@@ -0,0 +1,9 @@
# This is an Asset Catalog Definition file for Blender.
#
# Generated by M12-01B from a locked Blender 5.2 runtime.
VERSION 1
11111111-1111-4111-8111-111111111111:Characters:Characters
22222222-2222-4222-8222-222222222222:Characters/Heroes:Heroes
33333333-3333-4333-8333-333333333333:Materials/Metal:Metal

View File

@@ -0,0 +1,48 @@
{
"schemaVersion": 1,
"task": "M11-14",
"blenderVersion": "5.2.0",
"domains": [
{
"domain": "RENDER",
"fixture": "tests/files/web/m11_render_reference.blend",
"sha256": "d2bea55fe4de0b00e73a9241b4d1b0c802c2eb0e6159c0cd48e007b97b9d3963",
"desktopGolden": "tests/golden/M11-04/manifest.json",
"cancellationCode": "OPEN_CANCELLED",
"budgetCode": "GPU_TEXTURE_BUDGET_EXCEEDED"
},
{
"domain": "COMPOSITOR",
"fixture": "tests/files/web/m11_compositor_allowlist.blend",
"sha256": "e844eba69002101c8cf4f376cdd844c4be78729091627d898634088707374b29",
"desktopGolden": "tests/golden/M11-07/compositor-node-golden.json",
"cancellationCode": "COMPOSITOR_CANCELLED",
"budgetCode": "COMPOSITOR_BUDGET_EXCEEDED"
},
{
"domain": "MEDIA",
"fixture": "tests/files/web/media/sequencer-probe.mp4",
"sha256": "f35a5a2765aef9d0fed7146108c699d0beb03f34414cb27e6b7a7d4871187b65",
"desktopGolden": "tests/golden/M11-09/sequencer-codec-probe.json",
"cancellationCode": "SEQUENCER_CANCELLED",
"budgetCode": "SEQUENCER_BUDGET_EXCEEDED"
}
],
"textureFixture": {
"path": "tests/files/web/resources/udim_1001.png",
"sha256": "0c66d053412de9e99f1fb83fa8f199d061df874ad1b6bda86c76cf3f499382cf"
},
"requiredStages": ["CANCELLATION", "RESTART", "BUDGET", "RELEASE", "RECOVERY"],
"invariants": {
"cancellation": "zero result publication and zero temporary resources",
"restart": "generation 1 and 2 preserve domain identity and output SHA-256",
"budget": "stable domain-specific block before committed state mutation",
"release": "positive resource and byte release followed by zero owned resources",
"recovery": "a bounded real fixture produces non-empty output after restart"
},
"verification": {
"unit": "web/tests/unit/render-compositor-media-recovery.test.mjs",
"browser": "web/tests/e2e/render-compositor-media-recovery.spec.ts",
"command": "npm --prefix web run test:render-compositor-media-recovery"
}
}

View File

@@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"task": "M12-01I",
"parentTask": "M12-01",
"parityId": "N-023-ASSET-CATALOG-SCHEMA",
"status": "READY",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"subtasks": [
{ "task": "M12-01A", "path": "tests/golden/M12-01A/asset-catalog-field-inventory.json", "sha256": "194d5fd0fef5044f5e82a51931a061e226445f0f53410af9dc0594c70a79e936" },
{ "task": "M12-01B", "path": "tests/golden/M12-01B/manifest.json", "sha256": "1ee0abf961b82195e5fb586eb9e59302e4f7f839615f1ebbdf5cdcba8b60accd" },
{ "task": "M12-01C", "path": "tests/golden/M12-01C/manifest.json", "sha256": "612892b03a689ebe19804e495bd29744634ab43a3b29fff41adc2d7a5d0b67b9" },
{ "task": "M12-01D", "path": "tests/golden/M12-01D/manifest.json", "sha256": "fea9e01b51f64a7e9c15dcfc521b3e84636525ffbe0a849f7a6f1b0be2b02e9d" },
{ "task": "M12-01E", "path": "tests/golden/M12-01E/manifest.json", "sha256": "cf55ea1f9430df04fb55bf07c68c1b2099b044b0da0fdb07a7251b13c459724b" },
{ "task": "M12-01F", "path": "tests/golden/M12-01F/manifest.json", "sha256": "127b2b605ac79a891cd6ab516ee031c02624a512e1043b111bfac2ebf1d8278d" },
{ "task": "M12-01G", "path": "tests/golden/M12-01G/manifest.json", "sha256": "2596067956211698f1b7a07d9423411e2db830b72e6b78593354953a568738e7" },
{ "task": "M12-01H", "path": "tests/golden/M12-01H/manifest.json", "sha256": "59138eb83d63ae0d397b152e587fbe005d02bc2d229ee484c279b452ea3f2eae" }
],
"schema": {
"desktopCanonical": { "path": "tests/golden/M12-01B/canonical.json", "sha256": "6fc6def0b9d0e4f51ef40c1d87a1f17074f6340c126a01aee49e5e53271c961e" },
"webV2Golden": { "path": "tests/golden/M12-01C/asset-catalog-v2.json", "sha256": "2abe279cebbffd1341dc61b1fdc5366dd1f6f42769ba36171782019ca61fb929" },
"productionProtocol": { "path": "web/protocol/asset-catalog-v2.ts", "sha256": "0949838efb2f69466368185b80bb1d2e851672d78311fcd5270eb0814576e5d7" }
},
"migration": {
"sourceFixture": { "path": "tests/golden/M12-01D/catalog-v1.json", "sha256": "1cc43d181afefd63b53cb9c3626bb58195fad20187d6e534adc1ecbe65f75da5" },
"targetFixture": { "path": "tests/golden/M12-01D/catalog-v2.json", "sha256": "38e5d1771b3d2d63ce5e10268eac4513f2043b0cbf71ab1799c1868bc2aa97fb" },
"report": { "path": "tests/golden/M12-01D/migration-report.json", "sha256": "57b972c1bbb9ae326c3f264c225c21546682442ff3194fbc343e1c46a2cd410a" },
"productionProtocol": { "path": "web/protocol/asset-catalog-migration.ts", "sha256": "6d74bc5605f94e05208eda82830192d38f88066dfc409058555a8876c785f226" },
"sourceManifestSha256": "cbdc8b03dc9ce1f38b44bae1c7459eb273126cbd6c7dd33115d2b1966e975881",
"targetManifestSha256": "0c08ed1af1dd0998c809f76d969fd21d8fdae28ad37cf5571bdc99b16bf3ec90",
"revision": 7
},
"runtime": {
"indexedDB": { "path": "web/app/src/storage/asset-catalog-indexeddb.ts", "sha256": "283a08bccb5ea301bd2aef8f01d4496972e3962f75e340479336c50e3539ba57" },
"restartWorker": { "path": "web/app/src/workers/asset-catalog-restart-test.worker.ts", "sha256": "e6007d352555be046b89d0becd719692652ee0418cc97087bf7599155f2a538e" },
"catalogCount": 3,
"assetCount": 2,
"faultPoints": ["AFTER_TARGET_PUT", "AFTER_SOURCE_DELETE"],
"restartContexts": ["INITIAL_PAGE", "RELOADED_PAGE", "WORKER_GENERATION_1", "WORKER_GENERATION_2"]
},
"claims": [
"BLENDER_5_2_FIELD_INVENTORY_BOUND",
"DESKTOP_FIXTURE_REOPENED",
"SCHEMA_V2_UTF8_BUDGETED",
"V1_TO_V2_MIGRATION_HASH_BOUND",
"LEGACY_READER_FAIL_CLOSED",
"NEGATIVE_MATRIX_RECOVERS",
"INDEXEDDB_ABORT_PRESERVES_V1",
"PAGE_WORKER_RESTART_IDENTITY_STABLE"
],
"nextTask": "M12-02A"
}

View File

@@ -0,0 +1,264 @@
{
"schemaVersion": 1,
"task": "M12-01A",
"enablingTask": true,
"parityStateChange": false,
"blenderVersion": "5.2.0",
"sources": [
{
"role": "DNA_ASSET_STORAGE",
"path": "blender-5.2.0/source/blender/makesdna/DNA_asset_types.h",
"sha256": "de97f44c71d46049b0effdf0fed6aba3f0df72d6d4ba89b88f61e7624d3b9a49"
},
{
"role": "RNA_ASSET_API",
"path": "blender-5.2.0/source/blender/makesrna/intern/rna_asset.cc",
"sha256": "92c9cd5ce718d5b250a02eaefbd1c356a962e2f69b235d23fc97447a9a567a22"
},
{
"role": "ASSET_BLEND_IO",
"path": "blender-5.2.0/source/blender/blenkernel/intern/asset.cc",
"sha256": "eb66d1bfb3efb39cd6c6a6be9e6ff23f2be10a1c427e5b998a2dbcd1ec5b9adc"
},
{
"role": "CATALOG_MODEL",
"path": "blender-5.2.0/source/blender/asset_system/AS_asset_catalog.hh",
"sha256": "6fd93dc78d0634b9c4d0ee03a23d3233be591204aea7957d1b9768b7c3508110"
},
{
"role": "CATALOG_PATH",
"path": "blender-5.2.0/source/blender/asset_system/AS_asset_catalog_path.hh",
"sha256": "9579b74fac1cb3208c96c8aa56b51c9e44d96e3416b718d77270d5f8269bc7b9"
},
{
"role": "CATALOG_FILE_API",
"path": "blender-5.2.0/source/blender/asset_system/intern/asset_catalog_definition_file.hh",
"sha256": "880f5ae42fa6c532d46b70d5264f3ac7f8e8abab8c6cf189a4e8aebdd9dd04b6"
},
{
"role": "CATALOG_FILE_FORMAT",
"path": "blender-5.2.0/source/blender/asset_system/intern/asset_catalog_definition_file.cc",
"sha256": "4316b683125df87e07e4f1116b44f58eb725b654ac9941118a148c08ee916a18"
},
{
"role": "CURRENT_WEB_V1_SNAPSHOT",
"path": "web/protocol/asset-library-io.ts",
"sha256": "5c62539db3833a2113007dfbdfd12ca06704d86e0cf0e57ad97f7fbf6acda7f4"
}
],
"assetTag": {
"fields": [
{
"name": "next",
"cType": "AssetTag*",
"sourceToken": "struct AssetTag *next",
"persistence": "LIST_LINKAGE",
"semantic": "NON_USER_LINKAGE",
"rnaProperty": null
},
{
"name": "prev",
"cType": "AssetTag*",
"sourceToken": "*prev = nullptr",
"persistence": "LIST_LINKAGE",
"semantic": "NON_USER_LINKAGE",
"rnaProperty": null
},
{
"name": "name",
"cType": "char[64]",
"sourceToken": "char name[/*MAX_NAME*/ 64]",
"persistence": "BLEND",
"semantic": "USER_VALUE",
"rnaProperty": "name",
"maximumStorageBytesIncludingNull": 64
}
],
"collectionOperations": ["new", "remove"],
"newSupportsSkipIfExists": true,
"orderIsPersistent": true
},
"assetMetaData": {
"dnaFields": [
{
"name": "local_type_info",
"cType": "AssetTypeInfo*",
"sourceToken": "struct AssetTypeInfo *local_type_info",
"persistence": "RUNTIME_ONLY",
"semantic": "CALLBACK_TYPE",
"rnaProperty": null
},
{
"name": "properties",
"cType": "IDProperty*",
"sourceToken": "struct IDProperty *properties",
"persistence": "BLEND_IDPROPERTY",
"semantic": "CUSTOM_METADATA_NO_ID_POINTERS",
"rnaProperty": "CUSTOM_ID_PROPERTIES"
},
{
"name": "catalog_id",
"cType": "bUUID",
"sourceToken": "struct bUUID catalog_id",
"persistence": "BLEND",
"semantic": "AUTHORITATIVE_CATALOG_ID",
"rnaProperty": "catalog_id"
},
{
"name": "catalog_simple_name",
"cType": "char[64]",
"sourceToken": "char catalog_simple_name[/*MAX_NAME*/ 64]",
"persistence": "BLEND",
"semantic": "RECOVERY_ONLY_NOT_AUTHORITY",
"rnaProperty": "catalog_simple_name"
},
{
"name": "author",
"cType": "char*",
"sourceToken": "char *author = nullptr",
"persistence": "BLEND_DYNAMIC_STRING",
"semantic": "OPTIONAL_DISPLAY_TEXT",
"rnaProperty": "author"
},
{
"name": "description",
"cType": "char*",
"sourceToken": "char *description = nullptr",
"persistence": "BLEND_DYNAMIC_STRING",
"semantic": "OPTIONAL_DISPLAY_TEXT",
"rnaProperty": "description"
},
{
"name": "copyright",
"cType": "char*",
"sourceToken": "char *copyright = nullptr",
"persistence": "BLEND_DYNAMIC_STRING",
"semantic": "OPTIONAL_LEGAL_TEXT",
"rnaProperty": "copyright"
},
{
"name": "license",
"cType": "char*",
"sourceToken": "char *license = nullptr",
"persistence": "BLEND_DYNAMIC_STRING",
"semantic": "OPTIONAL_LEGAL_TEXT",
"rnaProperty": "license"
},
{
"name": "tags",
"cType": "ListBaseT<AssetTag>",
"sourceToken": "ListBaseT<AssetTag> tags",
"persistence": "BLEND_ORDERED_COLLECTION",
"semantic": "FILTER_TOKENS",
"rnaProperty": "tags"
},
{
"name": "active_tag",
"cType": "short",
"sourceToken": "short active_tag = 0",
"persistence": "BLEND",
"semantic": "EDITOR_SELECTION_INDEX",
"rnaProperty": "active_tag"
},
{
"name": "tot_tags",
"cType": "short",
"sourceToken": "short tot_tags = 0",
"persistence": "BLEND_DERIVED_COUNT",
"semantic": "MUST_EQUAL_TAG_LIST_COUNT",
"rnaProperty": null
},
{
"name": "flag",
"cType": "AssetMetaDataFlag",
"sourceToken": "AssetMetaDataFlag flag = {}",
"persistence": "BLEND",
"semantic": "USE_OWN_IMPORT_METHOD_BIT",
"rnaProperty": "use_preferred_import_method"
},
{
"name": "preferred_import_method",
"cType": "eAssetImportMethod",
"sourceToken": "eAssetImportMethod preferred_import_method",
"persistence": "BLEND",
"semantic": "CONDITIONAL_IMPORT_POLICY",
"rnaProperty": "preferred_import_method"
},
{
"name": "_pad",
"cType": "char[4]",
"sourceToken": "char _pad[4] = {}",
"persistence": "ABI_PADDING",
"semantic": "NON_USER_PADDING",
"rnaProperty": null
}
],
"rnaProperties": [
{ "identifier": "author", "type": "STRING", "isReadonly": false, "isRuntime": false },
{ "identifier": "description", "type": "STRING", "isReadonly": false, "isRuntime": false },
{ "identifier": "copyright", "type": "STRING", "isReadonly": false, "isRuntime": false },
{ "identifier": "license", "type": "STRING", "isReadonly": false, "isRuntime": false },
{ "identifier": "tags", "type": "COLLECTION", "isReadonly": true, "isRuntime": false },
{ "identifier": "active_tag", "type": "INT", "isReadonly": false, "isRuntime": false },
{ "identifier": "catalog_id", "type": "STRING", "isReadonly": false, "isRuntime": false },
{ "identifier": "catalog_simple_name", "type": "STRING", "isReadonly": true, "isRuntime": false },
{ "identifier": "use_preferred_import_method", "type": "BOOLEAN", "isReadonly": false, "isRuntime": false },
{ "identifier": "preferred_import_method", "type": "ENUM", "isReadonly": false, "isRuntime": false }
],
"preferredImportMethods": ["LINK", "APPEND", "APPEND_REUSE", "PACK"],
"emptyAuthorDescriptionCopyrightLicenseAreValid": true,
"catalogIdFormat": "RFC4122_UUID_OR_NIL",
"customPropertiesMayNotReferenceDataBlocks": true
},
"catalog": {
"semanticFields": [
{ "name": "catalog_id", "cType": "CatalogID", "sourceToken": "const CatalogID catalog_id", "persistence": "CATALOG_DEFINITION_FILE" },
{ "name": "path", "cType": "AssetCatalogPath", "sourceToken": "AssetCatalogPath path", "persistence": "CATALOG_DEFINITION_FILE" },
{ "name": "simple_name", "cType": "std::string", "sourceToken": "std::string simple_name", "persistence": "CATALOG_DEFINITION_FILE_AND_ASSET_RECOVERY_COPY" }
],
"runtimeFlags": [
{ "name": "is_deleted", "sourceToken": "bool is_deleted = false" },
{ "name": "is_first_loaded", "sourceToken": "bool is_first_loaded = false" },
{ "name": "has_unsaved_changes", "sourceToken": "bool has_unsaved_changes = false" }
],
"definitionFile": {
"defaultFilename": "blender_assets.cats.txt",
"supportedVersion": 1,
"versionMarker": "VERSION ",
"recordFields": ["catalog_id", "path", "simple_name"],
"delimiter": ":",
"writeOrder": ["path", "is_first_loaded", "catalog_id"],
"emptyAndCommentLinesIgnored": true,
"firstSemanticLineMustBeVersion": true
},
"path": {
"encoding": "UTF8_BYTES",
"separator": "/",
"hierarchy": "IMPLICIT_PATH_COMPONENTS",
"leadingSlash": false,
"componentColonAllowed": false,
"cleanup": ["TRIM_COMPONENTS", "REMOVE_EMPTY_COMPONENTS", "REPLACE_INVALID_CHARACTERS"]
},
"identityRules": {
"assetReferenceAuthority": "catalog_id",
"simpleNameAuthority": false,
"parentIdStored": false,
"duplicatePathsAllowed": true,
"duplicatePathSelectionOrder": ["is_first_loaded", "catalog_id"]
}
},
"currentWebSnapshot": {
"assetCatalogIRFields": ["id", "name", "parentId"],
"assetEntryIRFields": ["id", "name", "kind", "catalogId", "tags", "author", "license", "sourceSha256", "sourcePath", "preview"],
"directlyRepresentedMetadata": ["catalog_id", "tags", "author", "license"],
"missingMetadata": ["properties", "description", "copyright", "catalog_simple_name", "active_tag", "use_preferred_import_method", "preferred_import_method"],
"semanticDrift": [
"Blender author and license are optional; Web v1 requires both to be non-empty",
"Blender catalog hierarchy is an implicit cleaned path; Web v1 stores an explicit parentId graph",
"Blender catalog simple_name is recovery metadata; Web v1 name is the only catalog label",
"Blender tag storage is 64 bytes including the terminator; Web v1 bounds JavaScript string length to 64 code units",
"Blender custom ID properties are persisted but Web v1 has no preservation field"
]
},
"nextTask": "M12-01B"
}

View File

@@ -0,0 +1,116 @@
{
"schemaVersion": 1,
"task": "M12-01B",
"enablingTask": true,
"parityStateChange": false,
"blenderVersion": "5.2.0",
"catalogDefinition": {
"fileName": "blender_assets.cats.txt",
"version": 1,
"records": [
{
"catalogId": "11111111-1111-4111-8111-111111111111",
"path": "Characters",
"simpleName": "Characters",
"parentPath": null
},
{
"catalogId": "22222222-2222-4222-8222-222222222222",
"path": "Characters/Heroes",
"simpleName": "Heroes",
"parentPath": "Characters"
},
{
"catalogId": "33333333-3333-4333-8333-333333333333",
"path": "Materials/Metal",
"simpleName": "Metal",
"parentPath": "Materials"
}
]
},
"assets": [
{
"idType": "MATERIAL",
"name": "M12 Brushed Metal",
"catalogId": "33333333-3333-4333-8333-333333333333",
"catalogSimpleName": "",
"author": "",
"description": "",
"copyright": "",
"license": "",
"tags": [
"metal"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "APPEND",
"customProperties": [
{
"name": "roughness",
"type": "FLOAT",
"value": 0.35
}
]
},
{
"idType": "OBJECT",
"name": "M12 Hero",
"catalogId": "22222222-2222-4222-8222-222222222222",
"catalogSimpleName": "",
"author": "M12 Artist",
"description": "Desktop catalog v1 object fixture",
"copyright": "Copyright 2026 M12 Fixture Authors",
"license": "CC0-1.0",
"tags": [
"character",
"hero",
"rig-ready"
],
"activeTag": 1,
"usePreferredImportMethod": true,
"preferredImportMethod": "APPEND",
"customProperties": [
{
"name": "approved",
"type": "BOOL",
"value": true
},
{
"name": "dimensions",
"type": "IDPROPERTYARRAY",
"value": [
2.0,
2.0,
0.0
]
},
{
"name": "rating",
"type": "INT",
"value": 5
},
{
"name": "source",
"type": "STR",
"value": "M12-01B"
}
]
},
{
"idType": "WORLD",
"name": "M12 Uncataloged World",
"catalogId": "00000000-0000-0000-0000-000000000000",
"catalogSimpleName": "",
"author": "M12 Artist",
"description": "Asset without a catalog assignment",
"copyright": "",
"license": "CC0-1.0",
"tags": [],
"activeTag": 0,
"usePreferredImportMethod": true,
"preferredImportMethod": "LINK",
"customProperties": []
}
],
"nextTask": "M12-01C"
}

View File

@@ -0,0 +1,60 @@
{
"schemaVersion": 1,
"task": "M12-01B",
"parentTask": "M12-01",
"parityId": "N-023-ASSET-CATALOG-SCHEMA",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"blenderVersion": "5.2.0",
"artifacts": {
"generator": {
"path": "tools/web/generate-asset-catalog-v1.py",
"sha256": "4156ec6293562d44f87ad5a268de52fbfa31a65da8263a67a2bc45bf22999307"
},
"exporter": {
"path": "tools/web/export-asset-catalog-v1.py",
"sha256": "3dbccd8df54d8686aa526f7967f58fd1008336c4c5b1deed01a136ba15aa3e41"
},
"checker": {
"path": "tools/web/check-asset-catalog-v1-fixture.mjs",
"sha256": "c2813aa36482eff0b23ca0209f488cfe70ea11e4f39cc6c6036d6c47bbc8e17b"
},
"fixture": {
"path": "tests/files/web/m12_asset_catalog_v1/m12_asset_catalog_v1.blend",
"sha256": "8facfe82e3ca0a0605c139ef5bdcbb82d27a6953a6add4e008b68af90bc1211f"
},
"catalogDefinition": {
"path": "tests/files/web/m12_asset_catalog_v1/blender_assets.cats.txt",
"sha256": "a72f542acb2d2239951a2b20cda0f92305e1d6d8222f84072ad1aab52e3e4c98"
},
"canonicalReport": {
"path": "tests/golden/M12-01B/canonical.json",
"sha256": "6fc6def0b9d0e4f51ef40c1d87a1f17074f6340c126a01aee49e5e53271c961e"
}
},
"coverage": {
"catalogRecords": 3,
"assetRecords": 3,
"assetIdTypes": ["MATERIAL", "OBJECT", "WORLD"],
"metadata": [
"properties",
"catalog_id",
"catalog_simple_name",
"author",
"description",
"copyright",
"license",
"tags",
"active_tag",
"use_preferred_import_method",
"preferred_import_method"
]
},
"nonGoals": [
"Web catalog schema v2",
"Web migration behavior",
"Catalog or asset mutation parity"
],
"nextTask": "M12-01C"
}

View File

@@ -0,0 +1,127 @@
{
"schemaVersion": 2,
"revision": 1,
"catalogs": [
{
"catalogId": "11111111-1111-4111-8111-111111111111",
"path": "Characters",
"simpleName": "Characters",
"parentPath": null
},
{
"catalogId": "22222222-2222-4222-8222-222222222222",
"path": "Characters/Heroes",
"simpleName": "Heroes",
"parentPath": "Characters"
},
{
"catalogId": "33333333-3333-4333-8333-333333333333",
"path": "Materials/Metal",
"simpleName": "Metal",
"parentPath": "Materials"
}
],
"assets": [
{
"assetId": "asset:0ecbd393de19b0cc4fe26956ba658e782aaad4a859b4a9647161f1405ddadfd5",
"assetLibraryIdentifier": null,
"relativeAssetIdentifier": "Material/M12 Brushed Metal",
"idType": "MATERIAL",
"name": "M12 Brushed Metal",
"catalogId": "33333333-3333-4333-8333-333333333333",
"catalogSimpleName": "",
"author": "",
"description": "",
"copyright": "",
"license": "",
"tags": [
"metal"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "APPEND",
"customProperties": [
{
"name": "roughness",
"type": "FLOAT",
"value": 0.35
}
],
"sourceSha256": null,
"sourcePath": null,
"preview": null
},
{
"assetId": "asset:4e9e5a3755581b0f3b604748fc5c64f4f51f14b5c2efd485a7093edeae4dbabd",
"assetLibraryIdentifier": null,
"relativeAssetIdentifier": "Object/M12 Hero",
"idType": "OBJECT",
"name": "M12 Hero",
"catalogId": "22222222-2222-4222-8222-222222222222",
"catalogSimpleName": "",
"author": "M12 Artist",
"description": "Desktop catalog v1 object fixture",
"copyright": "Copyright 2026 M12 Fixture Authors",
"license": "CC0-1.0",
"tags": [
"character",
"hero",
"rig-ready"
],
"activeTag": 1,
"usePreferredImportMethod": true,
"preferredImportMethod": "APPEND",
"customProperties": [
{
"name": "approved",
"type": "BOOL",
"value": true
},
{
"name": "dimensions",
"type": "FLOAT_ARRAY",
"value": [
2,
2,
0
]
},
{
"name": "rating",
"type": "INT",
"value": 5
},
{
"name": "source",
"type": "STRING",
"value": "M12-01B"
}
],
"sourceSha256": null,
"sourcePath": null,
"preview": null
},
{
"assetId": "asset:7ef9ef809269e187e988effca992a2249e6340bd65bba2da2c4b2545c8a7bd8c",
"assetLibraryIdentifier": null,
"relativeAssetIdentifier": "World/M12 Uncataloged World",
"idType": "WORLD",
"name": "M12 Uncataloged World",
"catalogId": null,
"catalogSimpleName": "",
"author": "M12 Artist",
"description": "Asset without a catalog assignment",
"copyright": "",
"license": "CC0-1.0",
"tags": [],
"activeTag": 0,
"usePreferredImportMethod": true,
"preferredImportMethod": "LINK",
"customProperties": [],
"sourceSha256": null,
"sourcePath": null,
"preview": null
}
],
"libraries": []
}

View File

@@ -0,0 +1,45 @@
{
"schemaVersion": 1,
"task": "M12-01C",
"parentTask": "M12-01",
"parityId": "N-023-ASSET-CATALOG-SCHEMA",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"blenderVersion": "5.2.0",
"identityContract": {
"catalog": "CANONICAL_RFC4122_UUID",
"asset": "SHA256_OF_BLENDER_ASSET_WEAK_REFERENCE_V1",
"renameBehavior": "RELATIVE_IDENTIFIER_AND_ASSET_ID_CHANGE_TO_MATCH_BLENDER",
"catalogHierarchy": "CLEANED_SLASH_PATH"
},
"artifacts": {
"protocol": {
"path": "web/protocol/asset-catalog-v2.ts",
"sha256": "0949838efb2f69466368185b80bb1d2e851672d78311fcd5270eb0814576e5d7"
},
"generator": {
"path": "tools/web/generate-asset-catalog-v2.mjs",
"sha256": "b20e7dbd8508ca8ecf17d7da507b45b1225315bac7759c55c7b324683d660881"
},
"desktopCanonical": {
"path": "tests/golden/M12-01B/canonical.json",
"sha256": "6fc6def0b9d0e4f51ef40c1d87a1f17074f6340c126a01aee49e5e53271c961e"
},
"schemaGolden": {
"path": "tests/golden/M12-01C/asset-catalog-v2.json",
"sha256": "2abe279cebbffd1341dc61b1fdc5366dd1f6f42769ba36171782019ca61fb929"
},
"blenderWeakReferenceSource": {
"path": "blender-5.2.0/source/blender/asset_system/intern/asset_representation.cc",
"sha256": "ace3d6468bcdb2f6190431e967495794b7afa35758fd82fc3941b1ce7d374635"
}
},
"nonGoals": [
"Schema v1 to v2 migration",
"Schema v2 legacy-reader policy",
"IndexedDB migration",
"Catalog mutation parity"
],
"nextTask": "M12-01D"
}

View File

@@ -0,0 +1,58 @@
{
"schemaVersion": 1,
"revision": 7,
"catalogs": [
{ "id": "catalog:root", "name": "Characters", "parentId": null },
{ "id": "catalog:heroes", "name": "Heroes", "parentId": "catalog:root" },
{ "id": "44444444-4444-4444-8444-444444444444", "name": "Animation", "parentId": null }
],
"assets": [
{
"id": "legacy:hero",
"name": "Legacy Hero",
"kind": "OBJECT",
"catalogId": "catalog:heroes",
"tags": ["character", "hero"],
"author": "M12 Artist",
"license": "CC0-1.0",
"sourceSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sourcePath": "assets/legacy-hero.blend",
"preview": {
"assetId": "legacy:hero",
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"mimeType": "image/png",
"width": 64,
"height": 64,
"byteLength": 4096
}
},
{
"id": "legacy:walk",
"name": "Legacy Walk",
"kind": "ACTION",
"catalogId": "44444444-4444-4444-8444-444444444444",
"tags": ["walk"],
"author": "M12 Artist",
"license": "CC-BY-4.0",
"sourceSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
}
],
"libraries": [
{
"id": "library:characters",
"name": "Characters",
"sourcePath": "libraries/characters.blend",
"sourceSha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
"dependencyIds": ["library:materials"],
"readOnly": true
},
{
"id": "library:materials",
"name": "Materials",
"sourcePath": "libraries/materials.blend",
"sourceSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"dependencyIds": [],
"readOnly": true
}
]
}

View File

@@ -0,0 +1,99 @@
{
"schemaVersion": 2,
"revision": 7,
"catalogs": [
{
"catalogId": "44444444-4444-4444-8444-444444444444",
"path": "Animation",
"simpleName": "Animation",
"parentPath": null
},
{
"catalogId": "fe3ca14c-95d7-549f-a13b-1bb4c07b6074",
"path": "Characters",
"simpleName": "Characters",
"parentPath": null
},
{
"catalogId": "b4bb3608-9267-5aba-bb9e-9e97b006d5ab",
"path": "Characters/Heroes",
"simpleName": "Heroes",
"parentPath": "Characters"
}
],
"assets": [
{
"assetId": "asset:ac88c6147ada877adccf4df8ca54d64e1a9cb50a13514212f6d19b1da1660f8d",
"assetLibraryIdentifier": null,
"relativeAssetIdentifier": "Action/Legacy Walk",
"idType": "ACTION",
"name": "Legacy Walk",
"catalogId": "44444444-4444-4444-8444-444444444444",
"catalogSimpleName": "Animation",
"author": "M12 Artist",
"description": "",
"copyright": "",
"license": "CC-BY-4.0",
"tags": [
"walk"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "APPEND",
"customProperties": [],
"sourceSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"sourcePath": null,
"preview": null
},
{
"assetId": "asset:bb91ef0dddf69bb7d87dd4217a01e7f549b50ad628db0367d3896c6840b3a326",
"assetLibraryIdentifier": null,
"relativeAssetIdentifier": "Object/Legacy Hero",
"idType": "OBJECT",
"name": "Legacy Hero",
"catalogId": "b4bb3608-9267-5aba-bb9e-9e97b006d5ab",
"catalogSimpleName": "Heroes",
"author": "M12 Artist",
"description": "",
"copyright": "",
"license": "CC0-1.0",
"tags": [
"character",
"hero"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "APPEND",
"customProperties": [],
"sourceSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sourcePath": "assets/legacy-hero.blend",
"preview": {
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"mimeType": "image/png",
"width": 64,
"height": 64,
"byteLength": 4096
}
}
],
"libraries": [
{
"libraryId": "library:characters",
"name": "Characters",
"sourcePath": "libraries/characters.blend",
"sourceSha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
"dependencyIds": [
"library:materials"
],
"readOnly": true
},
{
"libraryId": "library:materials",
"name": "Materials",
"sourcePath": "libraries/materials.blend",
"sourceSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"dependencyIds": [],
"readOnly": true
}
]
}

View File

@@ -0,0 +1,44 @@
{
"schemaVersion": 1,
"task": "M12-01D",
"parentTask": "M12-01",
"parityId": "N-023-ASSET-CATALOG-SCHEMA",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"migration": {
"sourceSchemaVersion": 1,
"targetSchemaVersion": 2,
"catalogIdMapping": "RFC4122_UUIDV5_FIXED_NAMESPACE",
"assetIdMapping": "BLENDER_ASSET_WEAK_REFERENCE_V1_SHA256",
"revisionPolicy": "PRESERVE",
"unknownFieldPolicy": "BLOCK_BEFORE_MIGRATION"
},
"artifacts": {
"migrationProtocol": {
"path": "web/protocol/asset-catalog-migration.ts",
"sha256": "6d74bc5605f94e05208eda82830192d38f88066dfc409058555a8876c785f226"
},
"targetProtocol": {
"path": "web/protocol/asset-catalog-v2.ts",
"sha256": "0949838efb2f69466368185b80bb1d2e851672d78311fcd5270eb0814576e5d7"
},
"generator": {
"path": "tools/web/generate-asset-catalog-migration.mjs",
"sha256": "954d15c02d68e3f644b57c8c4c136ce09bd037a0af2012bf74b97f681c77dd60"
},
"sourceFixture": {
"path": "tests/golden/M12-01D/catalog-v1.json",
"sha256": "1cc43d181afefd63b53cb9c3626bb58195fad20187d6e534adc1ecbe65f75da5"
},
"targetFixture": {
"path": "tests/golden/M12-01D/catalog-v2.json",
"sha256": "38e5d1771b3d2d63ce5e10268eac4513f2043b0cbf71ab1799c1868bc2aa97fb"
},
"migrationReport": {
"path": "tests/golden/M12-01D/migration-report.json",
"sha256": "57b972c1bbb9ae326c3f264c225c21546682442ff3194fbc343e1c46a2cd410a"
}
},
"nextTask": "M12-01E"
}

View File

@@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"task": "M12-01D",
"status": "MIGRATED",
"sourceSchemaVersion": 1,
"targetSchemaVersion": 2,
"sourceRevision": 7,
"targetRevision": 7,
"sourceManifestSha256": "cbdc8b03dc9ce1f38b44bae1c7459eb273126cbd6c7dd33115d2b1966e975881",
"targetManifestSha256": "0c08ed1af1dd0998c809f76d969fd21d8fdae28ad37cf5571bdc99b16bf3ec90",
"catalogMappings": [
{
"legacyId": "44444444-4444-4444-8444-444444444444",
"catalogId": "44444444-4444-4444-8444-444444444444",
"path": "Animation"
},
{
"legacyId": "catalog:heroes",
"catalogId": "b4bb3608-9267-5aba-bb9e-9e97b006d5ab",
"path": "Characters/Heroes"
},
{
"legacyId": "catalog:root",
"catalogId": "fe3ca14c-95d7-549f-a13b-1bb4c07b6074",
"path": "Characters"
}
],
"assetMappings": [
{
"legacyId": "legacy:hero",
"assetId": "asset:bb91ef0dddf69bb7d87dd4217a01e7f549b50ad628db0367d3896c6840b3a326",
"relativeAssetIdentifier": "Object/Legacy Hero"
},
{
"legacyId": "legacy:walk",
"assetId": "asset:ac88c6147ada877adccf4df8ca54d64e1a9cb50a13514212f6d19b1da1660f8d",
"relativeAssetIdentifier": "Action/Legacy Walk"
}
],
"defaultsApplied": {
"dynamicMetadataFields": 4,
"preferredImportMethods": 2,
"customPropertyCollections": 2
},
"preserved": {
"catalogs": 3,
"assets": 2,
"libraries": 2,
"previews": 1,
"sourceBindings": 2
},
"nextTask": "M12-01E"
}

View File

@@ -0,0 +1,255 @@
{
"read": {
"schemaVersion": 1,
"task": "M12-01E",
"readerSchemaVersion": 1,
"documentSchemaVersion": 2,
"operation": "READ",
"status": "READ_ONLY",
"code": "ASSET_SCHEMA_DOWNGRADE_BLOCKED",
"recoverable": true,
"sourceSha256": "0c08ed1af1dd0998c809f76d969fd21d8fdae28ad37cf5571bdc99b16bf3ec90",
"snapshot": {
"revision": 7,
"catalogs": [
{
"catalogId": "44444444-4444-4444-8444-444444444444",
"path": "Animation",
"simpleName": "Animation"
},
{
"catalogId": "fe3ca14c-95d7-549f-a13b-1bb4c07b6074",
"path": "Characters",
"simpleName": "Characters"
},
{
"catalogId": "b4bb3608-9267-5aba-bb9e-9e97b006d5ab",
"path": "Characters/Heroes",
"simpleName": "Heroes"
}
],
"assets": [
{
"assetId": "asset:ac88c6147ada877adccf4df8ca54d64e1a9cb50a13514212f6d19b1da1660f8d",
"relativeAssetIdentifier": "Action/Legacy Walk",
"idType": "ACTION",
"name": "Legacy Walk",
"catalogId": "44444444-4444-4444-8444-444444444444"
},
{
"assetId": "asset:bb91ef0dddf69bb7d87dd4217a01e7f549b50ad628db0367d3896c6840b3a326",
"relativeAssetIdentifier": "Object/Legacy Hero",
"idType": "OBJECT",
"name": "Legacy Hero",
"catalogId": "b4bb3608-9267-5aba-bb9e-9e97b006d5ab"
}
],
"libraries": [
{
"libraryId": "library:characters",
"name": "Characters",
"readOnly": true
},
{
"libraryId": "library:materials",
"name": "Materials",
"readOnly": true
}
]
},
"nextTask": "M12-01F"
},
"catalogWrite": {
"schemaVersion": 1,
"task": "M12-01E",
"readerSchemaVersion": 1,
"documentSchemaVersion": 2,
"operation": "CATALOG_WRITE",
"status": "BLOCKED",
"code": "ASSET_SCHEMA_DOWNGRADE_BLOCKED",
"recoverable": true,
"sourceSha256": "0c08ed1af1dd0998c809f76d969fd21d8fdae28ad37cf5571bdc99b16bf3ec90",
"snapshot": {
"revision": 7,
"catalogs": [
{
"catalogId": "44444444-4444-4444-8444-444444444444",
"path": "Animation",
"simpleName": "Animation"
},
{
"catalogId": "fe3ca14c-95d7-549f-a13b-1bb4c07b6074",
"path": "Characters",
"simpleName": "Characters"
},
{
"catalogId": "b4bb3608-9267-5aba-bb9e-9e97b006d5ab",
"path": "Characters/Heroes",
"simpleName": "Heroes"
}
],
"assets": [
{
"assetId": "asset:ac88c6147ada877adccf4df8ca54d64e1a9cb50a13514212f6d19b1da1660f8d",
"relativeAssetIdentifier": "Action/Legacy Walk",
"idType": "ACTION",
"name": "Legacy Walk",
"catalogId": "44444444-4444-4444-8444-444444444444"
},
{
"assetId": "asset:bb91ef0dddf69bb7d87dd4217a01e7f549b50ad628db0367d3896c6840b3a326",
"relativeAssetIdentifier": "Object/Legacy Hero",
"idType": "OBJECT",
"name": "Legacy Hero",
"catalogId": "b4bb3608-9267-5aba-bb9e-9e97b006d5ab"
}
],
"libraries": [
{
"libraryId": "library:characters",
"name": "Characters",
"readOnly": true
},
{
"libraryId": "library:materials",
"name": "Materials",
"readOnly": true
}
]
},
"nextTask": "M12-01F"
},
"assetWrite": {
"schemaVersion": 1,
"task": "M12-01E",
"readerSchemaVersion": 1,
"documentSchemaVersion": 2,
"operation": "ASSET_WRITE",
"status": "BLOCKED",
"code": "ASSET_SCHEMA_DOWNGRADE_BLOCKED",
"recoverable": true,
"sourceSha256": "0c08ed1af1dd0998c809f76d969fd21d8fdae28ad37cf5571bdc99b16bf3ec90",
"snapshot": {
"revision": 7,
"catalogs": [
{
"catalogId": "44444444-4444-4444-8444-444444444444",
"path": "Animation",
"simpleName": "Animation"
},
{
"catalogId": "fe3ca14c-95d7-549f-a13b-1bb4c07b6074",
"path": "Characters",
"simpleName": "Characters"
},
{
"catalogId": "b4bb3608-9267-5aba-bb9e-9e97b006d5ab",
"path": "Characters/Heroes",
"simpleName": "Heroes"
}
],
"assets": [
{
"assetId": "asset:ac88c6147ada877adccf4df8ca54d64e1a9cb50a13514212f6d19b1da1660f8d",
"relativeAssetIdentifier": "Action/Legacy Walk",
"idType": "ACTION",
"name": "Legacy Walk",
"catalogId": "44444444-4444-4444-8444-444444444444"
},
{
"assetId": "asset:bb91ef0dddf69bb7d87dd4217a01e7f549b50ad628db0367d3896c6840b3a326",
"relativeAssetIdentifier": "Object/Legacy Hero",
"idType": "OBJECT",
"name": "Legacy Hero",
"catalogId": "b4bb3608-9267-5aba-bb9e-9e97b006d5ab"
}
],
"libraries": [
{
"libraryId": "library:characters",
"name": "Characters",
"readOnly": true
},
{
"libraryId": "library:materials",
"name": "Materials",
"readOnly": true
}
]
},
"nextTask": "M12-01F"
},
"save": {
"schemaVersion": 1,
"task": "M12-01E",
"readerSchemaVersion": 1,
"documentSchemaVersion": 2,
"operation": "SAVE",
"status": "BLOCKED",
"code": "ASSET_SCHEMA_DOWNGRADE_BLOCKED",
"recoverable": true,
"sourceSha256": "0c08ed1af1dd0998c809f76d969fd21d8fdae28ad37cf5571bdc99b16bf3ec90",
"snapshot": {
"revision": 7,
"catalogs": [
{
"catalogId": "44444444-4444-4444-8444-444444444444",
"path": "Animation",
"simpleName": "Animation"
},
{
"catalogId": "fe3ca14c-95d7-549f-a13b-1bb4c07b6074",
"path": "Characters",
"simpleName": "Characters"
},
{
"catalogId": "b4bb3608-9267-5aba-bb9e-9e97b006d5ab",
"path": "Characters/Heroes",
"simpleName": "Heroes"
}
],
"assets": [
{
"assetId": "asset:ac88c6147ada877adccf4df8ca54d64e1a9cb50a13514212f6d19b1da1660f8d",
"relativeAssetIdentifier": "Action/Legacy Walk",
"idType": "ACTION",
"name": "Legacy Walk",
"catalogId": "44444444-4444-4444-8444-444444444444"
},
{
"assetId": "asset:bb91ef0dddf69bb7d87dd4217a01e7f549b50ad628db0367d3896c6840b3a326",
"relativeAssetIdentifier": "Object/Legacy Hero",
"idType": "OBJECT",
"name": "Legacy Hero",
"catalogId": "b4bb3608-9267-5aba-bb9e-9e97b006d5ab"
}
],
"libraries": [
{
"libraryId": "library:characters",
"name": "Characters",
"readOnly": true
},
{
"libraryId": "library:materials",
"name": "Materials",
"readOnly": true
}
]
},
"nextTask": "M12-01F"
},
"future": {
"schemaVersion": 1,
"task": "M12-01E",
"readerSchemaVersion": 1,
"documentSchemaVersion": 3,
"operation": "READ",
"status": "BLOCKED",
"code": "PROTOCOL_MISMATCH",
"recoverable": false,
"sourceSha256": "d6b7646f6ede911358f954a0ccae376703f1c0e8854a9953a4a77a968b600b80",
"snapshot": null,
"nextTask": "M12-01F"
}
}

View File

@@ -0,0 +1,39 @@
{
"schemaVersion": 1,
"task": "M12-01E",
"parentTask": "M12-01",
"parityId": "N-023-ASSET-CATALOG-SCHEMA",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"policy": {
"nativeV1": "READY",
"validV2Read": "READ_ONLY",
"validV2Write": "ASSET_SCHEMA_DOWNGRADE_BLOCKED",
"futureSchema": "PROTOCOL_MISMATCH",
"sourceMutation": "FORBIDDEN"
},
"artifacts": {
"protocol": {
"path": "web/protocol/asset-catalog-compatibility.ts",
"sha256": "5d51029b232e03f08fc0778d4933910b2a54818b4d653fbcc185c8d764641349"
},
"errorContract": {
"path": "web/protocol/error.ts",
"sha256": "309ab84d5c755a69466ddb73e4b54e87caf62cbcac0f10f34d904e9f6f4a5f34"
},
"generator": {
"path": "tools/web/generate-asset-catalog-compatibility.mjs",
"sha256": "208f568db092e1712095d77ab28db92d5a1eb11fe62813b237ce466f9268fd4e"
},
"v2Fixture": {
"path": "tests/golden/M12-01D/catalog-v2.json",
"sha256": "38e5d1771b3d2d63ce5e10268eac4513f2043b0cbf71ab1799c1868bc2aa97fb"
},
"compatibilityReport": {
"path": "tests/golden/M12-01E/compatibility-report.json",
"sha256": "3c2f9241b56ad5bd742dc4152dec703bdc56eef4e6d8d60e6edf163c4571ad54"
}
},
"nextTask": "M12-01F"
}

View File

@@ -0,0 +1,33 @@
{
"schemaVersion": 1,
"task": "M12-01F",
"parentTask": "M12-01",
"parityId": "N-023-ASSET-CATALOG-SCHEMA",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"negativeCaseCount": 9,
"artifacts": {
"schemaProtocol": {
"path": "web/protocol/asset-catalog-v2.ts",
"sha256": "0949838efb2f69466368185b80bb1d2e851672d78311fcd5270eb0814576e5d7"
},
"migrationProtocol": {
"path": "web/protocol/asset-catalog-migration.ts",
"sha256": "6d74bc5605f94e05208eda82830192d38f88066dfc409058555a8876c785f226"
},
"v1Fixture": {
"path": "tests/golden/M12-01D/catalog-v1.json",
"sha256": "1cc43d181afefd63b53cb9c3626bb58195fad20187d6e534adc1ecbe65f75da5"
},
"v2Fixture": {
"path": "tests/golden/M12-01D/catalog-v2.json",
"sha256": "38e5d1771b3d2d63ce5e10268eac4513f2043b0cbf71ab1799c1868bc2aa97fb"
},
"negativeCases": {
"path": "tests/golden/M12-01F/negative-cases.json",
"sha256": "74672fa0aec248fd5d9a265743ef4dfe4d417fce8a24622b8b3d6be2f95c8874"
}
},
"nextTask": "M12-01G"
}

View File

@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"task": "M12-01F",
"cases": [
{ "id": "DUPLICATE_CATALOG_ID", "code": "ASSET_MANIFEST_INVALID" },
{ "id": "DUPLICATE_ASSET_ID", "code": "ASSET_MANIFEST_INVALID" },
{ "id": "LEGACY_PARENT_CYCLE", "code": "ASSET_MANIFEST_INVALID" },
{ "id": "PARENT_PATH_MISMATCH", "code": "ASSET_MANIFEST_INVALID" },
{ "id": "OVERLONG_SIMPLE_NAME_UTF8", "code": "ASSET_BUDGET_EXCEEDED" },
{ "id": "OVERLONG_TAG_UTF8", "code": "ASSET_BUDGET_EXCEEDED" },
{ "id": "V2_UNKNOWN_TOP_LEVEL", "code": "ASSET_MANIFEST_INVALID" },
{ "id": "V2_UNKNOWN_ASSET_FIELD", "code": "ASSET_MANIFEST_INVALID" },
{ "id": "V1_UNKNOWN_ASSET_FIELD", "code": "ASSET_MANIFEST_INVALID" }
],
"recovery": "VALID_V2_AFTER_EACH_NEGATIVE",
"nextTask": "M12-01G"
}

View File

@@ -0,0 +1,40 @@
{
"schemaVersion": 1,
"task": "M12-01G",
"parentTask": "M12-01",
"parityId": "N-023-ASSET-CATALOG-SCHEMA",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"transaction": {
"sourceRow": "asset-catalog:index:v1",
"targetRow": "asset-catalog:index:v2",
"receiptRow": "asset-catalog:migration:v1-to-v2",
"faultPoints": ["AFTER_TARGET_PUT", "AFTER_SOURCE_DELETE"],
"failureCode": "STORAGE_TRANSACTION",
"rollbackPolicy": "PRESERVE_SOURCE_TARGET_RECEIPT_AND_UNRELATED_ROWS"
},
"artifacts": {
"indexedDBMigration": {
"path": "web/app/src/storage/asset-catalog-indexeddb.ts",
"sha256": "283a08bccb5ea301bd2aef8f01d4496972e3962f75e340479336c50e3539ba57"
},
"browserSuite": {
"path": "web/tests/e2e/asset-catalog-indexeddb-migration.spec.ts",
"sha256": "82c40f38898277b4053a9654537ec9c21bc01a784d1091831e139361575a81e9"
},
"sourceFixture": {
"path": "tests/golden/M12-01D/catalog-v1.json",
"sha256": "1cc43d181afefd63b53cb9c3626bb58195fad20187d6e534adc1ecbe65f75da5"
},
"targetFixture": {
"path": "tests/golden/M12-01D/catalog-v2.json",
"sha256": "38e5d1771b3d2d63ce5e10268eac4513f2043b0cbf71ab1799c1868bc2aa97fb"
},
"migrationReport": {
"path": "tests/golden/M12-01D/migration-report.json",
"sha256": "57b972c1bbb9ae326c3f264c225c21546682442ff3194fbc343e1c46a2cd410a"
}
},
"nextTask": "M12-01H"
}

View File

@@ -0,0 +1,43 @@
{
"schemaVersion": 1,
"task": "M12-01H",
"parentTask": "M12-01",
"parityId": "N-023-ASSET-CATALOG-SCHEMA",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"restart": {
"contexts": ["INITIAL_PAGE", "RELOADED_PAGE", "WORKER_GENERATION_1", "WORKER_GENERATION_2"],
"catalogCount": 3,
"assetCount": 2,
"manifestSha256": "0c08ed1af1dd0998c809f76d969fd21d8fdae28ad37cf5571bdc99b16bf3ec90",
"catalogOrder": [
"44444444-4444-4444-8444-444444444444",
"fe3ca14c-95d7-549f-a13b-1bb4c07b6074",
"b4bb3608-9267-5aba-bb9e-9e97b006d5ab"
],
"assetOrder": [
"asset:ac88c6147ada877adccf4df8ca54d64e1a9cb50a13514212f6d19b1da1660f8d",
"asset:bb91ef0dddf69bb7d87dd4217a01e7f549b50ad628db0367d3896c6840b3a326"
]
},
"artifacts": {
"indexedDBReader": {
"path": "web/app/src/storage/asset-catalog-indexeddb.ts",
"sha256": "283a08bccb5ea301bd2aef8f01d4496972e3962f75e340479336c50e3539ba57"
},
"restartWorker": {
"path": "web/app/src/workers/asset-catalog-restart-test.worker.ts",
"sha256": "e6007d352555be046b89d0becd719692652ee0418cc97087bf7599155f2a538e"
},
"browserSuite": {
"path": "web/tests/e2e/asset-catalog-restart.spec.ts",
"sha256": "7945ff38dd474a74c265594b66534a280eb69407509137e60d41f1bea3b3c19b"
},
"targetFixture": {
"path": "tests/golden/M12-01D/catalog-v2.json",
"sha256": "38e5d1771b3d2d63ce5e10268eac4513f2043b0cbf71ab1799c1868bc2aa97fb"
}
},
"nextTask": "M12-01I"
}

View File

@@ -0,0 +1,98 @@
{
"schemaVersion": 1,
"task": "M12-02A",
"parentTask": "M12-02",
"parityId": "N-023-ASSET-PREVIEW",
"enablingTask": true,
"parityStateChange": false,
"blenderVersion": "5.2.0",
"sources": [
{ "role": "PREVIEW_DNA", "path": "blender-5.2.0/source/blender/makesdna/DNA_ID.h", "sha256": "27d0eac02df737f2d2298eeef45e66eae0d76da66542ccdf32400b3e9bd904a9" },
{ "role": "PREVIEW_SLOT_ENUM", "path": "blender-5.2.0/source/blender/makesdna/DNA_ID_enums.h", "sha256": "9b97042ca408924c2efa92b49bc44a4f2f949b11768fb89b2c1b71c9c796cd61" },
{ "role": "PREVIEW_API", "path": "blender-5.2.0/source/blender/blenkernel/BKE_preview_image.hh", "sha256": "25656ad3ef44bb6d00b6e6f9748d26fd04a358d7bb9f876da4194c05d1e5a9a1" },
{ "role": "PREVIEW_IMPLEMENTATION", "path": "blender-5.2.0/source/blender/blenkernel/intern/preview_image.cc", "sha256": "886ae47b7f4950b7b3dc558ac1973c0b114a265d837aa130e4479dd8b438db86" },
{ "role": "PREVIEW_RNA", "path": "blender-5.2.0/source/blender/makesrna/intern/rna_ID.cc", "sha256": "d97b3346a3d36e5198989087cbb6a24f43d9d6d535a6f795392a47f77ccdfc2c" },
{ "role": "ASSET_PREVIEW_GENERATOR", "path": "blender-5.2.0/source/blender/editors/asset/intern/asset_ops.cc", "sha256": "94ae9ce907d1060b3f4dc7536ddd66590efb8e1680bf7d3dc72ce1ce53523732" },
{ "role": "PREVIEW_BLEND_READER", "path": "blender-5.2.0/source/blender/blenloader/intern/readblenentry.cc", "sha256": "dd10bdbdd5f42a09722411e8b99ce34749186d256e91413b7cb5bdef422b8cde" },
{ "role": "PREVIEW_SIZE_CONSTANTS", "path": "blender-5.2.0/source/blender/imbuf/IMB_thumbs.hh", "sha256": "c7d5ddf7c3fbbdd731eac7f0761cada3e8e0df024d8d2c7b48d777e517761be1" },
{ "role": "ICON_SIZE_CONSTANTS", "path": "blender-5.2.0/source/blender/blenkernel/BKE_icons.hh", "sha256": "c16c52503a2fe2e5df9c2925b04d6ce64468cf2c5d98fd85e0e47423cfc9d2aa" },
{ "role": "CURRENT_WEB_V1", "path": "web/protocol/asset-library-io.ts", "sha256": "5c62539db3833a2113007dfbdfd12ca06704d86e0cf0e57ad97f7fbf6acda7f4" },
{ "role": "CURRENT_WEB_V2", "path": "web/protocol/asset-catalog-v2.ts", "sha256": "0949838efb2f69466368185b80bb1d2e851672d78311fcd5270eb0814576e5d7" }
],
"fixtures": [
{ "role": "LOADED_RGBA8_PNG", "path": "tests/files/web/media/sequencer-frame.png", "sha256": "295b083db2299ab904947eb39c17173de70c211882b0d855537d8f3cc27698ed" },
{ "role": "ASSET_NO_PREVIEW_BLEND", "path": "tests/files/web/m12_asset_catalog_v1/m12_asset_catalog_v1.blend", "sha256": "8facfe82e3ca0a0605c139ef5bdcbb82d27a6953a6add4e008b68af90bc1211f" }
],
"storage": {
"slotCount": 2,
"slots": [
{ "index": 0, "name": "ICON", "defaultRenderMaxHeight": 32 },
{ "index": 1, "name": "PREVIEW", "defaultRenderMaxHeight": 128, "customAssetMaxHeight": 256 }
],
"fields": [
{ "name": "w", "cType": "unsigned int[2]", "persistence": "BLEND", "semantic": "PIXEL_WIDTH" },
{ "name": "h", "cType": "unsigned int[2]", "persistence": "BLEND", "semantic": "PIXEL_HEIGHT" },
{ "name": "flag", "cType": "short[2]", "persistence": "BLEND_WITH_RUNTIME_BITS_CLEARED", "semantic": "CHANGED_USER_EDITED_RENDERING" },
{ "name": "changed_timestamp", "cType": "short[2]", "persistence": "BLEND", "semantic": "CHANGE_COUNTER" },
{ "name": "rect", "cType": "unsigned int*[2]", "persistence": "BLEND_ARRAY", "semantic": "ONE_PACKED_RGBA32_VALUE_PER_PIXEL" },
{ "name": "runtime", "cType": "PreviewImageRuntime*", "persistence": "RUNTIME_ONLY", "semantic": "GPU_DEFERRED_LOADING_AND_USER_COUNT" }
],
"pixelFormat": "RGBA8_PACKED_UINT32",
"byteComponents": 4,
"alphaMode": "PREMULTIPLIED",
"colorSpace": null,
"colorSpaceFinding": "NO_COLOR_SPACE_FIELD_OR_RNA_PROPERTY; BYTE_BUFFER_INTERPRETATION_DEPENDS_ON_PRODUCER_PIPELINE"
},
"rna": {
"idProperty": { "identifier": "preview", "type": "POINTER", "readOnly": true, "nullWhenAbsent": true },
"imagePreviewProperties": [
{ "identifier": "is_image_custom", "type": "BOOLEAN", "readOnly": false, "arrayLength": 0 },
{ "identifier": "image_size", "type": "INT", "readOnly": false, "arrayLength": 2 },
{ "identifier": "image_pixels", "type": "INT", "readOnly": false, "arrayLength": 0 },
{ "identifier": "image_pixels_float", "type": "FLOAT", "readOnly": false, "arrayLength": 0 },
{ "identifier": "is_icon_custom", "type": "BOOLEAN", "readOnly": false, "arrayLength": 0 },
{ "identifier": "icon_size", "type": "INT", "readOnly": false, "arrayLength": 2 },
{ "identifier": "icon_pixels", "type": "INT", "readOnly": false, "arrayLength": 0 },
{ "identifier": "icon_pixels_float", "type": "FLOAT", "readOnly": false, "arrayLength": 0 },
{ "identifier": "icon_id", "type": "INT", "readOnly": true, "arrayLength": 0 }
],
"lengthRules": {
"packedPixels": "width*height",
"floatComponents": "width*height*4",
"floatConversion": "BYTE_COMPONENT_DIVIDED_BY_255_WITHOUT_COLOR_TRANSFORM"
}
},
"runtimeStates": {
"noPreview": {
"state": "ABSENT_POINTER",
"assetCount": 3,
"assetTypes": ["Object", "Material", "World"],
"previewValues": [null, null, null]
},
"loadedPreview": {
"sourceSize": [8, 8],
"imageSize": [8, 8],
"iconSize": [32, 32],
"imagePackedPixelCount": 64,
"imageFloatComponentCount": 256,
"iconPackedPixelCount": 1024,
"imageCustom": false,
"iconCustom": false
},
"emptyBufferRule": "NULL_RECT_OR_ZERO_WIDTH_OR_ZERO_HEIGHT_IS_NO_IMAGE_FOR_THAT_SLOT",
"invalidDeferredRule": "RUNTIME_TAG_ONLY_NOT_A_PERSISTED_COLOR_OR_CONTENT_IDENTITY"
},
"currentWeb": {
"v1Fields": ["assetId", "sha256", "mimeType", "width", "height", "byteLength"],
"v2Fields": ["sha256", "mimeType", "width", "height", "byteLength"],
"noPreviewRepresentation": { "v1": "OPTIONAL_PROPERTY_ABSENT", "v2": "NULL" },
"missingSemantics": ["slot", "colorSpace", "alphaMode", "sourceSha256", "generatorIdentity", "deferredOrInvalidState"]
},
"nonGoals": [
"Preview content identity schema",
"Decode budgets",
"OPFS commit ordering",
"Browser display parity"
],
"nextTask": "M12-02B"
}

View File

@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"assetId": "asset:4e9e5a3755581b0f3b604748fc5c64f4f51f14b5c2efd485a7093edeae4dbabd",
"slot": "PREVIEW",
"source": {
"mimeType": "image/png",
"byteLength": 261,
"sha256": "295b083db2299ab904947eb39c17173de70c211882b0d855537d8f3cc27698ed"
},
"content": {
"mimeType": "image/png",
"byteLength": 513,
"sha256": "ff139c4a1c5d388c78c5168dfd3272e80ea0d1b15b3743e29a38cd908e8c70e7",
"width": 8,
"height": 8,
"pixelFormat": "RGBA8",
"colorSpace": "SRGB",
"alphaMode": "STRAIGHT"
},
"generator": {
"name": "BLENDER_ASSET_PREVIEW_IDENTITY",
"version": "5.2.0",
"executableSha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82",
"scriptSha256": "753a26be90f7d97828737ebc3a4ab88275a7c655b4f9992406c7c35fba6535ce",
"settingsSha256": "07ebc55d4b6cbb293badf0640874846912df3d23549997cf6f87f4c325280ca0"
},
"identitySha256": "3139b1df11615fd8c7caaf920a0ba2c93e8921d999374876a2c65d889d256e5f"
}

View File

@@ -0,0 +1,31 @@
{
"schemaVersion": 1,
"task": "M12-02B",
"parentTask": "M12-02",
"parityId": "N-023-ASSET-PREVIEW",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"generatorSettings": {
"schemaVersion": 1,
"format": "PNG",
"colorMode": "RGBA",
"colorDepth": 8,
"compression": 0,
"displayDevice": "sRGB",
"viewTransform": "Standard",
"look": "None",
"exposure": 0,
"gamma": 1
},
"generatorSettingsSha256": "07ebc55d4b6cbb293badf0640874846912df3d23549997cf6f87f4c325280ca0",
"artifacts": {
"protocol": { "path": "web/protocol/asset-preview.ts", "sha256": "2a1d877af42424014097c669e9d44c21b27e3171be185320554693cbefcd7438" },
"errorContract": { "path": "web/protocol/error.ts", "sha256": "6aec7b8a9d6903d83ae67718db3ae775d709cabb207cf2a02968d4ff7c5f26c0" },
"generator": { "path": "tools/web/generate-asset-preview-identity.py", "sha256": "753a26be90f7d97828737ebc3a4ab88275a7c655b4f9992406c7c35fba6535ce" },
"blender": { "path": "build_blender_5.2.0/bin/blender", "sha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82" },
"source": { "path": "tests/files/web/media/sequencer-frame.png", "sha256": "295b083db2299ab904947eb39c17173de70c211882b0d855537d8f3cc27698ed" },
"content": { "path": "tests/golden/M12-02B/preview.png", "sha256": "ff139c4a1c5d388c78c5168dfd3272e80ea0d1b15b3743e29a38cd908e8c70e7" }
},
"nextTask": "M12-02C"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 B

View File

@@ -0,0 +1,34 @@
{
"schemaVersion": 1,
"task": "M12-02C",
"parentTask": "M12-02",
"parityId": "N-023-ASSET-PREVIEW",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"budget": {
"maxEncodedBytes": 16777216,
"maxWidth": 4096,
"maxHeight": 4096,
"maxPixels": 16777216,
"maxDecodedBytes": 67108864,
"maxCompressionRatio": 100
},
"preDecodeOrder": [
"IDENTITY_PARSE",
"ENCODED_BYTE_BUDGET",
"CONTENT_SHA256",
"MIME_AND_HEADER",
"DIMENSION_BINDING",
"PIXEL_AND_DECODED_BYTE_BUDGET",
"COMPRESSION_RATIO"
],
"negativeCaseCount": 8,
"artifacts": {
"decodeProtocol": { "path": "web/protocol/asset-preview-decode.ts", "sha256": "3d1c491af2503a7a6012541ed43400d680f8e666cc12a54db284aeb6ac18ff63" },
"identityProtocol": { "path": "web/protocol/asset-preview.ts", "sha256": "2a1d877af42424014097c669e9d44c21b27e3171be185320554693cbefcd7438" },
"identityGolden": { "path": "tests/golden/M12-02B/identity.json", "sha256": "1b1eb8416ce4aa4d83b0dc42b9cb375f1da965e3f0cb9650991289fc619a0a7b" },
"contentPNG": { "path": "tests/golden/M12-02B/preview.png", "sha256": "ff139c4a1c5d388c78c5168dfd3272e80ea0d1b15b3743e29a38cd908e8c70e7" }
},
"nextTask": "M12-02D"
}

View File

@@ -0,0 +1,48 @@
{
"schemaVersion": 1,
"task": "M12-02D",
"parentTask": "M12-02",
"parityId": "N-023-ASSET-PREVIEW",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"commitOrder": [
"PRE_DECODE_GATE",
"OPFS_WRITE",
"OPFS_READBACK",
"CATALOG_TRANSACTION"
],
"faultPoints": [
"BEFORE_OPFS_WRITE",
"AFTER_OPFS_WRITE",
"AFTER_CATALOG_PUT"
],
"failureCode": "STORAGE_TRANSACTION",
"artifacts": {
"commitCoordinator": {
"path": "web/app/src/storage/asset-preview-opfs-commit.ts",
"sha256": "36418e480b108eb885b5d8da6633174ed1face1fa62742a64372a45280e727a2"
},
"browserSuite": {
"path": "web/tests/e2e/asset-preview-opfs-commit.spec.ts",
"sha256": "043b9b06d4c0275938cc1d4f9837df95f58f281130f3c0f2b90c10540244592e"
},
"unitSuite": {
"path": "web/tests/unit/asset-preview-opfs-commit.test.mjs",
"sha256": "f489a1d9a4c5fb24a99bce6d31841534c7cf3981b53076a0d9e19d106e9d0d74"
},
"decodeProtocol": {
"path": "web/protocol/asset-preview-decode.ts",
"sha256": "3d1c491af2503a7a6012541ed43400d680f8e666cc12a54db284aeb6ac18ff63"
},
"identityGolden": {
"path": "tests/golden/M12-02B/identity.json",
"sha256": "1b1eb8416ce4aa4d83b0dc42b9cb375f1da965e3f0cb9650991289fc619a0a7b"
},
"contentPNG": {
"path": "tests/golden/M12-02B/preview.png",
"sha256": "ff139c4a1c5d388c78c5168dfd3272e80ea0d1b15b3743e29a38cd908e8c70e7"
}
},
"nextTask": "M12-02E"
}

View File

@@ -0,0 +1,41 @@
{
"schemaVersion": 1,
"task": "M12-02E",
"parentTask": "M12-02",
"parityId": "N-023-ASSET-PREVIEW",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"claims": [
"ONE_CONTENT_HASH_ONE_OPFS_PAYLOAD",
"DISTINCT_ASSET_IDENTITY_PRESERVED",
"DISTINCT_ASSET_METADATA_PRESERVED",
"REOPENED_HEAD_VERIFIED"
],
"assetCount": 2,
"physicalPayloadCount": 1,
"contentSha256": "ff139c4a1c5d388c78c5168dfd3272e80ea0d1b15b3743e29a38cd908e8c70e7",
"artifacts": {
"commitCoordinator": {
"path": "web/app/src/storage/asset-preview-opfs-commit.ts",
"sha256": "36418e480b108eb885b5d8da6633174ed1face1fa62742a64372a45280e727a2"
},
"browserSuite": {
"path": "web/tests/e2e/asset-preview-dedup.spec.ts",
"sha256": "4e53b7a35eb109b152bd6dc0c3348dcb08879d17619968d37fc761c67aa38b40"
},
"unitSuite": {
"path": "web/tests/unit/asset-preview-dedup.test.mjs",
"sha256": "e023fdd33e0f40c1331dfdd73cd287dc684256b9d134971ab17db8cfe2a83835"
},
"catalogFixture": {
"path": "tests/golden/M12-01D/catalog-v2.json",
"sha256": "38e5d1771b3d2d63ce5e10268eac4513f2043b0cbf71ab1799c1868bc2aa97fb"
},
"contentPNG": {
"path": "tests/golden/M12-02B/preview.png",
"sha256": "ff139c4a1c5d388c78c5168dfd3272e80ea0d1b15b3743e29a38cd908e8c70e7"
}
},
"nextTask": "M12-02F"
}

View File

@@ -0,0 +1,35 @@
{
"schemaVersion": 1,
"task": "M12-02F",
"parentTask": "M12-02",
"parityId": "N-023-ASSET-PREVIEW",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"states": ["READY", "QUARANTINED", "REOPENED_QUARANTINED"],
"failureCode": "ASSET_SOURCE_HASH_MISMATCH",
"catalogMutationOnCorruption": false,
"artifacts": {
"quarantineProtocol": {
"path": "web/app/src/storage/asset-preview-quarantine.ts",
"sha256": "a5f3330dbd16d9f578d85d9504c980fffea85b834b77340a307ce21ce9b1cb04"
},
"browserSuite": {
"path": "web/tests/e2e/asset-preview-quarantine.spec.ts",
"sha256": "d8e1d0741765e8f8bfd47b45cf03213d8629efdbbf77dfd33846785a3ec80c27"
},
"unitSuite": {
"path": "web/tests/unit/asset-preview-quarantine.test.mjs",
"sha256": "79c836c7a3be4e9f305a962775444c766577cca87fa6d1698811e75eb21f2d9a"
},
"commitCoordinator": {
"path": "web/app/src/storage/asset-preview-opfs-commit.ts",
"sha256": "36418e480b108eb885b5d8da6633174ed1face1fa62742a64372a45280e727a2"
},
"contentPNG": {
"path": "tests/golden/M12-02B/preview.png",
"sha256": "ff139c4a1c5d388c78c5168dfd3272e80ea0d1b15b3743e29a38cd908e8c70e7"
}
},
"nextTask": "M12-02G"
}

View File

@@ -0,0 +1,39 @@
{
"schemaVersion": 1,
"task": "M12-02G",
"parentTask": "M12-02",
"parityId": "N-023-ASSET-PREVIEW",
"enablingTask": true,
"parityStateChange": false,
"implementationClass": "NOT_APPLICABLE",
"sequence": [
"REMOVE_FIRST_REFERENCE",
"RETAIN_PAYLOAD",
"REMOVE_FINAL_REFERENCE",
"COLLECT_PAYLOAD"
],
"otherProjectPayload": "PRESERVED_READY",
"artifacts": {
"referenceGC": {
"path": "web/app/src/storage/asset-preview-reference-gc.ts",
"sha256": "76b9d8ccd3e7992356f33adc694001f9b0ff364788234389eacdb09198d1415e"
},
"browserSuite": {
"path": "web/tests/e2e/asset-preview-reference-gc.spec.ts",
"sha256": "2e9e9b47883997092c5afb0fb7859a484754764de19006e24a8ff2bdeb24f35a"
},
"unitSuite": {
"path": "web/tests/unit/asset-preview-reference-gc.test.mjs",
"sha256": "c5de3122b5d8aab17daf0179cb4f5bb0b5d299408818849cbe7b18e40a47706e"
},
"opfsStorage": {
"path": "web/app/src/storage/opfs-files.ts",
"sha256": "2e8ef9d72194bf803a7e823fb2b7de30b9f10ae6dd4b031b81b97ff82eee9cc6"
},
"contentPNG": {
"path": "tests/golden/M12-02B/preview.png",
"sha256": "ff139c4a1c5d388c78c5168dfd3272e80ea0d1b15b3743e29a38cd908e8c70e7"
}
},
"nextTask": "M12-02H"
}

View File

@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"task": "M12-02H",
"generator": "BLENDER_ASSET_PREVIEW_IDENTITY",
"blenderVersion": "5.2.0",
"width": 8,
"height": 8,
"pixelCount": 64,
"pixelFormat": "RGBA8",
"colorSpace": "SRGB",
"alphaMode": "STRAIGHT",
"uniquePixelCount": 1,
"referencePixel": [51, 102, 204, 255],
"rgbaSha256": "c2ff81750b41193ce1a06d47d0ff18168a756136dbf0f2586cd0638d4bf0ef00",
"sourceContentMaxFloatError": 0,
"contentSha256": "ff139c4a1c5d388c78c5168dfd3272e80ea0d1b15b3743e29a38cd908e8c70e7"
}

View File

@@ -0,0 +1,64 @@
{
"schemaVersion": 1,
"task": "M12-02H",
"parentTask": "M12-02",
"parityId": "N-023-ASSET-PREVIEW",
"enablingTask": false,
"parityStateChange": true,
"implementationClass": "LOCAL_EXACT",
"completedSlice": "A-preview-desktop-browser-main-offscreen-display",
"backends": [
"BLENDER_5_2_DESKTOP",
"MAIN_THREAD_CANVAS_2D",
"OFFSCREEN_CANVAS_2D"
],
"reference": {
"width": 8,
"height": 8,
"pixelFormat": "RGBA8",
"colorSpace": "SRGB",
"alphaMode": "STRAIGHT",
"rgbaSha256": "c2ff81750b41193ce1a06d47d0ff18168a756136dbf0f2586cd0638d4bf0ef00"
},
"thresholds": {
"maxMeanAbsoluteError": 0,
"maxRootMeanSquaredError": 0,
"maxP95ChannelError": 0,
"maxBadPixelRatio": 0,
"badPixelChannelError": 0,
"foregroundDeltaFromReferenceBackground": 1,
"minForegroundIntersectionOverUnion": 1,
"maxAlphaCoverageDeltaRatio": 0
},
"artifacts": {
"displayRuntime": {
"path": "web/app/src/assets/AssetPreviewDisplay.ts",
"sha256": "e4a46a40291427afc185caf016b02120ea71639ab4ab4ac250a5c3de28ee8d28"
},
"displayWorker": {
"path": "web/app/src/workers/asset-preview-display-test.worker.ts",
"sha256": "6322562f54b608b7eaac4ca3bc0f8d861f47bb8e9b37b3c2ae3d61163108724f"
},
"desktopChecker": {
"path": "tools/web/check-asset-preview-display.mjs",
"sha256": "d03d766e69ea0547be2c247b5d7ef8fd5ceab6b399dc996eba711fa0cac0ca54"
},
"desktopReport": {
"path": "tests/golden/M12-02H/desktop-report.json",
"sha256": "cdf841f5d27362f6df380772ada8b7b1427d051a8b46b5c2c20aab0b597c25d0"
},
"browserSuite": {
"path": "web/tests/e2e/asset-preview-display.spec.ts",
"sha256": "340d386c6a854c44cd6f6207e3699f870b8ded2aada4747afd6ff1103c719520"
},
"unitSuite": {
"path": "web/tests/unit/asset-preview-display.test.mjs",
"sha256": "f10a05900c4d1282251f7a76e7c413a18602910f55fe8400f1713f548eba3873"
},
"contentPNG": {
"path": "tests/golden/M12-02B/preview.png",
"sha256": "ff139c4a1c5d388c78c5168dfd3272e80ea0d1b15b3743e29a38cd908e8c70e7"
}
},
"nextTask": "M12-03A"
}

View File

@@ -0,0 +1,255 @@
{
"schemaVersion": 1,
"task": "M12-03A",
"enablingTask": true,
"parityStateChange": false,
"blenderVersion": "5.2.0",
"sources": [
{
"role": "LINK_APPEND_CONTEXT_API",
"path": "blender-5.2.0/source/blender/blenkernel/BKE_blendfile_link_append.hh",
"sha256": "3676283eca608a12fa6485c438374dfbe07a787efe22fd7719aadb8ccb8c81f9"
},
{
"role": "LINK_APPEND_CLOSURE",
"path": "blender-5.2.0/source/blender/blenkernel/intern/blendfile_link_append.cc",
"sha256": "b6e7f839a934eabd708c5465fdcf00e138e1f84837ae6690198b14a5069b6d23"
},
{
"role": "LINK_APPEND_OPERATORS",
"path": "blender-5.2.0/source/blender/windowmanager/intern/wm_files_link.cc",
"sha256": "d67d555f8534e6794ba7e520d95faef5a0a83017415517c8821690fa4accfb8d"
},
{
"role": "PYTHON_LIBRARY_LOAD",
"path": "blender-5.2.0/source/blender/python/intern/bpy_library_load.cc",
"sha256": "41b80637140582c3c75fabe234a8c33e9749c75cfc5c8f9c748401c18b253cab"
},
{
"role": "BLEND_LIBRARY_READER",
"path": "blender-5.2.0/source/blender/blenloader/BLO_readfile.hh",
"sha256": "24e65071b94e7fbc754e12563b9698f2d92a5aab06b89763950d2d4f7d66c623"
},
{
"role": "OVERRIDE_STORAGE",
"path": "blender-5.2.0/source/blender/makesdna/DNA_ID.h",
"sha256": "27d0eac02df737f2d2298eeef45e66eae0d76da66542ccdf32400b3e9bd904a9"
},
{
"role": "OVERRIDE_API",
"path": "blender-5.2.0/source/blender/blenkernel/BKE_lib_override.hh",
"sha256": "78545dad30c66a61e1f9754ba8017422b8d7fd3a4b41e522299e27f951762fde"
},
{
"role": "OVERRIDE_CLOSURE",
"path": "blender-5.2.0/source/blender/blenkernel/intern/lib_override.cc",
"sha256": "34ed9f0edd6549c77e73dc958ec189717bb6eb7dda3531109161546b8c532808"
},
{
"role": "CURRENT_WEB_LIBRARY_GATE",
"path": "web/protocol/asset-library-io.ts",
"sha256": "5c62539db3833a2113007dfbdfd12ca06704d86e0cf0e57ad97f7fbf6acda7f4"
}
],
"runtimeFixture": {
"path": "tests/files/web/image_resource_matrix.blend",
"sha256": "21e16821b62dad84539560bfa53ac755f097dbcd33ca2d614c07123461cdd74e",
"nestedLibraries": [
{
"filepath": "//resources/image_resource_library.blend",
"isArchive": false
}
]
},
"dataBlockSurface": {
"discoverableCollections": [
"actions",
"annotations",
"armatures",
"brushes",
"cache_files",
"cameras",
"collections",
"curves",
"fonts",
"grease_pencils",
"hair_curves",
"images",
"lattices",
"lightprobes",
"lights",
"linestyles",
"masks",
"materials",
"meshes",
"metaballs",
"movieclips",
"node_groups",
"objects",
"paint_curves",
"palettes",
"particles",
"pointclouds",
"scenes",
"screens",
"sounds",
"speakers",
"texts",
"textures",
"volumes",
"workspaces",
"worlds"
],
"onlyAppendableCollections": ["screens", "workspaces"],
"nonRootRoles": [
{"collection": "libraries", "role": "SOURCE_AND_TRANSITIVE_LIBRARY_METADATA"},
{"collection": "shape_keys", "role": "EMBEDDED_OWNER_DEPENDENCY"},
{"collection": "window_managers", "role": "CURRENT_FILE_RUNTIME_ONLY"},
{"collection": "all_ids", "role": "AGGREGATE_QUERY_NOT_LIBRARY_SECTION"}
]
},
"operatorSurface": {
"appendProperties": [
"filepath", "directory", "filename", "files", "check_existing", "filter_blender",
"filter_backup", "filter_image", "filter_movie", "filter_python", "filter_font",
"filter_sound", "filter_text", "filter_archive", "filter_btx", "filter_alembic",
"filter_usd", "filter_obj", "filter_volume", "filter_folder", "filter_blenlib",
"filemode", "display_type", "sort_method", "link", "do_reuse_local_id",
"clear_asset_data", "autoselect", "active_collection", "instance_collections",
"instance_object_data", "set_fake", "use_recursive"
],
"linkProperties": [
"filepath", "directory", "filename", "files", "check_existing", "filter_blender",
"filter_backup", "filter_image", "filter_movie", "filter_python", "filter_font",
"filter_sound", "filter_text", "filter_archive", "filter_btx", "filter_alembic",
"filter_usd", "filter_obj", "filter_volume", "filter_folder", "filter_blenlib",
"filemode", "relative_path", "display_type", "sort_method", "link",
"do_reuse_local_id", "clear_asset_data", "autoselect", "active_collection",
"instance_collections", "instance_object_data"
],
"pythonLoadArguments": [
"filepath", "link", "pack", "relative", "set_fake", "recursive", "reuse_local_id",
"assets_only", "clear_asset_data", "create_liboverrides", "reuse_liboverrides",
"create_liboverrides_runtime"
]
},
"operations": [
{
"operation": "APPEND",
"entryPoints": ["WM_OT_append", "bpy.data.libraries.load(link=False)"],
"rootCollections": "ALL_DISCOVERABLE",
"dataBlockRoles": [
"SOURCE_LIBRARY",
"DIRECT_SELECTED_ROOT",
"INDIRECT_ID_DEPENDENCY",
"TRANSITIVE_LIBRARY_DEPENDENCY",
"EMBEDDED_OWNER_DEPENDENCY",
"LIBOVERRIDE_DEPENDENCY",
"REUSABLE_LOCAL_ID",
"INSTANTIATED_COLLECTION_OBJECT_OR_OBJECT_DATA"
],
"closure": {
"walker": "BKE_library_foreach_ID_link",
"walkFlags": ["IDWALK_NOP"],
"ignoredAsIndependentEdges": [
"IDWALK_CB_EMBEDDED",
"IDWALK_CB_EMBEDDED_NOT_OWNING",
"IDWALK_CB_INTERNAL",
"IDWALK_CB_LOOPBACK"
],
"selectedRootTag": "DIRECT",
"discoveredDependencyTag": "INDIRECT",
"overrideDependencyTags": ["LIBOVERRIDE_DEPENDENCY", "LIBOVERRIDE_DEPENDENCY_ONLY"],
"terminalActions": ["KEEP_LINKED", "REUSE_LOCAL", "MAKE_LOCAL", "COPY_LOCAL"],
"recursiveCrossLibraryLocalization": "CONTROLLED_BY_USE_RECURSIVE"
}
},
{
"operation": "LINK",
"entryPoints": ["WM_OT_link", "bpy.data.libraries.load(link=True)"],
"rootCollections": "DISCOVERABLE_EXCEPT_ONLY_APPENDABLE",
"dataBlockRoles": [
"SOURCE_LIBRARY",
"DIRECT_LINKED_ROOT",
"INDIRECT_LINKED_ID_DEPENDENCY",
"TRANSITIVE_LIBRARY_DEPENDENCY",
"EMBEDDED_OWNER_DEPENDENCY",
"INSTANTIATED_COLLECTION_OBJECT_OR_OBJECT_DATA"
],
"closure": {
"readerStages": ["BLO_library_link_begin", "BLO_library_link_named_part", "BLO_library_link_end"],
"selectedRootTag": "ID_TAG_EXTERN",
"discoveredDependencyTag": "ID_TAG_INDIRECT",
"sourceOwnership": "ID.lib",
"transitiveLibraryOwnership": "Library.runtime.parent",
"localization": "NONE"
}
},
{
"operation": "LIBRARY_OVERRIDE",
"entryPoints": ["bpy.data.libraries.load(link=True, create_liboverrides=True)", "BKE_blendfile_override"],
"rootCollections": "LINK_ROOT_SURFACE_SUBJECT_TO_OVERRIDE_HIERARCHY",
"dataBlockRoles": [
"LINKED_REFERENCE_ROOT",
"LINKED_REFERENCE_DEPENDENCY",
"LOCAL_OVERRIDE_ID",
"LOCAL_HIERARCHY_ROOT",
"SYSTEM_OVERRIDE_DEPENDENCY",
"OVERRIDE_PROPERTY",
"OVERRIDE_PROPERTY_OPERATION",
"REFERENCE_AND_LOCAL_SUBITEM"
],
"closure": {
"referencePointer": "IDOverrideLibrary.reference",
"hierarchyRootPointer": "IDOverrideLibrary.hierarchy_root",
"propertyPath": "IDOverrideLibraryProperty.rna_path",
"operationCollection": "IDOverrideLibraryProperty.operations",
"creation": "BKE_lib_override_library_create",
"resync": "BKE_lib_override_library_resync",
"operationDiff": "BKE_lib_override_library_operations_create",
"dependencyRule": "REFERENCE_POINTER_GRAPH_WITH_LOCAL_OVERRIDE_REMAP",
"linkedReferencePreserved": true
}
}
],
"overrideRNA": {
"IDOverrideLibrary": [
{"identifier": "reference", "type": "POINTER", "isReadonly": true},
{"identifier": "hierarchy_root", "type": "POINTER", "isReadonly": true},
{"identifier": "is_in_hierarchy", "type": "BOOLEAN", "isReadonly": false},
{"identifier": "is_system_override", "type": "BOOLEAN", "isReadonly": false},
{"identifier": "properties", "type": "COLLECTION", "isReadonly": true}
],
"IDOverrideLibraryProperty": [
{"identifier": "rna_path", "type": "STRING", "isReadonly": true},
{"identifier": "operations", "type": "COLLECTION", "isReadonly": true}
],
"IDOverrideLibraryPropertyOperation": [
{"identifier": "operation", "type": "ENUM", "isReadonly": true},
{"identifier": "flag", "type": "ENUM", "isReadonly": true},
{"identifier": "subitem_reference_name", "type": "STRING", "isReadonly": true},
{"identifier": "subitem_local_name", "type": "STRING", "isReadonly": true},
{"identifier": "subitem_reference_id", "type": "POINTER", "isReadonly": true},
{"identifier": "subitem_local_id", "type": "POINTER", "isReadonly": true},
{"identifier": "subitem_reference_index", "type": "INT", "isReadonly": true},
{"identifier": "subitem_local_index", "type": "INT", "isReadonly": true},
{"identifier": "label", "type": "STRING", "isReadonly": true},
{"identifier": "tooltip", "type": "STRING", "isReadonly": true}
]
},
"currentWebGap": {
"represented": ["LIBRARY_SOURCE", "LIBRARY_DEPENDENCY_IDS", "READ_ONLY_FLAG"],
"missing": [
"PER_ID_SOURCE_LIBRARY",
"DIRECT_INDIRECT_ID_CLASSIFICATION",
"APPEND_LOCAL_OWNERSHIP",
"APPEND_REUSE_WEAK_REFERENCE",
"OVERRIDE_REFERENCE",
"OVERRIDE_HIERARCHY_ROOT",
"OVERRIDE_PROPERTY_OPERATIONS",
"INVALIDATION_TOKEN"
],
"mutationGate": "LIBRARY_MUTATION_UNAVAILABLE"
},
"nextTask": "M12-03B"
}

View File

@@ -0,0 +1,80 @@
{
"schemaVersion": 1,
"task": "M12-03B",
"source": {
"schemaVersion": 1,
"sourceLibraryId": "library:f35430c19c85351788053ceb44ba4dc01ec0a190a985197e60826d9d52c55a94",
"sourceLocator": "project-assets/libraries/m12-library-source.blend",
"sourceSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"bindings": [
{
"schemaVersion": 1,
"operation": "APPEND",
"source": {
"schemaVersion": 1,
"sourceLibraryId": "library:f35430c19c85351788053ceb44ba4dc01ec0a190a985197e60826d9d52c55a94",
"sourceLocator": "project-assets/libraries/m12-library-source.blend",
"sourceSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"sourceDataBlockId": "Object/M12Asset",
"owner": {
"kind": "LOCAL_MAIN",
"projectId": "project:m12",
"localDataBlockId": "Object/M12Asset"
},
"readOnly": false,
"referenceReadOnly": false,
"sourceGeneration": 3,
"sourceRevision": 7,
"dependencyClosureSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"invalidationToken": "libtoken:c650807d1afb1c64d1e9deba45120b2bff5336dfdff52ce61c5d3645be923e6a"
},
{
"schemaVersion": 1,
"operation": "LINK",
"source": {
"schemaVersion": 1,
"sourceLibraryId": "library:f35430c19c85351788053ceb44ba4dc01ec0a190a985197e60826d9d52c55a94",
"sourceLocator": "project-assets/libraries/m12-library-source.blend",
"sourceSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"sourceDataBlockId": "Object/M12Asset",
"owner": {
"kind": "SOURCE_LIBRARY",
"sourceLibraryId": "library:f35430c19c85351788053ceb44ba4dc01ec0a190a985197e60826d9d52c55a94",
"sourceDataBlockId": "Object/M12Asset"
},
"readOnly": true,
"referenceReadOnly": true,
"sourceGeneration": 3,
"sourceRevision": 7,
"dependencyClosureSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"invalidationToken": "libtoken:028c1336cb8f27596daa8754654be461ab10af66caa35f706eac3f4b9480cfa4"
},
{
"schemaVersion": 1,
"operation": "LIBRARY_OVERRIDE",
"source": {
"schemaVersion": 1,
"sourceLibraryId": "library:f35430c19c85351788053ceb44ba4dc01ec0a190a985197e60826d9d52c55a94",
"sourceLocator": "project-assets/libraries/m12-library-source.blend",
"sourceSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"sourceDataBlockId": "Object/M12Asset",
"owner": {
"kind": "LOCAL_OVERRIDE",
"projectId": "project:m12",
"localDataBlockId": "Object/M12AssetOverride",
"referenceSourceDataBlockId": "Object/M12Asset",
"hierarchyRootDataBlockId": "Collection/M12Root"
},
"readOnly": false,
"referenceReadOnly": true,
"sourceGeneration": 3,
"sourceRevision": 7,
"dependencyClosureSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"invalidationToken": "libtoken:a308528792e6056af25bebb3a349ac7e1ff5c4e40189d2cb7aad453a8a8299fe"
}
]
}

View File

@@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"task": "M12-03B",
"parentTask": "M12-03",
"enablingTask": true,
"parityStateChange": false,
"operations": ["APPEND", "LINK", "LIBRARY_OVERRIDE"],
"ownershipSemantics": {
"APPEND": {"owner": "LOCAL_MAIN", "readOnly": false, "referenceReadOnly": false},
"LINK": {"owner": "SOURCE_LIBRARY", "readOnly": true, "referenceReadOnly": true},
"LIBRARY_OVERRIDE": {"owner": "LOCAL_OVERRIDE", "readOnly": false, "referenceReadOnly": true}
},
"invalidationFields": [
"operation",
"sourceLibraryId",
"sourceSha256",
"sourceDataBlockId",
"owner",
"sourceGeneration",
"sourceRevision",
"dependencyClosureSha256"
],
"artifacts": {
"parentInventory": {
"path": "tests/golden/M12-03A/library-operation-inventory.json",
"sha256": "0dcb16233b52b3c0be2373f489d6bf3c771c9d6564993ff98b5d63dcd322fef7"
},
"protocol": {
"path": "web/protocol/library-operation-identity.ts",
"sha256": "5371133bd98e05935cd75bab582abedec32a36559af8d212f0bc8a534ef19451"
},
"golden": {
"path": "tests/golden/M12-03B/library-operation-bindings.json",
"sha256": "b39c0f26697ac688c1203ae4e3e27d8a02686c7c9da9afc438c89a0b4d69370f"
},
"unitSuite": {
"path": "web/tests/unit/library-operation-identity.test.mjs",
"sha256": "0e5b2c82d481180eb8eb22de16ad8cdef5e58fa7423c26011faaffbac788e547"
}
},
"nextTask": "M12-03C"
}

View File

@@ -0,0 +1,202 @@
{
"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"
}
],
"geometry": {
"edges": 4,
"loops": 4,
"materialSlots": [
"M12 Append Material"
],
"polygons": 1,
"uvLayers": [
"UVMap"
],
"vertices": 4
},
"ids": {
"IMAGE": {
"idType": "IMAGE",
"isLibraryOverride": false,
"library": null,
"name": "M12 Append Image",
"nameFull": "M12 Append Image"
},
"MATERIAL": {
"idType": "MATERIAL",
"isLibraryOverride": false,
"library": null,
"name": "M12 Append Material",
"nameFull": "M12 Append Material"
},
"MESH": {
"idType": "MESH",
"isLibraryOverride": false,
"library": null,
"name": "M12 Append Mesh",
"nameFull": "M12 Append Mesh"
},
"OBJECT": {
"idType": "OBJECT",
"isLibraryOverride": false,
"library": null,
"name": "M12 Append Object",
"nameFull": "M12 Append Object"
}
},
"image": {
"channels": 4,
"colorspace": "sRGB",
"packed": true,
"pixelFloat32Sha256": "6f0f8c231d65149e69e6ed12d370bcfa095ef90adc202065492ea8c8ef17e45a",
"size": [
2,
2
]
},
"root": {
"idType": "OBJECT",
"isLibraryOverride": false,
"library": null,
"name": "M12 Append Object",
"nameFull": "M12 Append Object"
},
"sourceMarker": "M12-03C"
},
"blenderVersion": "5.2.0",
"nextTask": "M12-03D",
"operation": "APPEND",
"schemaVersion": 1,
"selectedRoots": [
"Object/M12 Append Object"
],
"source": {
"file": "m12_append_source.blend",
"sha256": "5b60d02926efd588a6ca300ba31414cdf37dbc48786c17b383a70319057c0606"
},
"sourceGraph": {
"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"
}
],
"geometry": {
"edges": 4,
"loops": 4,
"materialSlots": [
"M12 Append Material"
],
"polygons": 1,
"uvLayers": [
"UVMap"
],
"vertices": 4
},
"ids": {
"IMAGE": {
"idType": "IMAGE",
"isLibraryOverride": false,
"library": null,
"name": "M12 Append Image",
"nameFull": "M12 Append Image"
},
"MATERIAL": {
"idType": "MATERIAL",
"isLibraryOverride": false,
"library": null,
"name": "M12 Append Material",
"nameFull": "M12 Append Material"
},
"MESH": {
"idType": "MESH",
"isLibraryOverride": false,
"library": null,
"name": "M12 Append Mesh",
"nameFull": "M12 Append Mesh"
},
"OBJECT": {
"idType": "OBJECT",
"isLibraryOverride": false,
"library": null,
"name": "M12 Append Object",
"nameFull": "M12 Append Object"
}
},
"image": {
"channels": 4,
"colorspace": "sRGB",
"packed": true,
"pixelFloat32Sha256": "6f0f8c231d65149e69e6ed12d370bcfa095ef90adc202065492ea8c8ef17e45a",
"size": [
2,
2
]
},
"root": {
"idType": "OBJECT",
"isLibraryOverride": false,
"library": null,
"name": "M12 Append Object",
"nameFull": "M12 Append Object"
},
"sourceMarker": "M12-03C"
},
"stableMapping": [
{
"local": "Object/M12 Append Object",
"owner": "LOCAL_MAIN",
"readOnly": false,
"source": "Object/M12 Append Object"
},
{
"local": "Mesh/M12 Append Mesh",
"owner": "LOCAL_MAIN",
"readOnly": false,
"source": "Mesh/M12 Append Mesh"
},
{
"local": "Material/M12 Append Material",
"owner": "LOCAL_MAIN",
"readOnly": false,
"source": "Material/M12 Append Material"
},
{
"local": "Image/M12 Append Image",
"owner": "LOCAL_MAIN",
"readOnly": false,
"source": "Image/M12 Append Image"
}
],
"target": {
"file": "m12_append_target.blend",
"sha256": "5cde4927f47e33f0338de38184ce5ba971825fcefd1d8dc6feb48d41a6cdb60c"
},
"task": "M12-03C"
}

View File

@@ -0,0 +1,39 @@
{
"schemaVersion": 1,
"task": "M12-03C",
"parentTask": "M12-03",
"enablingTask": true,
"parityStateChange": false,
"runtime": "BLENDER_5_2_DESKTOP",
"operation": "APPEND",
"selectedRoots": ["Object/M12 Append Object"],
"dependencyTypes": ["OBJECT", "MESH", "MATERIAL", "IMAGE"],
"containerDeterminism": "SESSION_BOUND_SEMANTIC_REPORT_EXACT",
"artifacts": {
"parentContract": {
"path": "tests/golden/M12-03B/manifest.json",
"sha256": "f388aedb9c0901932cf90c058f2f2b83259d9797cd6b144566a25e37a348c64b"
},
"generator": {
"path": "tools/web/generate-library-append-fixture.py",
"sha256": "b9d8e3bd24966b0d7d419382d7ef0b35ae8def9ee2c2236dfd3ad1a90aebe66f"
},
"checker": {
"path": "tools/web/check-library-append-fixture.mjs",
"sha256": "6f8cc0d51be19423cf6fb7203c3c16f8d27e0fbb3b5b357bad5dec6c5ceb432d"
},
"sourceBlend": {
"path": "tests/files/web/m12_library_append_v1/m12_append_source.blend",
"sha256": "5b60d02926efd588a6ca300ba31414cdf37dbc48786c17b383a70319057c0606"
},
"targetBlend": {
"path": "tests/files/web/m12_library_append_v1/m12_append_target.blend",
"sha256": "5cde4927f47e33f0338de38184ce5ba971825fcefd1d8dc6feb48d41a6cdb60c"
},
"desktopReport": {
"path": "tests/golden/M12-03C/desktop-append-report.json",
"sha256": "b1d7b8b9e832d18d69081f63981062800e0f00f0c9aec025b18274510ed76e2a"
}
},
"nextTask": "M12-03D"
}

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()

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -0,0 +1,87 @@
import { planAssetPreviewDecode } from "../../../protocol/asset-preview-decode";
import type { AssetPreviewIdentityIR } from "../../../protocol/asset-preview";
export type AssetPreviewDisplayBackend = "MAIN_THREAD_CANVAS_2D" | "OFFSCREEN_CANVAS_2D";
export interface AssetPreviewDisplayReceiptIR {
schemaVersion: 1;
status: "READY";
backend: AssetPreviewDisplayBackend;
identitySha256: string;
contentSha256: string;
width: number;
height: number;
pixelByteLength: number;
pixelSha256: string;
nonTransparentPixels: number;
bitmapClosed: true;
}
export interface AssetPreviewDisplayResultIR {
receipt: AssetPreviewDisplayReceiptIR;
pixels: Uint8Array;
}
type PreviewCanvas = HTMLCanvasElement | OffscreenCanvas;
async function sha256Bytes(value: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(value).buffer);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export async function displayAssetPreview(
canvas: PreviewCanvas,
backend: AssetPreviewDisplayBackend,
identityValue: unknown,
encodedBytes: ArrayBuffer,
): Promise<AssetPreviewDisplayResultIR> {
const plan = await planAssetPreviewDecode(identityValue, encodedBytes);
if (typeof createImageBitmap !== "function") throw new Error("CAPABILITY_MISSING: createImageBitmap is unavailable");
const identity = identityValue as AssetPreviewIdentityIR;
const bitmap = await createImageBitmap(new Blob([encodedBytes], { type: plan.mimeType }), {
colorSpaceConversion: "none",
premultiplyAlpha: "none",
imageOrientation: "none",
});
try {
if (bitmap.width !== plan.width || bitmap.height !== plan.height) {
throw new Error("ASSET_PREVIEW_IDENTITY_MISMATCH: decoded preview dimensions changed");
}
canvas.width = plan.width;
canvas.height = plan.height;
const context = canvas.getContext("2d", { alpha: true, willReadFrequently: true }) as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!context) throw new Error("CAPABILITY_MISSING: preview Canvas2D context is unavailable");
context.clearRect(0, 0, plan.width, plan.height);
context.imageSmoothingEnabled = false;
context.globalCompositeOperation = "copy";
context.drawImage(bitmap, 0, 0, plan.width, plan.height);
const pixels = new Uint8Array(context.getImageData(0, 0, plan.width, plan.height).data);
let nonTransparentPixels = 0;
for (let offset = 3; offset < pixels.byteLength; offset += 4) if (pixels[offset] !== 0) nonTransparentPixels++;
const receipt: AssetPreviewDisplayReceiptIR = {
schemaVersion: 1,
status: "READY",
backend,
identitySha256: identity.identitySha256,
contentSha256: identity.content.sha256,
width: plan.width,
height: plan.height,
pixelByteLength: pixels.byteLength,
pixelSha256: await sha256Bytes(pixels),
nonTransparentPixels,
bitmapClosed: true,
};
if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
canvas.dataset.assetPreviewStatus = "ready";
canvas.dataset.assetPreviewBackend = backend;
canvas.dataset.assetPreviewPixelSha256 = receipt.pixelSha256;
}
return { receipt, pixels };
}
finally {
bitmap.close();
}
}

View File

@@ -30,6 +30,7 @@ import type {
PaintStrokeSessionReceiptIR,
} from "../../../protocol/paint-stroke-session";
import type { PaintPBVHCapabilityRequest } from "../../../protocol/paint-pbvh-capability";
import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "../../../protocol/library-main-append";
interface PendingRequest {
resolve: (result: WebEngineResult) => void;
@@ -122,6 +123,28 @@ export class WebEngineClient {
};
}
async appendLibraryObject(
source: ArrayBuffer,
request: LibraryMainAppendRequestIR,
): Promise<BlendOpenResult & { delta: SceneDelta; receipt: LibraryMainAppendReceiptIR }> {
const result = await this.request({ type: "appendLibraryObject", request, source }, [source]);
if (!result.snapshot || !result.delta || !result.libraryAppend) {
throw this.report("ASSET_MANIFEST_INVALID", "WebEngine did not return the committed library append", true);
}
this.geometryBuffers = result.geometryDelta
? applyMeshGeometryDelta(this.geometryBuffers, result.geometryDelta)
: result.geometryBuffers ?? this.geometryBuffers;
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers;
return {
status: result.status,
snapshot: result.snapshot,
geometryBuffers: [...this.geometryBuffers],
nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers],
delta: result.delta,
receipt: result.libraryAppend,
};
}
async beginPaintStroke(session: PaintStrokeSessionBeginIR): Promise<PaintStrokeSessionReceiptIR> {
const result = await this.request({ type: "beginPaintStroke", session });
if (!result.paintStrokeSession) throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not open the paint pointer session", true);

View File

@@ -42,7 +42,13 @@ async function sha256(data: ArrayBuffer): Promise<string> {
return Array.from(new Uint8Array(result), (value) => value.toString(16).padStart(2, "0")).join("");
}
function waitForLoadedFrame(video: HTMLVideoElement, timeoutMs: number): Promise<void> {
function cancelled(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new SequencerMediaCacheValidationError("SEQUENCER_CANCELLED", "Movie proxy generation was cancelled");
}
}
function waitForLoadedFrame(video: HTMLVideoElement, timeoutMs: number, signal?: AbortSignal): Promise<void> {
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => finish(new Error("timeout")), timeoutMs);
@@ -50,12 +56,16 @@ function waitForLoadedFrame(video: HTMLVideoElement, timeoutMs: number): Promise
window.clearTimeout(timeout);
video.removeEventListener("loadeddata", ready);
video.removeEventListener("error", failed);
signal?.removeEventListener("abort", aborted);
if (error) reject(error); else resolve();
};
const ready = (): void => finish();
const failed = (): void => finish(new Error("decode"));
const aborted = (): void => finish(new SequencerMediaCacheValidationError("SEQUENCER_CANCELLED", "Movie proxy generation was cancelled"));
video.addEventListener("loadeddata", ready, { once: true });
video.addEventListener("error", failed, { once: true });
signal?.addEventListener("abort", aborted, { once: true });
if (signal?.aborted) aborted();
});
}
@@ -64,7 +74,9 @@ export async function generateInitialSequencerMovieProxyFrame(
capabilityValue: unknown,
profileValue: unknown,
sourceData: ArrayBuffer,
signal?: AbortSignal,
): Promise<SequencerGeneratedProxyFrameIR> {
cancelled(signal);
const source = parseSequencerCodecProbeRequest(sourceValue);
const capability = parseSequencerCodecProbeResult(capabilityValue);
const profile = parseSequencerMediaProxyProfile(profileValue);
@@ -75,6 +87,7 @@ export async function generateInitialSequencerMovieProxyFrame(
if (!(sourceData instanceof ArrayBuffer) || sourceData.byteLength !== source.byteLength || await sha256(sourceData) !== source.sourceSha256) {
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_SOURCE_MISMATCH", "Movie proxy source bytes failed identity verification");
}
cancelled(signal);
if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") {
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "HTML media proxy generation is unavailable");
}
@@ -87,7 +100,8 @@ export async function generateInitialSequencerMovieProxyFrame(
try {
video.src = url;
video.load();
await waitForLoadedFrame(video, 10_000);
await waitForLoadedFrame(video, 10_000, signal);
cancelled(signal);
const canvas = document.createElement("canvas");
canvas.width = profile.width;
canvas.height = profile.height;
@@ -97,6 +111,7 @@ export async function generateInitialSequencerMovieProxyFrame(
}
context.clearRect(0, 0, profile.width, profile.height);
context.drawImage(video, 0, 0, profile.width, profile.height);
cancelled(signal);
const pixels = context.getImageData(0, 0, profile.width, profile.height).data;
const data = pixels.buffer.slice(pixels.byteOffset, pixels.byteOffset + pixels.byteLength);
return {

View File

@@ -0,0 +1,236 @@
import {
migrateAssetCatalogV1ToV2,
type AssetCatalogMigrationReportIR,
} from "../../../protocol/asset-catalog-migration";
import {
parseAssetCatalogManifestV2,
type AssetCatalogManifestV2IR,
} from "../../../protocol/asset-catalog-v2";
import type { ErrorCode } from "../../../protocol/error";
export const ASSET_CATALOG_INDEX_V1_ID = "asset-catalog:index:v1" as const;
export const ASSET_CATALOG_INDEX_V2_ID = "asset-catalog:index:v2" as const;
export const ASSET_CATALOG_MIGRATION_ID = "asset-catalog:migration:v1-to-v2" as const;
export type AssetCatalogMigrationFault = "AFTER_TARGET_PUT" | "AFTER_SOURCE_DELETE";
export interface AssetCatalogIndexRowV1 {
id: typeof ASSET_CATALOG_INDEX_V1_ID;
value: unknown;
}
export interface AssetCatalogIndexRowV2 {
id: typeof ASSET_CATALOG_INDEX_V2_ID;
value: AssetCatalogManifestV2IR;
}
export interface AssetCatalogIndexedDBMigrationReceiptIR {
schemaVersion: 1;
id: typeof ASSET_CATALOG_MIGRATION_ID;
task: "M12-01G";
status: "MIGRATED";
sourceRevision: number;
targetRevision: number;
sourceManifestSha256: string;
targetManifestSha256: string;
}
export interface AssetCatalogIndexedDBMigrationResultIR {
status: "MIGRATED" | "ALREADY_MIGRATED";
manifest: AssetCatalogManifestV2IR;
receipt: AssetCatalogIndexedDBMigrationReceiptIR;
migrationReport: AssetCatalogMigrationReportIR | null;
}
export interface AssetCatalogIndexedDBSnapshotIR {
schemaVersion: 1;
revision: number;
manifestSha256: string;
catalogOrder: Array<{ catalogId: string; path: string }>;
assetOrder: string[];
assetIdentities: Array<{
assetId: string;
assetLibraryIdentifier: string | null;
relativeAssetIdentifier: string;
}>;
}
export class AssetCatalogIndexedDBMigrationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string, options?: ErrorOptions) {
super(`${code}: ${message}`, options);
this.name = "AssetCatalogIndexedDBMigrationError";
this.code = code;
}
}
interface StoredRow {
id: string;
value?: unknown;
[key: string]: unknown;
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stableJSON(value: unknown): string {
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);
}
async function sha256(value: unknown): Promise<string> {
const bytes = new TextEncoder().encode(stableJSON(value));
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function transactionComplete(transaction: IDBTransaction, failure: () => Error | undefined): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(failure() ?? transaction.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog migration transaction failed"));
transaction.onabort = () => reject(failure() ?? transaction.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog migration transaction aborted"));
});
}
function readRow(store: IDBObjectStore, id: string): Promise<StoredRow | undefined> {
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result as StoredRow | undefined);
request.onerror = () => reject(request.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", `Catalog index lookup failed for ${id}`));
});
}
function assertStores(database: IDBDatabase, settingStore: string, migrationStore: string): void {
if (!database.objectStoreNames.contains(settingStore) || !database.objectStoreNames.contains(migrationStore)) {
throw new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog migration stores are unavailable");
}
}
function parseReceipt(value: unknown): AssetCatalogIndexedDBMigrationReceiptIR {
const digest = /^[a-f0-9]{64}$/;
if (!record(value) || value.schemaVersion !== 1 || value.id !== ASSET_CATALOG_MIGRATION_ID || value.task !== "M12-01G" || value.status !== "MIGRATED" ||
!Number.isSafeInteger(value.sourceRevision) || Number(value.sourceRevision) < 0 ||
!Number.isSafeInteger(value.targetRevision) || Number(value.targetRevision) < 0 ||
typeof value.sourceManifestSha256 !== "string" || !digest.test(value.sourceManifestSha256) ||
typeof value.targetManifestSha256 !== "string" || !digest.test(value.targetManifestSha256)) {
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Stored catalog migration receipt is invalid");
}
return value as unknown as AssetCatalogIndexedDBMigrationReceiptIR;
}
export async function readAssetCatalogIndexedDBIndex(
database: IDBDatabase,
settingStore = "setting",
migrationStore = "migration",
): Promise<{ source: StoredRow | undefined; target: StoredRow | undefined; receipt: StoredRow | undefined }> {
assertStores(database, settingStore, migrationStore);
const transaction = database.transaction([settingStore, migrationStore], "readonly");
const sourcePromise = readRow(transaction.objectStore(settingStore), ASSET_CATALOG_INDEX_V1_ID);
const targetPromise = readRow(transaction.objectStore(settingStore), ASSET_CATALOG_INDEX_V2_ID);
const receiptPromise = readRow(transaction.objectStore(migrationStore), ASSET_CATALOG_MIGRATION_ID);
const result = await Promise.all([sourcePromise, targetPromise, receiptPromise]);
await transactionComplete(transaction, () => undefined);
return { source: result[0], target: result[1], receipt: result[2] };
}
export async function migrateAssetCatalogIndexedDB(
database: IDBDatabase,
options: {
settingStore?: string;
migrationStore?: string;
faultAt?: AssetCatalogMigrationFault;
} = {},
): Promise<AssetCatalogIndexedDBMigrationResultIR> {
const settingStore = options.settingStore ?? "setting";
const migrationStore = options.migrationStore ?? "migration";
assertStores(database, settingStore, migrationStore);
const before = await readAssetCatalogIndexedDBIndex(database, settingStore, migrationStore);
if (!before.source) {
if (!before.target || !before.receipt) {
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Catalog index has neither a complete v1 source nor a complete v2 migration");
}
const manifest = await parseAssetCatalogManifestV2(before.target.value);
const receipt = parseReceipt(before.receipt);
if (receipt.targetRevision !== manifest.revision || await sha256(manifest) !== receipt.targetManifestSha256) {
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Catalog migration receipt does not match the stored v2 index");
}
return { status: "ALREADY_MIGRATED", manifest, receipt, migrationReport: null };
}
if (before.target || before.receipt?.id === ASSET_CATALOG_MIGRATION_ID) {
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Catalog index contains a partial v1 to v2 migration");
}
const prepared = await migrateAssetCatalogV1ToV2(before.source.value);
const receipt: AssetCatalogIndexedDBMigrationReceiptIR = {
schemaVersion: 1,
id: ASSET_CATALOG_MIGRATION_ID,
task: "M12-01G",
status: "MIGRATED",
sourceRevision: prepared.report.sourceRevision,
targetRevision: prepared.report.targetRevision,
sourceManifestSha256: prepared.report.sourceManifestSha256,
targetManifestSha256: prepared.report.targetManifestSha256,
};
let migrationFailure: Error | undefined;
const transaction = database.transaction([settingStore, migrationStore], "readwrite");
const completion = transactionComplete(transaction, () => migrationFailure);
const settings = transaction.objectStore(settingStore);
const migrations = transaction.objectStore(migrationStore);
const sourceRequest = settings.get(ASSET_CATALOG_INDEX_V1_ID);
sourceRequest.onerror = () => {
migrationFailure = sourceRequest.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog source recheck failed");
};
sourceRequest.onsuccess = () => {
const current = sourceRequest.result as StoredRow | undefined;
if (!current || stableJSON(current.value) !== stableJSON(before.source?.value)) {
migrationFailure = new AssetCatalogIndexedDBMigrationError("REVISION_CONFLICT", "Catalog v1 index changed while migration was prepared");
transaction.abort();
return;
}
settings.put({ id: ASSET_CATALOG_INDEX_V2_ID, value: prepared.manifest } satisfies AssetCatalogIndexRowV2);
if (options.faultAt === "AFTER_TARGET_PUT") {
migrationFailure = new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Injected catalog migration failure after target write");
transaction.abort();
return;
}
migrations.put(receipt);
settings.delete(ASSET_CATALOG_INDEX_V1_ID);
if (options.faultAt === "AFTER_SOURCE_DELETE") {
migrationFailure = new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Injected catalog migration failure after source delete");
transaction.abort();
}
};
await completion;
return { status: "MIGRATED", manifest: prepared.manifest, receipt, migrationReport: prepared.report };
}
export async function loadAssetCatalogIndexedDBSnapshot(
database: IDBDatabase,
options: { settingStore?: string; migrationStore?: string } = {},
): Promise<AssetCatalogIndexedDBSnapshotIR> {
const loaded = await migrateAssetCatalogIndexedDB(database, options);
const manifestSha256 = await sha256(loaded.manifest);
if (manifestSha256 !== loaded.receipt.targetManifestSha256) {
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Loaded catalog index does not match its migration receipt");
}
return {
schemaVersion: 1,
revision: loaded.manifest.revision,
manifestSha256,
catalogOrder: loaded.manifest.catalogs.map(({ catalogId, path }) => ({ catalogId, path })),
assetOrder: loaded.manifest.assets.map(({ assetId }) => assetId),
assetIdentities: loaded.manifest.assets.map(({ assetId, assetLibraryIdentifier, relativeAssetIdentifier }) => ({
assetId,
assetLibraryIdentifier,
relativeAssetIdentifier,
})),
};
}

View File

@@ -0,0 +1,262 @@
import {
parseAssetCatalogManifestV2,
type AssetCatalogManifestV2IR,
type AssetCatalogV2PreviewIR,
} from "../../../protocol/asset-catalog-v2";
import { planAssetPreviewDecode } from "../../../protocol/asset-preview-decode";
import {
createAssetPreviewIdentity,
parseAssetPreviewIdentity,
type AssetPreviewIdentityIR,
} from "../../../protocol/asset-preview";
import type { ErrorCode } from "../../../protocol/error";
import {
ASSET_CATALOG_INDEX_V2_ID,
type AssetCatalogIndexRowV2,
} from "./asset-catalog-indexeddb";
import { readContentAsset, writeContentAsset } from "./opfs-files";
export const ASSET_PREVIEW_CATALOG_HEAD_ID = "asset-catalog:preview-head:v1" as const;
export { createAssetPreviewIdentity, parseAssetCatalogManifestV2 };
export type AssetPreviewCatalogCommitFault =
| "BEFORE_OPFS_WRITE"
| "AFTER_OPFS_WRITE"
| "AFTER_CATALOG_PUT";
export interface AssetPreviewCatalogCommitReceiptIR {
schemaVersion: 1;
id: typeof ASSET_PREVIEW_CATALOG_HEAD_ID;
task: "M12-02D";
status: "COMMITTED";
projectId: string;
assetId: string;
baseRevision: number;
committedRevision: number;
baseManifestSha256: string;
committedManifestSha256: string;
previewIdentitySha256: string;
contentSha256: string;
contentByteLength: number;
opfsPath: string;
}
export interface AssetPreviewCatalogCommitResultIR {
manifest: AssetCatalogManifestV2IR;
receipt: AssetPreviewCatalogCommitReceiptIR;
preview: AssetCatalogV2PreviewIR;
deduplicated: boolean;
}
export class AssetPreviewCatalogCommitError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string, options?: ErrorOptions) {
super(`${code}: ${message}`, options);
this.name = "AssetPreviewCatalogCommitError";
this.code = code;
}
}
interface StoredRow {
id: string;
value?: unknown;
[key: string]: unknown;
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stableJSON(value: unknown): string {
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);
}
async function sha256(value: unknown): Promise<string> {
const bytes = new TextEncoder().encode(stableJSON(value));
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function sha256Bytes(value: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", value);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function readRow(store: IDBObjectStore, id: string): Promise<StoredRow | undefined> {
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result as StoredRow | undefined);
request.onerror = () => reject(request.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", `Catalog row lookup failed for ${id}`));
});
}
function transactionComplete(transaction: IDBTransaction, failure: () => Error | undefined): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(failure() ?? transaction.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Preview catalog transaction failed"));
transaction.onabort = () => reject(failure() ?? transaction.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Preview catalog transaction aborted"));
});
}
async function readCatalog(database: IDBDatabase, settingStore: string): Promise<{ row: StoredRow; manifest: AssetCatalogManifestV2IR }> {
if (!database.objectStoreNames.contains(settingStore)) {
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Catalog setting store is unavailable");
}
const transaction = database.transaction(settingStore, "readonly");
const row = await readRow(transaction.objectStore(settingStore), ASSET_CATALOG_INDEX_V2_ID);
await transactionComplete(transaction, () => undefined);
if (!row) throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Catalog v2 index is missing");
return { row, manifest: await parseAssetCatalogManifestV2(row.value) };
}
function parseReceipt(value: unknown): AssetPreviewCatalogCommitReceiptIR {
const digest = /^[a-f0-9]{64}$/;
if (!record(value) || value.schemaVersion !== 1 || value.id !== ASSET_PREVIEW_CATALOG_HEAD_ID ||
value.task !== "M12-02D" || value.status !== "COMMITTED" || typeof value.projectId !== "string" ||
typeof value.assetId !== "string" || !Number.isSafeInteger(value.baseRevision) || Number(value.baseRevision) < 0 ||
!Number.isSafeInteger(value.committedRevision) || Number(value.committedRevision) !== Number(value.baseRevision) + 1 ||
typeof value.baseManifestSha256 !== "string" || !digest.test(value.baseManifestSha256) ||
typeof value.committedManifestSha256 !== "string" || !digest.test(value.committedManifestSha256) ||
typeof value.previewIdentitySha256 !== "string" || !digest.test(value.previewIdentitySha256) ||
typeof value.contentSha256 !== "string" || !digest.test(value.contentSha256) ||
!Number.isSafeInteger(value.contentByteLength) || Number(value.contentByteLength) <= 0 ||
typeof value.opfsPath !== "string" || value.opfsPath.length === 0) {
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview catalog commit receipt is invalid");
}
return value as unknown as AssetPreviewCatalogCommitReceiptIR;
}
function previewFromIdentity(identity: AssetPreviewIdentityIR): AssetCatalogV2PreviewIR {
return {
sha256: identity.content.sha256,
mimeType: identity.content.mimeType,
width: identity.content.width,
height: identity.content.height,
byteLength: identity.content.byteLength,
};
}
export async function commitAssetPreviewToOPFS(
database: IDBDatabase,
projectId: string,
expectedRevision: number,
identityValue: unknown,
encodedBytes: ArrayBuffer,
options: {
settingStore?: string;
storage?: StorageManager;
faultAt?: AssetPreviewCatalogCommitFault;
} = {},
): Promise<AssetPreviewCatalogCommitResultIR> {
const settingStore = options.settingStore ?? "setting";
const identity = await parseAssetPreviewIdentity(identityValue);
await planAssetPreviewDecode(identity, encodedBytes);
const before = await readCatalog(database, settingStore);
if (before.manifest.revision !== expectedRevision) {
throw new AssetPreviewCatalogCommitError("REVISION_CONFLICT", "Catalog revision changed before preview storage");
}
const assetIndex = before.manifest.assets.findIndex((asset) => asset.assetId === identity.assetId);
if (assetIndex === -1) {
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview identity does not belong to a catalog asset");
}
if (expectedRevision === Number.MAX_SAFE_INTEGER) {
throw new AssetPreviewCatalogCommitError("ASSET_BUDGET_EXCEEDED", "Catalog revision cannot advance safely");
}
const preview = previewFromIdentity(identity);
const candidate = await parseAssetCatalogManifestV2({
...before.manifest,
revision: expectedRevision + 1,
assets: before.manifest.assets.map((asset, index) => index === assetIndex ? { ...asset, preview } : asset),
});
const baseManifestSha256 = await sha256(before.manifest);
const committedManifestSha256 = await sha256(candidate);
if (options.faultAt === "BEFORE_OPFS_WRITE") {
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Injected failure before preview OPFS write");
}
let written: Awaited<ReturnType<typeof writeContentAsset>>;
try {
written = await writeContentAsset(projectId, identity.content.sha256, encodedBytes, options.storage);
const persisted = await readContentAsset(projectId, identity.content.sha256, options.storage);
await planAssetPreviewDecode(identity, persisted);
}
catch (error) {
if (error instanceof AssetPreviewCatalogCommitError || (error instanceof Error && "code" in error)) throw error;
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Preview OPFS write or readback verification failed", { cause: error });
}
if (options.faultAt === "AFTER_OPFS_WRITE") {
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Injected failure after preview OPFS write");
}
const receipt: AssetPreviewCatalogCommitReceiptIR = {
schemaVersion: 1,
id: ASSET_PREVIEW_CATALOG_HEAD_ID,
task: "M12-02D",
status: "COMMITTED",
projectId,
assetId: identity.assetId,
baseRevision: expectedRevision,
committedRevision: candidate.revision,
baseManifestSha256,
committedManifestSha256,
previewIdentitySha256: identity.identitySha256,
contentSha256: identity.content.sha256,
contentByteLength: identity.content.byteLength,
opfsPath: written.path,
};
let commitFailure: Error | undefined;
const transaction = database.transaction(settingStore, "readwrite");
const completion = transactionComplete(transaction, () => commitFailure);
const store = transaction.objectStore(settingStore);
const request = store.get(ASSET_CATALOG_INDEX_V2_ID);
request.onerror = () => {
commitFailure = request.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Catalog source recheck failed");
};
request.onsuccess = () => {
const current = request.result as StoredRow | undefined;
if (!current || stableJSON(current.value) !== stableJSON(before.row.value)) {
commitFailure = new AssetPreviewCatalogCommitError("REVISION_CONFLICT", "Catalog index changed while preview storage was prepared");
transaction.abort();
return;
}
store.put({ id: ASSET_CATALOG_INDEX_V2_ID, value: candidate } satisfies AssetCatalogIndexRowV2);
store.put(receipt);
if (options.faultAt === "AFTER_CATALOG_PUT") {
commitFailure = new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Injected failure after preview catalog writes");
transaction.abort();
}
};
await completion;
return { manifest: candidate, receipt, preview, deduplicated: written.deduplicated };
}
export async function loadCommittedAssetPreviewCatalog(
database: IDBDatabase,
options: { settingStore?: string; storage?: StorageManager } = {},
): Promise<AssetPreviewCatalogCommitResultIR> {
const settingStore = options.settingStore ?? "setting";
const catalog = await readCatalog(database, settingStore);
const transaction = database.transaction(settingStore, "readonly");
const row = await readRow(transaction.objectStore(settingStore), ASSET_PREVIEW_CATALOG_HEAD_ID);
await transactionComplete(transaction, () => undefined);
if (!row) throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview catalog commit receipt is missing");
const receipt = parseReceipt(row);
if (catalog.manifest.revision !== receipt.committedRevision || await sha256(catalog.manifest) !== receipt.committedManifestSha256) {
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview catalog receipt does not match the current catalog");
}
const asset = catalog.manifest.assets.find((candidate) => candidate.assetId === receipt.assetId);
if (!asset?.preview || asset.preview.sha256 !== receipt.contentSha256 || asset.preview.byteLength !== receipt.contentByteLength) {
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Committed catalog preview does not match its receipt");
}
const persisted = await readContentAsset(receipt.projectId, receipt.contentSha256, options.storage);
if (persisted.byteLength !== receipt.contentByteLength || await sha256Bytes(persisted) !== receipt.contentSha256) {
throw new AssetPreviewCatalogCommitError("ASSET_SOURCE_HASH_MISMATCH", "Committed preview payload identity changed");
}
return { manifest: catalog.manifest, receipt, preview: asset.preview, deduplicated: false };
}

Some files were not shown because too many files have changed in this diff Show More