feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
- 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:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user