chore: snapshot workflow migration progress

This commit is contained in:
2026-04-12 11:49:04 +02:00
parent 0cd02513d5
commit 3e810c74a3
163 changed files with 31774 additions and 2753 deletions
+298 -55
View File
@@ -4,6 +4,7 @@ Used by the render-worker Celery container (which has BLENDER_BIN set and
cadquery installed). The backend and standard workers fall back to the Pillow
placeholder when this service is unavailable.
"""
import hashlib
import json
import logging
import os
@@ -12,16 +13,175 @@ import signal
import subprocess
from pathlib import Path
from app.core.render_paths import ensure_group_writable_dir
logger = logging.getLogger(__name__)
def _glb_from_step(step_path: Path, glb_path: Path, tessellation_engine: str = "occ") -> None:
def resolve_tessellation_settings(
profile: str = "render",
tessellation_engine: str | None = None,
) -> tuple[float, float, str]:
"""Resolve tessellation settings from system settings for a given profile."""
profile_key = "scene" if profile == "scene" else "render"
defaults = {
"scene": (0.1, 0.1),
"render": (0.03, 0.05),
}
default_linear, default_angular = defaults[profile_key]
try:
from app.services.step_processor import _get_all_settings
settings = _get_all_settings()
linear_deflection = float(
settings.get(f"{profile_key}_linear_deflection", str(default_linear))
)
angular_deflection = float(
settings.get(f"{profile_key}_angular_deflection", str(default_angular))
)
effective_engine = (
tessellation_engine
or settings.get("tessellation_engine", "occ")
or "occ"
)
return linear_deflection, angular_deflection, effective_engine
except Exception as exc:
logger.warning(
"Could not resolve %s tessellation settings: %s; using defaults",
profile_key,
exc,
)
return default_linear, default_angular, tessellation_engine or "occ"
def build_tessellated_glb_path(
step_path: Path,
profile: str,
tessellation_engine: str,
linear_deflection: float,
angular_deflection: float,
) -> Path:
"""Build a settings-sensitive GLB path to avoid stale mesh reuse."""
signature = hashlib.sha1(
f"{profile}:{tessellation_engine}:{linear_deflection:.6f}:{angular_deflection:.6f}".encode(
"utf-8"
)
).hexdigest()[:10]
return step_path.parent / f"{step_path.stem}_{profile}_{signature}.glb"
def _stringify_optional_arg(value: object) -> str:
if value in (None, ""):
return ""
return str(value)
def _resolve_render_samples(engine: str, samples: int | None) -> int:
if samples is not None:
return int(samples)
effective_engine = (engine or "cycles").lower()
setting_key = (
"blender_eevee_samples"
if effective_engine == "eevee"
else "blender_cycles_samples"
)
try:
from app.services.step_processor import _get_all_settings
settings = _get_all_settings()
return int(settings[setting_key])
except Exception as exc:
logger.warning(
"Could not resolve Blender samples from settings for engine=%s: %s; "
"using legacy fallback",
effective_engine,
exc,
)
return 64 if effective_engine == "eevee" else 256
def build_turntable_ffmpeg_cmd(
frames_dir: Path,
output_path: Path,
*,
fps: int = 30,
bg_color: str = "",
width: int = 1920,
height: int = 1080,
ffmpeg_bin: str | None = None,
) -> list[str]:
"""Build the canonical FFmpeg command for turntable MP4 composition.
Legacy and graph/shadow paths must share this logic so template-backed
turntable outputs do not drift due to encoding differences.
"""
ffmpeg = ffmpeg_bin or shutil.which("ffmpeg") or "ffmpeg"
if any(frames_dir.glob("frame_*.png")):
frame_pattern = str(frames_dir / "frame_%04d.png")
else:
frame_pattern = str(frames_dir / "%04d.png")
if bg_color:
hex_color = bg_color.lstrip("#") or "ffffff"
return [
ffmpeg,
"-y",
"-framerate",
str(fps),
"-i",
frame_pattern,
"-f",
"lavfi",
"-i",
f"color=c=0x{hex_color}:size={width}x{height}:rate={fps}",
"-filter_complex",
"[1:v][0:v]overlay=0:0:shortest=1",
"-vcodec",
"libx264",
"-pix_fmt",
"yuv420p",
"-crf",
"18",
"-movflags",
"+faststart",
str(output_path),
]
return [
ffmpeg,
"-y",
"-framerate",
str(fps),
"-i",
frame_pattern,
"-vcodec",
"libx264",
"-pix_fmt",
"yuv420p",
"-crf",
"18",
"-movflags",
"+faststart",
str(output_path),
]
def _glb_from_step(
step_path: Path,
glb_path: Path,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
) -> None:
"""Convert STEP → GLB via OCC or GMSH (export_step_to_gltf.py, no Blender needed)."""
import subprocess
import sys as _sys
linear_deflection = 0.3
angular_deflection = 0.5
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
script_path = scripts_dir / "export_step_to_gltf.py"
@@ -32,7 +192,7 @@ def _glb_from_step(step_path: Path, glb_path: Path, tessellation_engine: str = "
"--output_path", str(glb_path),
"--linear_deflection", str(linear_deflection),
"--angular_deflection", str(angular_deflection),
"--tessellation_engine", tessellation_engine,
"--tessellation_engine", effective_engine,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
for line in result.stdout.splitlines():
@@ -44,7 +204,15 @@ def _glb_from_step(step_path: Path, glb_path: Path, tessellation_engine: str = "
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
f"STDERR: {result.stderr[-1000:]}"
)
logger.info("GLB converted: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
logger.info(
"GLB converted: %s (%d KB) with %s tessellation linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
def find_blender() -> str:
@@ -67,9 +235,9 @@ def render_still(
width: int = 512,
height: int = 512,
engine: str = "cycles",
samples: int = 256,
samples: int | None = None,
smooth_angle: int = 30,
cycles_device: str = "auto",
cycles_device: str = "gpu",
transparent_bg: bool = False,
part_colors: dict | None = None,
template_path: str | None = None,
@@ -92,9 +260,12 @@ def render_still(
log_callback: "Callable[[str], None] | None" = None,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
template_inputs: dict | None = None,
**ignored_control_kwargs,
) -> dict:
"""Convert STEP → GLB (OCC or GMSH) → PNG (Blender subprocess).
@@ -120,8 +291,18 @@ def render_still(
t0 = time.monotonic()
if ignored_control_kwargs:
logger.debug(
"render_still ignoring unsupported control kwargs: %s",
sorted(ignored_control_kwargs.keys()),
)
if isinstance(usd_path, str) and usd_path.strip():
usd_path = Path(usd_path)
actual_samples = _resolve_render_samples(engine, samples)
# 1. GLB conversion (OCC) — skipped when usd_path is provided
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
use_usd = bool(usd_path and usd_path.exists())
t_glb = time.monotonic()
@@ -129,15 +310,39 @@ def render_still(
logger.info("[render_blender] using USD path: %s", usd_path)
glb_size_bytes = 0
else:
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
glb_path = build_tessellated_glb_path(
step_path,
tessellation_profile,
effective_engine,
linear_deflection,
angular_deflection,
)
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(step_path, glb_path, tessellation_engine)
_glb_from_step(
step_path,
glb_path,
tessellation_engine=effective_engine,
tessellation_profile=tessellation_profile,
)
else:
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
logger.info(
"GLB local hit: %s (%d KB) profile=%s linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
glb_size_bytes = glb_path.stat().st_size if glb_path.exists() else 0
glb_duration_s = round(time.monotonic() - t_glb, 2)
# 2. Blender render
output_path.parent.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(output_path.parent)
env = dict(os.environ)
if engine == "eevee":
@@ -149,6 +354,7 @@ def render_still(
})
else:
env["EGL_PLATFORM"] = "surfaceless"
env["BLENDER_DEFAULT_SAMPLES"] = str(actual_samples)
def _build_cmd(eng: str) -> list:
# Pass "" as glb_path when using USD — blender_render.py reads --usd-path instead
@@ -161,7 +367,7 @@ def render_still(
glb_arg,
str(output_path),
str(width), str(height),
eng, str(samples), str(smooth_angle),
eng, str(actual_samples), str(smooth_angle),
cycles_device,
"1" if transparent_bg else "0",
template_path or "",
@@ -172,9 +378,9 @@ def render_still(
"1" if lighting_only else "0",
"1" if shadow_catcher else "0",
str(rotation_x), str(rotation_y), str(rotation_z),
noise_threshold or "", denoiser or "",
denoising_input_passes or "", denoising_prefilter or "",
denoising_quality or "", denoising_use_gpu or "",
_stringify_optional_arg(noise_threshold), _stringify_optional_arg(denoiser),
_stringify_optional_arg(denoising_input_passes), _stringify_optional_arg(denoising_prefilter),
_stringify_optional_arg(denoising_quality), _stringify_optional_arg(denoising_use_gpu),
]
if use_usd:
cmd += ["--usd-path", str(usd_path)]
@@ -188,6 +394,8 @@ def render_still(
cmd += ["--sensor-width", str(sensor_width_mm)]
if material_override:
cmd += ["--material-override", material_override]
if template_inputs:
cmd += ["--template-inputs", json.dumps(template_inputs)]
return cmd
def _run(eng: str) -> tuple[int, list[str], list[str]]:
@@ -305,7 +513,7 @@ def render_turntable_to_file(
engine: str = "cycles",
samples: int = 128,
smooth_angle: int = 30,
cycles_device: str = "auto",
cycles_device: str = "gpu",
transparent_bg: bool = False,
bg_color: str = "",
turntable_axis: str = "world_z",
@@ -323,9 +531,11 @@ def render_turntable_to_file(
camera_orbit: bool = True,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
template_inputs: dict | None = None,
) -> dict:
"""Render a turntable animation: STEP → STL → N frames (Blender) → mp4 (ffmpeg).
@@ -357,25 +567,48 @@ def render_turntable_to_file(
t0 = time.monotonic()
# 1. GLB conversion (OCC) — skipped when usd_path is provided
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
use_usd = bool(usd_path and usd_path.exists())
t_glb = time.monotonic()
if use_usd:
logger.info("[render_blender] turntable using USD path: %s", usd_path)
else:
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
glb_path = build_tessellated_glb_path(
step_path,
tessellation_profile,
effective_engine,
linear_deflection,
angular_deflection,
)
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(step_path, glb_path, tessellation_engine)
_glb_from_step(
step_path,
glb_path,
tessellation_engine=effective_engine,
tessellation_profile=tessellation_profile,
)
else:
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
logger.info(
"GLB local hit: %s (%d KB) profile=%s linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
glb_duration_s = round(time.monotonic() - t_glb, 2)
# 2. Render frames with Blender
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
if frames_dir.exists():
_shutil.rmtree(frames_dir, ignore_errors=True)
frames_dir.mkdir(parents=True, exist_ok=True)
output_path.parent.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(frames_dir)
ensure_group_writable_dir(output_path.parent)
env = dict(os.environ)
env["EGL_PLATFORM"] = "surfaceless"
@@ -416,6 +649,8 @@ def render_turntable_to_file(
cmd += ["--sensor-width", str(sensor_width_mm)]
if material_override:
cmd += ["--material-override", material_override]
if template_inputs:
cmd += ["--template-inputs", json.dumps(template_inputs)]
log_lines: list[str] = []
@@ -458,34 +693,15 @@ def render_turntable_to_file(
# 3. Compose frames → mp4 with ffmpeg
t_ffmpeg = time.monotonic()
ffmpeg_cmd = [
ffmpeg_bin,
"-y",
"-framerate", str(fps),
"-i", str(frames_dir / "frame_%04d.png"),
"-vcodec", "libx264",
"-pix_fmt", "yuv420p",
"-crf", "18",
"-movflags", "+faststart",
str(output_path),
]
# If bg_color is set and transparent_bg is True, overlay frames on solid bg
if bg_color and transparent_bg:
hex_color = bg_color.lstrip("#")
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
ffmpeg_cmd = [
ffmpeg_bin, "-y",
"-framerate", str(fps),
"-i", str(frames_dir / "frame_%04d.png"),
"-f", "lavfi", "-i", f"color=c=0x{hex_color}:size={width}x{height}:rate={fps}",
"-filter_complex", "[1:v][0:v]overlay=0:0:shortest=1",
"-vcodec", "libx264",
"-pix_fmt", "yuv420p",
"-crf", "18",
"-movflags", "+faststart",
str(output_path),
]
ffmpeg_cmd = build_turntable_ffmpeg_cmd(
frames_dir,
output_path,
fps=fps,
bg_color=bg_color if transparent_bg else "",
width=width,
height=height,
ffmpeg_bin=ffmpeg_bin,
)
ffmpeg_proc = subprocess.run(
ffmpeg_cmd, capture_output=True, text=True, timeout=300
@@ -530,7 +746,7 @@ def render_cinematic_to_file(
engine: str = "cycles",
samples: int = 128,
smooth_angle: int = 30,
cycles_device: str = "auto",
cycles_device: str = "gpu",
transparent_bg: bool = False,
part_colors: dict | None = None,
template_path: str | None = None,
@@ -545,9 +761,11 @@ def render_cinematic_to_file(
rotation_z: float = 0.0,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
template_inputs: dict | None = None,
log_callback: "Callable[[str], None] | None" = None,
) -> dict:
"""Render a cinematic highlight animation: STEP -> GLB/USD -> 480 frames @ 24fps (Blender) -> mp4 (ffmpeg).
@@ -587,25 +805,48 @@ def render_cinematic_to_file(
t0 = time.monotonic()
# 1. GLB conversion (OCC) — skipped when usd_path is provided
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
use_usd = bool(usd_path and usd_path.exists())
t_glb = time.monotonic()
if use_usd:
logger.info("[render_blender] cinematic using USD path: %s", usd_path)
else:
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
glb_path = build_tessellated_glb_path(
step_path,
tessellation_profile,
effective_engine,
linear_deflection,
angular_deflection,
)
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(step_path, glb_path, tessellation_engine)
_glb_from_step(
step_path,
glb_path,
tessellation_engine=effective_engine,
tessellation_profile=tessellation_profile,
)
else:
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
logger.info(
"GLB local hit: %s (%d KB) profile=%s linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
glb_duration_s = round(time.monotonic() - t_glb, 2)
# 2. Render frames with Blender
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
if frames_dir.exists():
_shutil.rmtree(frames_dir, ignore_errors=True)
frames_dir.mkdir(parents=True, exist_ok=True)
output_path.parent.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(frames_dir)
ensure_group_writable_dir(output_path.parent)
env = dict(os.environ)
env["EGL_PLATFORM"] = "surfaceless"
@@ -645,6 +886,8 @@ def render_cinematic_to_file(
cmd += ["--sensor-width", str(sensor_width_mm)]
if material_override:
cmd += ["--material-override", material_override]
if template_inputs:
cmd += ["--template-inputs", json.dumps(template_inputs)]
log_lines: list[str] = []