feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

- Add 11:07 Chinese-narrated lesson video, VTT, poster and 13 chapter screenshots.
- Regenerate 20-render WebGL2 matrix and 15 operation captures on RTX 4060.
- Fix WASM staging for non-ASCII import filenames (猴头.glb); rebuild web_engine.
- Harden lesson video recording pipeline; fix stale lesson-link E2E selector.
- Add RE PBR derived libraries, lesson page, evidence and status report.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-12 21:49:24 +08:00
parent a078648297
commit d5dd647881
276 changed files with 9632 additions and 314 deletions

View File

@@ -1941,6 +1941,44 @@ std::string image_mime_type(const std::string &filepath)
return "application/octet-stream";
}
bool packed_image_dimensions(const std::string &mime_type,
const std::vector<uint8_t> &bytes,
int64_t &width,
int64_t &height)
{
width = 0;
height = 0;
if (mime_type == "image/png") {
if (bytes.size() < 24 || bytes[0] != 0x89 || bytes[1] != 'P' || bytes[2] != 'N' || bytes[3] != 'G') return false;
width = (int64_t(bytes[16]) << 24) | (int64_t(bytes[17]) << 16) | (int64_t(bytes[18]) << 8) | int64_t(bytes[19]);
height = (int64_t(bytes[20]) << 24) | (int64_t(bytes[21]) << 16) | (int64_t(bytes[22]) << 8) | int64_t(bytes[23]);
return width > 0 && height > 0;
}
if (mime_type == "image/jpeg") {
if (bytes.size() < 4 || bytes[0] != 0xff || bytes[1] != 0xd8) return false;
size_t offset = 2;
while (offset + 9 < bytes.size()) {
if (bytes[offset] != 0xff) { offset += 1; continue; }
const uint8_t marker = bytes[offset + 1];
offset += 2;
if (marker == 0xd8 || marker == 0xd9) continue;
if (offset + 2 > bytes.size()) break;
const size_t length = (size_t(bytes[offset]) << 8) | size_t(bytes[offset + 1]);
if (length < 2 || offset + length > bytes.size()) break;
if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) {
if (length < 7) break;
height = (int64_t(bytes[offset + 3]) << 8) | int64_t(bytes[offset + 4]);
width = (int64_t(bytes[offset + 5]) << 8) | int64_t(bytes[offset + 6]);
return width > 0 && height > 0;
}
offset += length;
}
}
return false;
}
bool image_signature_valid(const std::string &mime_type, const std::vector<uint8_t> &bytes)
{
if (bytes.empty()) return false;
@@ -4782,6 +4820,9 @@ json scene_ir_from_blend(const ParsedBlend &blend,
float emission_strength = 1.0f;
std::vector<float> emission_color = {0.0f, 0.0f, 0.0f, 1.0f};
std::string normal_image_id;
std::string displacement_image_id;
float displacement_midlevel = 0.5f;
float displacement_scale = 0.001f;
std::vector<std::string> image_ids;
std::vector<std::string> warnings;
json material_nodes = json::array();
@@ -4809,6 +4850,9 @@ json scene_ir_from_blend(const ParsedBlend &blend,
node_type == "ShaderNodeBsdfPrincipled" ? "PRINCIPLED" :
node_type == "ShaderNodeTexImage" ? "IMAGE_TEXTURE" :
node_type == "ShaderNodeNormalMap" ? "NORMAL_MAP" :
node_type == "NodeReroute" ? "REROUTE" :
node_type == "NodeFrame" ? "FRAME" :
node_type == "ShaderNodeDisplacement" ? "DISPLACEMENT" :
node_type == "ShaderNodeOutputMaterial" ? "OUTPUT" : "UNSUPPORTED";
json node_ir = {{"id", node_id}, {"type", mapped_type}, {"name", node_name.empty() ? node_type : node_name}};
auto location = read_float_array(*blend.sdna, node, "location", 2);
@@ -4995,6 +5039,8 @@ json scene_ir_from_blend(const ParsedBlend &blend,
std::string uv_map;
double strength = 1.0;
double distance = 1.0;
double midlevel = 0.5;
double scale = 0.01;
bool evaluated = true;
};
std::function<bool(const std::string &, int, TextureResolution &)> resolve_texture =
@@ -5014,6 +5060,12 @@ json scene_ir_from_blend(const ParsedBlend &blend,
std::string next_socket;
if (type == "NORMAL_MAP") { next_socket = "Color"; out.strength = socket_number(node, "Strength", 1.0); }
else if (type == "BUMP") { next_socket = "Height"; out.strength = socket_number(node, "Strength", 1.0); out.distance = socket_number(node, "Distance", 1.0); out.terminal_type = "BUMP"; }
else if (type == "DISPLACEMENT") {
next_socket = "Height";
out.midlevel = socket_number(node, "Midlevel", 0.5);
out.scale = socket_number(node, "Scale", 0.01);
out.terminal_type = "DISPLACEMENT";
}
else if (type == "MAPPING") { next_socket = "Vector"; out.evaluated = false; }
else if (type == "SEPARATE_COLOR") { next_socket = "Color"; out.evaluated = false; }
if (next_socket.empty() && node.contains("sockets")) {
@@ -5076,6 +5128,43 @@ json scene_ir_from_blend(const ParsedBlend &blend,
if (std::find(image_ids.begin(), image_ids.end(), resolution.image_id) == image_ids.end()) image_ids.push_back(resolution.image_id);
}
}
for (const json &output : material_nodes) {
if (output.value("type", "") != "OUTPUT" || !output.contains("sockets")) continue;
for (const json &socket : output["sockets"]) {
if (socket.value("direction", "") != "INPUT" || socket.value("name", "") != "Displacement" || !socket.value("linked", false)) continue;
const std::string socket_id = socket.value("id", "");
const auto link_it = link_by_target.find(output.value("id", std::string()) + "\n" + socket_id);
if (link_it == link_by_target.end()) continue;
const std::string displacement_node_id = (*link_it->second).value("fromNodeId", std::string());
const auto displacement_it = node_by_id.find(displacement_node_id);
if (displacement_it == node_by_id.end() ||
(*displacement_it->second).value("type", "") != "DISPLACEMENT") {
warnings.push_back("displacement_terminal_unsupported");
continue;
}
TextureResolution resolution;
if (!resolve_texture(displacement_node_id, 0, resolution)) {
warnings.push_back("displacement_binding_unresolved");
continue;
}
const std::string key = resolution.image_id + "\nHEIGHT";
if (!binding_keys.insert(key).second) continue;
json binding = {
{"imageId", resolution.image_id},
{"usage", "HEIGHT"},
{"socket", "Height"},
{"evaluated", resolution.evaluated},
{"midlevel", resolution.midlevel},
{"scale", resolution.scale},
};
if (!resolution.uv_map.empty()) binding["uvMap"] = resolution.uv_map;
texture_bindings.push_back(std::move(binding));
displacement_image_id = resolution.image_id;
displacement_midlevel = float(resolution.midlevel);
displacement_scale = float(resolution.scale);
if (std::find(image_ids.begin(), image_ids.end(), resolution.image_id) == image_ids.end()) image_ids.push_back(resolution.image_id);
}
}
}
if (node_tree_pointer && !has_principled) warnings.push_back("principled_node_not_found");
json material = { {"id", record.id},
@@ -5093,6 +5182,11 @@ json scene_ir_from_blend(const ParsedBlend &blend,
{"coatRoughness", std::clamp(coat_roughness, 0.0f, 1.0f)},
{"emissionStrength", std::clamp(emission_strength, 0.0f, 1000000.0f)} };
if (!normal_image_id.empty()) material["normalImageId"] = normal_image_id;
if (!displacement_image_id.empty()) {
material["displacementImageId"] = displacement_image_id;
material["displacementMidlevel"] = displacement_midlevel;
material["displacementScale"] = displacement_scale;
}
if (!image_ids.empty()) material["imageIds"] = image_ids;
if (!texture_bindings.empty()) material["textureBindings"] = std::move(texture_bindings);
if (!material_nodes.empty()) {
@@ -5336,10 +5430,21 @@ json scene_ir_from_blend(const ParsedBlend &blend,
if (!packed_record.bytes.empty()) packed_assets.emplace(record.id, packed_record.bytes);
corrupt = !image_signature_valid(mime_type, packed_record.bytes);
}
const int64_t width = !image_tiles.empty() ? read_integer(*blend.sdna, image_tiles.front(), "gen_x").value_or(0) :
read_integer(*blend.sdna, element, "gen_x").value_or(0);
const int64_t height = !image_tiles.empty() ? read_integer(*blend.sdna, image_tiles.front(), "gen_y").value_or(0) :
read_integer(*blend.sdna, element, "gen_y").value_or(0);
const int64_t image_width = read_integer(*blend.sdna, element, "x").value_or(0);
const int64_t image_height = read_integer(*blend.sdna, element, "y").value_or(0);
int64_t packed_width = 0;
int64_t packed_height = 0;
if (!packed_records.empty()) {
packed_image_dimensions(mime_type, packed_records.front().bytes, packed_width, packed_height);
}
const int64_t width = packed_width > 0 ? packed_width :
(source != 6 && image_width > 0 ? image_width :
(!image_tiles.empty() ? read_integer(*blend.sdna, image_tiles.front(), "gen_x").value_or(0) :
read_integer(*blend.sdna, element, "gen_x").value_or(0)));
const int64_t height = packed_height > 0 ? packed_height :
(source != 6 && image_height > 0 ? image_height :
(!image_tiles.empty() ? read_integer(*blend.sdna, image_tiles.front(), "gen_y").value_or(0) :
read_integer(*blend.sdna, element, "gen_y").value_or(0)));
std::string asset_status = packed ? "PACKED" : !library_path.empty() ? "LINKED_LIBRARY_REQUIRED" :
source == 4 ? "GENERATED" : source == 6 ? "UDIM_EXTERNAL" :
!filepath.empty() ? "EXTERNAL" : "MISSING";

View File

@@ -24898,12 +24898,20 @@ bool web_engine_blend_main_obj_io(WebBlendMainState *state,
std::filesystem::path path;
~Scratch() { std::error_code ignored; std::filesystem::remove_all(path, ignored); }
} scratch{directory};
const std::string basename = BLI_path_basename(file_name ? file_name : "scene.obj");
if (basename.empty() || basename == "." || basename == ".." || basename.size() > 255) {
const std::string original_basename = BLI_path_basename(file_name ? file_name : "scene.obj");
if (original_basename.empty() || original_basename == "." || original_basename == ".." || original_basename.size() > 255) {
error = "IO_FORMAT_INPUT_INVALID: mesh file name is invalid";
return false;
}
const std::string filename = (directory / basename).string();
/* The browser may send non-ASCII file names (for example 猴头.glb). Blender's
* importers look the staged path up through the Emscripten VFS, so keep the
* on-disk staging name ASCII while leaving the UI's original display name
* untouched. ASCII names keep their existing on-disk name. */
std::string staged_basename = original_basename;
for (char &character : staged_basename) {
if (static_cast<unsigned char>(character) >= 0x80) character = '_';
}
const std::string filename = (directory / staged_basename).string();
const int before = BLI_listbase_count(&state->main->objects);
if (importing) {
const auto bytes = decode_base64(base64);