chore: snapshot workflow migration progress
This commit is contained in:
@@ -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] = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user