EG/project/webgl_packager.py
2026-03-06 09:52:57 +08:00

996 lines
33 KiB
Python

"""WebGL project packager for EG editor (Three.js static scene export)."""
from __future__ import annotations
import datetime
import json
import os
import re
import shutil
import stat
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
class WebGLPackager:
"""Export current EG scene into a static WebGL package directory."""
BASIS_MATRIX_ROW_MAJOR = [
1.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
-1.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1.0,
]
ENERGY_TO_INTENSITY_SCALE = 0.001
def __init__(self, world):
self.world = world
self.scene_manager = getattr(world, "scene_manager", None)
self._project_path = ""
self._output_root = ""
self._assets_model_dir = ""
self._assets_texture_dir = ""
self._copied_source_to_uri: Dict[str, str] = {}
self._name_counter: Dict[str, int] = {}
self._node_id_by_pointer: Dict[int, str] = {}
self.report: Dict[str, Any] = {
"status": "failed",
"warnings": [],
"missing_assets": [],
"unsupported_assets": [],
"converted_assets": [],
"copied_assets": [],
"output_dir": "",
}
def package(self, project_path: str, output_dir: str) -> Dict[str, Any]:
"""Export project as WebGL static site and return export report."""
self._project_path = os.path.normpath(project_path)
project_name = os.path.basename(self._project_path.rstrip(os.sep)) or "project"
self._output_root = os.path.normpath(os.path.join(output_dir, f"{project_name}_webgl"))
self._assets_model_dir = os.path.join(self._output_root, "assets", "models")
self._assets_texture_dir = os.path.join(self._output_root, "assets", "textures")
self.report["output_dir"] = self._output_root
try:
if not os.path.isdir(self._project_path):
self._fail(f"项目路径不存在: {self._project_path}")
return self.report
self._prepare_output_dir()
self._copy_templates()
scene_manifest = self._build_scene_manifest(project_name)
self._write_json(
os.path.join(self._output_root, "scene", "scene_webgl.json"),
scene_manifest,
)
self._write_preview_scripts()
status = "success"
if self.report["missing_assets"] or self.report["unsupported_assets"]:
status = "partial"
self.report["status"] = status
except Exception as exc:
self._fail(f"WebGL打包失败: {exc}")
finally:
self._write_json(
os.path.join(self._output_root, "reports", "export_report.json"),
self.report,
)
return self.report
def _prepare_output_dir(self) -> None:
if os.path.isdir(self._output_root):
shutil.rmtree(self._output_root)
os.makedirs(os.path.join(self._output_root, "js"), exist_ok=True)
os.makedirs(os.path.join(self._output_root, "vendor"), exist_ok=True)
os.makedirs(self._assets_model_dir, exist_ok=True)
os.makedirs(self._assets_texture_dir, exist_ok=True)
os.makedirs(os.path.join(self._output_root, "scene"), exist_ok=True)
os.makedirs(os.path.join(self._output_root, "reports"), exist_ok=True)
def _copy_templates(self) -> None:
repo_root = Path(__file__).resolve().parent.parent
template_root = repo_root / "templates" / "webgl"
if not template_root.exists():
raise FileNotFoundError(f"模板目录不存在: {template_root}")
file_mapping = {
"index.html": "index.html",
"style.css": "style.css",
"viewer.js": os.path.join("js", "viewer.js"),
}
for src_name, dst_rel in file_mapping.items():
src = template_root / src_name
dst = Path(self._output_root) / dst_rel
if not src.exists():
raise FileNotFoundError(f"模板文件不存在: {src}")
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(src), str(dst))
vendor_src = template_root / "vendor"
vendor_dst = Path(self._output_root) / "vendor"
if vendor_src.exists():
for entry in vendor_src.iterdir():
if entry.is_file():
shutil.copy2(str(entry), str(vendor_dst / entry.name))
self._try_resolve_vendor_files(vendor_dst)
# Placeholder marker warning
placeholder_file = vendor_dst / "three.module.min.js"
if placeholder_file.exists():
content = placeholder_file.read_text(encoding="utf-8", errors="ignore")
if "EG_VENDOR_PLACEHOLDER" in content:
self.report["warnings"].append(
"当前 vendor 为占位文件,请替换为官方 three.module.min.js / OrbitControls.js / GLTFLoader.js 后再预览。"
)
def _try_resolve_vendor_files(self, vendor_dst: Path) -> None:
"""Try to replace template placeholders with system-installed Three.js modules."""
lookup_roots = [
Path("/usr/share/javascript/three"),
Path("/usr/share/nodejs/three"),
Path("/usr/lib/node_modules/three"),
Path.home() / ".local/lib/node_modules/three",
]
targets = {
"three.module.min.js": [
"build/three.module.min.js",
"build/three.module.js",
],
"OrbitControls.js": [
"examples/jsm/controls/OrbitControls.js",
],
"GLTFLoader.js": [
"examples/jsm/loaders/GLTFLoader.js",
],
}
for dst_name, rel_candidates in targets.items():
dst_path = vendor_dst / dst_name
if not dst_path.exists():
continue
try:
content = dst_path.read_text(encoding="utf-8", errors="ignore")
except Exception:
content = ""
# Only replace placeholders.
if "EG_VENDOR_PLACEHOLDER" not in content:
continue
found_source = None
for root in lookup_roots:
if not root.exists():
continue
for rel in rel_candidates:
candidate = root / rel
if candidate.exists() and candidate.is_file():
found_source = candidate
break
if found_source:
break
if found_source:
shutil.copy2(str(found_source), str(dst_path))
def _build_scene_manifest(self, project_name: str) -> Dict[str, Any]:
render = getattr(self.world, "render", None)
if not render:
raise RuntimeError("world.render 不可用")
export_nodes: List[Any] = []
model_nodes = self._collect_model_nodes()
spot_nodes = self._collect_valid_nodes(getattr(self.scene_manager, "Spotlight", []))
point_nodes = self._collect_valid_nodes(getattr(self.scene_manager, "Pointlight", []))
ground_node = self._get_default_ground_node()
export_nodes.extend(model_nodes)
export_nodes.extend(spot_nodes)
export_nodes.extend(point_nodes)
if ground_node is not None:
export_nodes.append(ground_node)
# Unique by pointer
uniq: Dict[int, Any] = {}
for node in export_nodes:
uniq[id(node)] = node
export_nodes = list(uniq.values())
for index, node in enumerate(export_nodes, start=1):
self._node_id_by_pointer[id(node)] = f"node_{index:04d}"
nodes_json: List[Dict[str, Any]] = []
for node in model_nodes:
entry = self._build_model_node_entry(node)
if entry:
nodes_json.append(entry)
for node in point_nodes:
entry = self._build_light_node_entry(node, kind="point_light")
if entry:
nodes_json.append(entry)
for node in spot_nodes:
entry = self._build_light_node_entry(node, kind="spot_light")
if entry:
nodes_json.append(entry)
if ground_node is not None:
entry = self._build_ground_node_entry(ground_node)
if entry:
nodes_json.append(entry)
manifest = {
"meta": {
"format_version": "1.0",
"project_name": project_name,
"exported_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
},
"coordinate": {
"source": "panda3d_zup",
"target": "threejs_yup",
"matrix_convention": "panda_row_vector_row_major",
"basis_matrix": self.BASIS_MATRIX_ROW_MAJOR,
},
"camera": self._build_camera_entry(),
"environment": self._build_environment_entry(),
"nodes": nodes_json,
}
return manifest
def _collect_model_nodes(self) -> List[Any]:
if not self.scene_manager:
return []
all_models = self._collect_valid_nodes(getattr(self.scene_manager, "models", []))
light_ptrs = {id(n) for n in self._collect_valid_nodes(getattr(self.scene_manager, "Spotlight", []))}
light_ptrs |= {id(n) for n in self._collect_valid_nodes(getattr(self.scene_manager, "Pointlight", []))}
models: List[Any] = []
for node in all_models:
if id(node) in light_ptrs:
continue
if node.hasTag("light_type"):
continue
if node.getName() in {"render", "camera", "cam"}:
continue
models.append(node)
return models
@staticmethod
def _collect_valid_nodes(nodes: List[Any]) -> List[Any]:
valid = []
for node in nodes or []:
if not node:
continue
try:
if node.isEmpty():
continue
except Exception:
continue
valid.append(node)
return valid
def _get_default_ground_node(self):
ground = getattr(self.world, "ground", None)
if not ground:
return None
try:
if ground.isEmpty():
return None
return ground
except Exception:
return None
def _build_camera_entry(self) -> Dict[str, Any]:
render = getattr(self.world, "render", None)
cam = getattr(self.world, "cam", None) or getattr(self.world, "camera", None)
default = {
"matrix_local_row_major": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
-50.0,
20.0,
1.0,
],
"fov_deg": 80.0,
"near": 0.1,
"far": 10000.0,
}
if not cam or not render:
return default
try:
cam_mat = cam.getMat(render)
fov = 80.0
near = 0.1
far = 10000.0
lens = cam.node().getLens() if cam.node() else None
if lens:
try:
fov = float(lens.getFov()[0])
except Exception:
pass
try:
near = float(lens.getNear())
except Exception:
pass
try:
far = float(lens.getFar())
except Exception:
pass
return {
"matrix_local_row_major": self._mat4_to_row_major_list(cam_mat),
"fov_deg": fov,
"near": near,
"far": far,
}
except Exception:
return default
def _build_environment_entry(self) -> Dict[str, Any]:
ambient = getattr(self.world, "ambient_light", None)
directional = getattr(self.world, "directional_light", None)
ambient_color = [0.2, 0.2, 0.2]
directional_color = [0.8, 0.8, 0.8]
directional_dir = [0.0, 0.0, -1.0]
if ambient and not ambient.isEmpty():
try:
c = ambient.node().getColor()
ambient_color = [float(c[0]), float(c[1]), float(c[2])]
except Exception:
pass
if directional and not directional.isEmpty():
try:
c = directional.node().getColor()
directional_color = [float(c[0]), float(c[1]), float(c[2])]
except Exception:
pass
try:
q = directional.getQuat(getattr(self.world, "render", directional.getParent()))
fwd = q.getForward()
directional_dir = [float(fwd[0]), float(fwd[1]), float(fwd[2])]
except Exception:
pass
return {
"include_default_ground": True,
"ambient_light": {
"color": ambient_color,
"intensity": 1.0,
},
"directional_light": {
"color": directional_color,
"intensity": 1.0,
"direction": directional_dir,
},
}
def _build_model_node_entry(self, node) -> Optional[Dict[str, Any]]:
node_id = self._node_id_by_pointer.get(id(node))
if not node_id:
return None
parent_id, mat = self._get_parent_and_matrix(node)
node_name = node.getName() or node_id
model_source, source_tag = self._resolve_model_source(node)
if not model_source:
self.report["missing_assets"].append(
{
"node": node_name,
"reason": "model_path_not_found",
"tags_checked": ["model_path", "saved_model_path", "original_path", "file"],
}
)
return None
model_uri = self._prepare_model_asset(model_source, node_name)
if not model_uri:
self.report["unsupported_assets"].append(
{
"node": node_name,
"source": model_source,
"reason": "model_conversion_failed",
}
)
return None
material_override = self._extract_material_override(node)
textures = self._collect_and_copy_texture_overrides(node, model_source)
entry: Dict[str, Any] = {
"id": node_id,
"name": node_name,
"kind": "model",
"parent_id": parent_id,
"matrix_local_row_major": self._mat4_to_row_major_list(mat),
"model": {"uri": model_uri},
"material_override": material_override,
"source_model_tag": source_tag,
}
if textures:
entry["texture_overrides"] = textures
return entry
def _build_light_node_entry(self, node, kind: str) -> Optional[Dict[str, Any]]:
node_id = self._node_id_by_pointer.get(id(node))
if not node_id:
return None
parent_id, mat = self._get_parent_and_matrix(node)
node_name = node.getName() or node_id
light_obj = node.getPythonTag("rp_light_object") if node.hasPythonTag("rp_light_object") else None
color = [1.0, 1.0, 1.0]
energy = self._safe_float(node.getTag("light_energy"), 5000.0) if node.hasTag("light_energy") else 5000.0
radius = self._safe_float(node.getTag("light_radius"), 30.0) if node.hasTag("light_radius") else 30.0
spot_fov = self._safe_float(node.getTag("light_fov"), 45.0) if node.hasTag("light_fov") else 45.0
inner_ratio = 0.4
if light_obj is not None:
try:
c = getattr(light_obj, "color", None)
if c is not None:
color = [float(c[0]), float(c[1]), float(c[2])]
except Exception:
pass
try:
energy = float(getattr(light_obj, "energy", energy))
except Exception:
pass
try:
radius = float(getattr(light_obj, "radius", radius))
except Exception:
pass
try:
if hasattr(light_obj, "fov"):
spot_fov = float(getattr(light_obj, "fov", spot_fov))
except Exception:
pass
try:
if hasattr(light_obj, "inner_radius"):
inner_ratio = float(getattr(light_obj, "inner_radius", inner_ratio))
except Exception:
pass
intensity = max(0.0, energy * self.ENERGY_TO_INTENSITY_SCALE)
return {
"id": node_id,
"name": node_name,
"kind": kind,
"parent_id": parent_id,
"matrix_local_row_major": self._mat4_to_row_major_list(mat),
"light": {
"color": color,
"intensity": intensity,
"range": max(0.0, radius),
"spot_angle_deg": max(1.0, spot_fov),
"inner_cone_ratio": max(0.0, min(1.0, inner_ratio)),
},
}
def _build_ground_node_entry(self, node) -> Optional[Dict[str, Any]]:
node_id = self._node_id_by_pointer.get(id(node))
if not node_id:
return None
parent_id, mat = self._get_parent_and_matrix(node)
node_name = node.getName() or "ground"
color = [0.8, 0.8, 0.8, 1.0]
roughness = 1.0
metallic = 0.0
material = None
try:
material = node.getMaterial()
except Exception:
material = None
if material:
try:
if material.hasBaseColor():
c = material.getBaseColor()
color = [float(c[0]), float(c[1]), float(c[2]), float(c[3])]
except Exception:
pass
try:
roughness = float(material.getRoughness())
except Exception:
pass
try:
metallic = float(material.getMetallic())
except Exception:
pass
return {
"id": node_id,
"name": node_name,
"kind": "ground",
"parent_id": parent_id,
"matrix_local_row_major": self._mat4_to_row_major_list(mat),
"ground": {
"width": 100.0,
"height": 100.0,
},
"material_override": {
"base_color": [color[0], color[1], color[2], 1.0],
"roughness": roughness,
"metallic": metallic,
"opacity": 1.0,
},
}
def _resolve_model_source(self, node) -> Tuple[Optional[str], str]:
tags = ["model_path", "saved_model_path", "original_path", "file"]
for tag in tags:
if not node.hasTag(tag):
continue
value = (node.getTag(tag) or "").strip()
if not value:
continue
resolved = self._resolve_asset_path(value)
if resolved:
return resolved, tag
return None, ""
def _resolve_asset_path(self, candidate: str) -> Optional[str]:
candidate = (candidate or "").strip()
if not candidate:
return None
if candidate.startswith(("http://", "https://")):
return None
# Absolute path: use directly.
if os.path.isabs(candidate):
abs_candidate = os.path.normpath(candidate)
if os.path.exists(abs_candidate):
return abs_candidate
return None
# Relative path resolution policy.
search_roots = [
self._project_path,
os.path.join(self._project_path, "models"),
os.path.join(self._project_path, "Resources"),
os.path.join(self._project_path, "scenes", "resources"),
os.getcwd(),
]
for root in search_roots:
full = os.path.normpath(os.path.join(root, candidate))
if os.path.exists(full):
return full
return None
def _prepare_model_asset(self, source_path: str, node_name: str) -> Optional[str]:
source_path = os.path.normpath(source_path)
ext = os.path.splitext(source_path)[1].lower()
if ext in {".gltf", ".glb"}:
return self._copy_asset_to_models(source_path)
with tempfile.TemporaryDirectory(prefix="eg_webgl_conv_") as temp_dir:
conversion_source = source_path
if ext == ".bam":
temp_egg = os.path.join(temp_dir, self._sanitize_filename(Path(source_path).stem) + ".egg")
if not self._run_tool_command(["bam2egg", source_path, temp_egg], timeout=120):
return None
temp_obj = os.path.join(temp_dir, self._sanitize_filename(Path(source_path).stem) + ".obj")
if not self._run_tool_command(["egg2obj", temp_egg, temp_obj], timeout=120):
return None
conversion_source = temp_obj
elif ext == ".egg":
temp_obj = os.path.join(temp_dir, self._sanitize_filename(Path(source_path).stem) + ".obj")
if not self._run_tool_command(["egg2obj", source_path, temp_obj], timeout=120):
return None
conversion_source = temp_obj
target_filename = self._unique_filename(node_name or Path(source_path).stem, ".glb")
target_abs = os.path.join(self._assets_model_dir, target_filename)
converter = self._convert_to_glb(conversion_source, target_abs)
if not converter:
return None
self.report["converted_assets"].append(
{
"source": source_path,
"converted_from": conversion_source,
"target": os.path.relpath(target_abs, self._output_root).replace("\\", "/"),
"converter": converter,
}
)
return os.path.relpath(target_abs, self._output_root).replace("\\", "/")
def _convert_to_glb(self, source_path: str, target_path: str) -> str:
scene_manager = self.scene_manager
if scene_manager is None:
return ""
conversion_order = [
"_convertWithBlender",
"_convertWithFBX2glTF",
"_convertWithAssimp",
]
for method_name in conversion_order:
method = getattr(scene_manager, method_name, None)
if not callable(method):
continue
try:
ok = method(source_path, target_path, None)
except TypeError:
ok = method(source_path, target_path)
except Exception:
ok = False
if ok and os.path.exists(target_path):
return method_name
return ""
def _run_tool_command(self, args: List[str], timeout: int) -> bool:
executable = shutil.which(args[0])
if not executable:
self.report["warnings"].append(f"缺少转换工具: {args[0]}")
return False
try:
result = subprocess.run(
args,
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
except Exception as exc:
self.report["warnings"].append(f"执行命令失败: {' '.join(args)} ({exc})")
return False
if result.returncode != 0:
stderr = (result.stderr or "").strip()
stdout = (result.stdout or "").strip()
detail = stderr or stdout or f"exit={result.returncode}"
self.report["warnings"].append(f"命令失败: {' '.join(args)} -> {detail}")
return False
return True
def _copy_asset_to_models(self, source_path: str) -> str:
source_path = os.path.normpath(source_path)
if source_path in self._copied_source_to_uri:
return self._copied_source_to_uri[source_path]
ext = os.path.splitext(source_path)[1].lower() or ".bin"
safe_name = self._unique_filename(Path(source_path).stem, ext)
target_abs = os.path.join(self._assets_model_dir, safe_name)
shutil.copy2(source_path, target_abs)
rel_uri = os.path.relpath(target_abs, self._output_root).replace("\\", "/")
self._copied_source_to_uri[source_path] = rel_uri
self.report["copied_assets"].append(
{
"source": source_path,
"target": rel_uri,
"type": "model",
}
)
return rel_uri
def _collect_and_copy_texture_overrides(self, node, model_source: str) -> List[Dict[str, Any]]:
textures: List[Dict[str, Any]] = []
texture_pairs = self._extract_texture_stage_and_paths(node)
if not texture_pairs:
return textures
model_dir = os.path.dirname(model_source)
for stage_name, tex_path in texture_pairs:
abs_path = self._resolve_texture_path(tex_path, model_dir)
if not abs_path:
continue
rel_uri = self._copy_asset_to_textures(abs_path)
if rel_uri:
textures.append({"stage": stage_name, "uri": rel_uri})
return textures
def _extract_texture_stage_and_paths(self, node) -> List[Tuple[str, str]]:
pairs: List[Tuple[str, str]] = []
seen: set = set()
nodes_to_scan = [node]
try:
geom_paths = node.findAllMatches("**/+GeomNode")
for i in range(geom_paths.getNumPaths()):
nodes_to_scan.append(geom_paths.getPath(i))
except Exception:
pass
for np in nodes_to_scan:
try:
stages = np.findAllTextureStages()
except Exception:
continue
try:
stage_count = stages.getNumTextureStages()
except Exception:
stage_count = 0
for idx in range(stage_count):
try:
stage = stages.getTextureStage(idx)
texture = np.getTexture(stage)
except Exception:
continue
if not texture:
continue
tex_path = ""
try:
if texture.hasFullpath():
tex_path = texture.getFullpath().toOsSpecific()
except Exception:
tex_path = ""
if not tex_path:
continue
stage_name = stage.getName() if stage else f"stage_{idx}"
key = (stage_name, tex_path)
if key in seen:
continue
seen.add(key)
pairs.append(key)
return pairs
def _resolve_texture_path(self, path_hint: str, model_dir: str) -> Optional[str]:
path_hint = (path_hint or "").strip()
if not path_hint:
return None
if os.path.isabs(path_hint) and os.path.exists(path_hint):
return os.path.normpath(path_hint)
search_roots = [
model_dir,
self._project_path,
os.path.join(self._project_path, "scenes", "resources"),
os.getcwd(),
]
for root in search_roots:
full = os.path.normpath(os.path.join(root, path_hint))
if os.path.exists(full):
return full
return None
def _copy_asset_to_textures(self, source_path: str) -> Optional[str]:
source_path = os.path.normpath(source_path)
cache_key = f"texture::{source_path}"
if cache_key in self._copied_source_to_uri:
return self._copied_source_to_uri[cache_key]
ext = os.path.splitext(source_path)[1].lower() or ".png"
safe_name = self._unique_filename(Path(source_path).stem, ext)
target_abs = os.path.join(self._assets_texture_dir, safe_name)
try:
shutil.copy2(source_path, target_abs)
except Exception:
return None
rel_uri = os.path.relpath(target_abs, self._output_root).replace("\\", "/")
self._copied_source_to_uri[cache_key] = rel_uri
self.report["copied_assets"].append(
{
"source": source_path,
"target": rel_uri,
"type": "texture",
}
)
return rel_uri
def _extract_material_override(self, node) -> Dict[str, Any]:
base_color = [1.0, 1.0, 1.0, 1.0]
roughness = 0.5
metallic = 0.0
material = None
try:
material = node.getMaterial()
except Exception:
material = None
if material:
try:
if material.hasBaseColor():
c = material.getBaseColor()
base_color = [float(c[0]), float(c[1]), float(c[2]), float(c[3])]
except Exception:
pass
try:
roughness = float(material.getRoughness())
except Exception:
pass
try:
metallic = float(material.getMetallic())
except Exception:
pass
else:
try:
c = node.getColor()
base_color = [float(c[0]), float(c[1]), float(c[2]), float(c[3])]
except Exception:
pass
opacity = max(0.0, min(1.0, float(base_color[3])))
if opacity >= 0.999:
opacity = 1.0
elif opacity <= 0.001:
opacity = 0.0
return {
"base_color": base_color,
"roughness": roughness,
"metallic": metallic,
"opacity": opacity,
}
def _get_parent_and_matrix(self, node) -> Tuple[Optional[str], Any]:
render = getattr(self.world, "render", None)
parent_id = None
try:
parent = node.getParent()
except Exception:
parent = None
if parent and not parent.isEmpty() and id(parent) in self._node_id_by_pointer:
parent_id = self._node_id_by_pointer[id(parent)]
try:
return parent_id, node.getMat(parent)
except Exception:
pass
if render:
try:
return None, node.getMat(render)
except Exception:
pass
try:
return None, node.getMat()
except Exception:
return None, node.getTransform().getMat()
@staticmethod
def _mat4_to_row_major_list(mat4_obj) -> List[float]:
try:
values = []
for r in range(4):
for c in range(4):
values.append(float(mat4_obj.getCell(r, c)))
return values
except Exception:
return [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
]
@staticmethod
def _safe_float(value: Any, default: float) -> float:
try:
return float(value)
except Exception:
return float(default)
def _unique_filename(self, stem: str, suffix: str) -> str:
safe_stem = self._sanitize_filename(stem) or "asset"
key = f"{safe_stem}{suffix.lower()}"
index = self._name_counter.get(key, 0)
self._name_counter[key] = index + 1
if index == 0:
return f"{safe_stem}{suffix}"
return f"{safe_stem}_{index:03d}{suffix}"
@staticmethod
def _sanitize_filename(name: str) -> str:
normalized = re.sub(r"[^A-Za-z0-9._-]+", "_", str(name or "")).strip("._")
return normalized or "asset"
def _write_preview_scripts(self) -> None:
sh_path = os.path.join(self._output_root, "run_preview.sh")
bat_path = os.path.join(self._output_root, "run_preview.bat")
sh_content = "#!/usr/bin/env bash\npython3 -m http.server 8000\n"
bat_content = "@echo off\npython -m http.server 8000\n"
with open(sh_path, "w", encoding="utf-8") as f:
f.write(sh_content)
with open(bat_path, "w", encoding="utf-8") as f:
f.write(bat_content)
current_mode = os.stat(sh_path).st_mode
os.chmod(sh_path, current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@staticmethod
def _write_json(path: str, payload: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def _fail(self, message: str) -> None:
self.report["status"] = "failed"
self.report["warnings"].append(message)