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:
329
tools/web/generate-re-pbr-library.py
Normal file
329
tools/web/generate-re-pbr-library.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""Derive the Web Blender RE 5000PBR asset library from the desktop catalog.
|
||||
|
||||
Usage:
|
||||
build_blender_native/bin/blender --background --factory-startup \
|
||||
--python tools/web/generate-re-pbr-library.py -- \
|
||||
"/path/to/5000+ PBR catalog.blend" OUTPUT_DIRECTORY \
|
||||
[--base-color-size 1024] [--normal-size 1024] [--scalar-size 512] [--preview-size 256]
|
||||
|
||||
The source .blend is never modified. The derived library keeps the original
|
||||
shader graphs (FRAME/REROUTE/DISPLACEMENT/MAPPING/VALUE included) so the Web
|
||||
compiler/reader must support them; this script only marks assets, downsamples
|
||||
packed textures, repacks them and renders material previews.
|
||||
|
||||
Outputs (inside OUTPUT_DIRECTORY):
|
||||
re-pbr-library.blend six marked MATERIAL assets, no scene objects
|
||||
blender_assets.cats.txt Materials/RE5000PBR catalog
|
||||
previews/<sha256>.png 256x256 EEVEE material previews
|
||||
preview-manifest.json
|
||||
build-report.json
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
CATALOG_ID = "a8000000-0000-4000-8000-000000000001"
|
||||
CATALOG_PATH = "Materials/RE5000PBR"
|
||||
CATALOG_NAME = "RE5000PBR"
|
||||
LIBRARY_NAME = "re-pbr-library.blend"
|
||||
EXPECTED_MATERIALS = 6
|
||||
|
||||
|
||||
def sha256_file(path: pathlib.Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def load_common():
|
||||
path = pathlib.Path(__file__).with_name("generate-pbr-library.py")
|
||||
spec = importlib.util.spec_from_file_location("pbr_library_common", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"cannot load {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(arguments) < 2:
|
||||
raise SystemExit(
|
||||
"usage: blender -b --python generate-re-pbr-library.py -- "
|
||||
"SOURCE_BLEND OUTPUT_DIR [--base-color-size N] [--normal-size N] "
|
||||
"[--scalar-size N] [--preview-size N]"
|
||||
)
|
||||
source = pathlib.Path(arguments[0]).resolve()
|
||||
output = pathlib.Path(arguments[1]).resolve()
|
||||
options = {
|
||||
"base_color_size": 1024,
|
||||
"normal_size": 1024,
|
||||
"scalar_size": 512,
|
||||
"preview_size": 256,
|
||||
"preserve_original": False,
|
||||
"material_suffix": "",
|
||||
"library_id": "library:re-pbr-v1",
|
||||
}
|
||||
index = 2
|
||||
while index < len(arguments):
|
||||
flag = arguments[index]
|
||||
if flag == "--preserve-original":
|
||||
options["preserve_original"] = True
|
||||
index += 1
|
||||
continue
|
||||
if flag not in {"--base-color-size", "--normal-size", "--scalar-size", "--preview-size", "--texture-size", "--material-suffix", "--library-id"}:
|
||||
raise SystemExit(f"unknown option {flag}")
|
||||
if index + 1 >= len(arguments):
|
||||
raise SystemExit(f"missing value for {flag}")
|
||||
key = flag.removeprefix("--").replace("-", "_")
|
||||
value = arguments[index + 1]
|
||||
if key == "texture_size":
|
||||
size = int(value)
|
||||
options["base_color_size"] = size
|
||||
options["normal_size"] = size
|
||||
options["scalar_size"] = size
|
||||
elif key in {"base_color_size", "normal_size", "scalar_size", "preview_size"}:
|
||||
options[key] = int(value)
|
||||
else:
|
||||
options[key] = value
|
||||
index += 2
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"source blend not found: {source}")
|
||||
return source, output, options
|
||||
|
||||
|
||||
def target_size(image_name: str, options) -> int:
|
||||
lowered = image_name.lower()
|
||||
if "normal" in lowered:
|
||||
return options["normal_size"]
|
||||
if "base_color" in lowered or "basecolor" in lowered or "albedo" in lowered:
|
||||
return options["base_color_size"]
|
||||
return options["scalar_size"]
|
||||
|
||||
|
||||
def resample_image(image, size: int, temporary: pathlib.Path, preserve_original: bool = False):
|
||||
if image.size[0] == 0 or image.size[1] == 0:
|
||||
return
|
||||
if preserve_original and image.size[0] == size and image.size[1] == size:
|
||||
return
|
||||
if image.size[0] != size or image.size[1] != size:
|
||||
image.scale(size, size)
|
||||
image.file_format = "JPEG"
|
||||
temporary.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
image.save(filepath=str(temporary), quality=84)
|
||||
except TypeError:
|
||||
image.filepath_raw = str(temporary)
|
||||
image.save()
|
||||
if not temporary.is_file() or temporary.stat().st_size == 0:
|
||||
raise RuntimeError(f"failed to write resampled image {temporary}")
|
||||
data = temporary.read_bytes()
|
||||
# Image.pack() with no data re-packs the original packed file. Pack the
|
||||
# resampled JPEG bytes explicitly so the library actually shrinks.
|
||||
image.pack(data=data, data_len=len(data))
|
||||
|
||||
|
||||
def material_uses_principled(material) -> bool:
|
||||
if not material.use_nodes or material.node_tree is None:
|
||||
return False
|
||||
return any(node.type == "BSDF_PRINCIPLED" for node in material.node_tree.nodes)
|
||||
|
||||
|
||||
def collect_materials():
|
||||
materials = [
|
||||
material for material in bpy.data.materials
|
||||
if material.name != "Dots Stroke" and material_uses_principled(material)
|
||||
]
|
||||
materials.sort(key=lambda material: material.name)
|
||||
if len(materials) != EXPECTED_MATERIALS:
|
||||
raise RuntimeError(
|
||||
f"expected {EXPECTED_MATERIALS} Principled materials, found {len(materials)}: "
|
||||
+ ", ".join(material.name for material in materials)
|
||||
)
|
||||
return materials
|
||||
|
||||
|
||||
def write_catalog_definition(path: pathlib.Path):
|
||||
lines = [
|
||||
"# This is an Asset Catalog Definition file for Blender.",
|
||||
"# Derived from the desktop RE 5000PBR catalog by generate-re-pbr-library.py",
|
||||
"",
|
||||
"VERSION 1",
|
||||
"",
|
||||
f"{CATALOG_ID}:{CATALOG_PATH}:{CATALOG_NAME}",
|
||||
]
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def mark_materials(materials):
|
||||
for material in materials:
|
||||
if material.asset_data is None:
|
||||
material.asset_mark()
|
||||
material.asset_data.catalog_id = CATALOG_ID
|
||||
material.asset_data.description = "RE 5000PBR material for the Web Blender render lesson"
|
||||
material.asset_data.tags.new("PBR")
|
||||
material.asset_data.tags.new("RE5000PBR")
|
||||
|
||||
|
||||
def resample_material_images(materials, options, temporary_dir: pathlib.Path):
|
||||
processed = set()
|
||||
resampled = []
|
||||
for material in materials:
|
||||
if material.node_tree is None:
|
||||
continue
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type != "TEX_IMAGE" or node.image is None:
|
||||
continue
|
||||
image = node.image
|
||||
if image.name in processed or not image.packed_file:
|
||||
continue
|
||||
processed.add(image.name)
|
||||
size = target_size(image.name, options)
|
||||
temporary = temporary_dir / f"{sha256_bytes(image.name.encode('utf-8'))[:16]}.jpg"
|
||||
before = image.size[:]
|
||||
colorspace = image.colorspace_settings.name
|
||||
resample_image(image, size, temporary, options.get("preserve_original", False))
|
||||
image.colorspace_settings.name = colorspace
|
||||
resampled.append({
|
||||
"name": image.name,
|
||||
"from": list(before),
|
||||
"to": list(image.size[:]),
|
||||
"colorspace": colorspace,
|
||||
"packedBytes": image.packed_file.size if image.packed_file else 0,
|
||||
})
|
||||
return resampled
|
||||
|
||||
|
||||
def remove_scene_objects():
|
||||
for obj in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
|
||||
def remove_unused_data(materials):
|
||||
keep_material = {material.name for material in materials}
|
||||
for material in list(bpy.data.materials):
|
||||
if material.name not in keep_material:
|
||||
bpy.data.materials.remove(material)
|
||||
used_images = set()
|
||||
for material in materials:
|
||||
if material.node_tree is None:
|
||||
continue
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == "TEX_IMAGE" and node.image is not None:
|
||||
used_images.add(node.image.name)
|
||||
for image in list(bpy.data.images):
|
||||
if image.name not in used_images and not image.name.startswith("Render Result"):
|
||||
bpy.data.images.remove(image)
|
||||
|
||||
|
||||
def render_previews(common, materials, preview_dir: pathlib.Path, preview_size: int):
|
||||
common.PREVIEW_SIZE = preview_size
|
||||
scene = bpy.context.scene
|
||||
camera = common.configure_preview_scene(scene)
|
||||
scene.render.resolution_x = preview_size
|
||||
scene.render.resolution_y = preview_size
|
||||
entries = []
|
||||
for material in materials:
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=96, ring_count=64, radius=1.0, location=(0.0, 0.0, 0.0))
|
||||
sphere = bpy.context.object
|
||||
sphere.name = f"Preview {material.name}"
|
||||
bpy.ops.object.shade_smooth()
|
||||
sphere.data.materials.append(material)
|
||||
common.frame_object(scene, camera, sphere)
|
||||
temporary = preview_dir / "tmp.png"
|
||||
common.render_preview(scene, temporary)
|
||||
entry = common.store_preview(temporary, preview_dir)
|
||||
bpy.data.objects.remove(sphere, do_unlink=True)
|
||||
entry.update({"idType": "MATERIAL", "name": material.name})
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def main():
|
||||
source, output, options = parse_arguments()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
preview_dir = output / "previews"
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
for stale in preview_dir.glob("*.png"):
|
||||
stale.unlink()
|
||||
|
||||
common = load_common()
|
||||
bpy.ops.wm.open_mainfile(filepath=str(source))
|
||||
materials = collect_materials()
|
||||
suffix = str(options.get("material_suffix", ""))
|
||||
if suffix:
|
||||
for material in materials:
|
||||
material.name = f"{material.name}{suffix}"
|
||||
mark_materials(materials)
|
||||
temporary_dir = output / ".resample"
|
||||
resampled = resample_material_images(materials, options, temporary_dir)
|
||||
remove_scene_objects()
|
||||
remove_unused_data(materials)
|
||||
|
||||
catalog_path = output / "blender_assets.cats.txt"
|
||||
write_catalog_definition(catalog_path)
|
||||
library_path = output / LIBRARY_NAME
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(library_path), compress=True)
|
||||
|
||||
preview_entries = render_previews(common, materials, preview_dir, options["preview_size"])
|
||||
preview_manifest = output / "preview-manifest.json"
|
||||
preview_manifest.write_text(
|
||||
json.dumps({"schemaVersion": 1, "previews": preview_entries}, indent=2, ensure_ascii=True) + "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
for item in temporary_dir.glob("*"):
|
||||
if item.is_file():
|
||||
item.unlink()
|
||||
if temporary_dir.is_dir():
|
||||
temporary_dir.rmdir()
|
||||
|
||||
source_sha = sha256_file(source)
|
||||
library_sha = sha256_file(library_path)
|
||||
preview_bytes = sum(entry["byteLength"] for entry in preview_entries)
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"generator": "tools/web/generate-re-pbr-library.py",
|
||||
"scriptSha256": sha256_file(pathlib.Path(__file__).resolve()),
|
||||
"source": {"path": str(source), "sha256": source_sha, "byteLength": source.stat().st_size},
|
||||
"library": {
|
||||
"path": str(library_path),
|
||||
"sha256": library_sha,
|
||||
"byteLength": library_path.stat().st_size,
|
||||
"libraryId": "library:re-pbr-v1",
|
||||
"name": LIBRARY_NAME,
|
||||
},
|
||||
"catalog": {"id": CATALOG_ID, "path": CATALOG_PATH, "name": CATALOG_NAME},
|
||||
"materials": [material.name for material in materials],
|
||||
"textures": resampled,
|
||||
"textureOptions": options,
|
||||
"previewCount": len(preview_entries),
|
||||
"previewBytes": preview_bytes,
|
||||
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
|
||||
}
|
||||
(output / "build-report.json").write_text(
|
||||
json.dumps(report, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
print(json.dumps({
|
||||
"materials": len(materials),
|
||||
"previews": len(preview_entries),
|
||||
"librarySha256": library_sha,
|
||||
"libraryBytes": library_path.stat().st_size,
|
||||
"textureBytes": sum(item["packedBytes"] for item in resampled),
|
||||
}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user