825 lines
31 KiB
Python
825 lines
31 KiB
Python
"""Rendering domain tasks — Celery tasks for Blender-based rendering.
|
|
|
|
These tasks run on the `asset_pipeline` queue in the render-worker
|
|
container, which has Blender and cadquery available.
|
|
|
|
Phase A2: Initial implementation replacing the blender-renderer HTTP service.
|
|
Phase B: This module will be expanded as part of the Domain-Driven restructure.
|
|
"""
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from app.tasks.celery_app import celery_app
|
|
from app.core.task_logs import log_task_event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _update_workflow_run_status(
|
|
order_line_id: str,
|
|
status: str,
|
|
error: str | None = None,
|
|
*,
|
|
workflow_run_id: str | None = None,
|
|
workflow_node_id: str | None = None,
|
|
) -> None:
|
|
"""Update WorkflowRun / WorkflowNodeResult state after task completion."""
|
|
try:
|
|
import asyncio
|
|
import uuid
|
|
from datetime import datetime as _dt
|
|
|
|
async def _run():
|
|
from app.database import AsyncSessionLocal
|
|
from app.domains.rendering.models import WorkflowNodeResult, WorkflowRun
|
|
from sqlalchemy import select as _sel
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
run = None
|
|
if workflow_run_id:
|
|
try:
|
|
resolved_run_id = uuid.UUID(str(workflow_run_id))
|
|
except (TypeError, ValueError):
|
|
resolved_run_id = workflow_run_id
|
|
run_res = await db.execute(
|
|
_sel(WorkflowRun).where(WorkflowRun.id == resolved_run_id)
|
|
)
|
|
run = run_res.scalar_one_or_none()
|
|
else:
|
|
res = await db.execute(
|
|
_sel(WorkflowRun)
|
|
.where(WorkflowRun.order_line_id == order_line_id)
|
|
.order_by(WorkflowRun.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
run = res.scalar_one_or_none()
|
|
|
|
if run is None:
|
|
return
|
|
|
|
if workflow_node_id:
|
|
node_res = await db.execute(
|
|
_sel(WorkflowNodeResult).where(
|
|
WorkflowNodeResult.run_id == run.id,
|
|
WorkflowNodeResult.node_name == workflow_node_id,
|
|
)
|
|
)
|
|
node_result = node_res.scalar_one_or_none()
|
|
if node_result is not None:
|
|
metadata = dict(node_result.output or {})
|
|
if error:
|
|
metadata["last_error"] = error[:2000]
|
|
node_result.status = status
|
|
node_result.log = error[:2000] if error else None
|
|
node_result.output = metadata
|
|
|
|
node_results_res = await db.execute(
|
|
_sel(WorkflowNodeResult).where(WorkflowNodeResult.run_id == run.id)
|
|
)
|
|
node_results = list(node_results_res.scalars().all())
|
|
|
|
if any(node.status == "failed" for node in node_results):
|
|
run.status = "failed"
|
|
run.completed_at = _dt.utcnow()
|
|
if error:
|
|
run.error_message = error[:2000]
|
|
elif any(node.status in {"pending", "queued", "running", "retrying"} for node in node_results):
|
|
run.status = "pending"
|
|
run.completed_at = None
|
|
if status != "failed":
|
|
run.error_message = None
|
|
else:
|
|
run.status = status
|
|
run.completed_at = _dt.utcnow()
|
|
if status != "failed":
|
|
run.error_message = None
|
|
|
|
await db.commit()
|
|
|
|
asyncio.get_event_loop().run_until_complete(_run())
|
|
except Exception as _exc:
|
|
logger.warning("Failed to update WorkflowRun status for line %s: %s", order_line_id, _exc)
|
|
|
|
|
|
@celery_app.task(
|
|
bind=True,
|
|
name="app.domains.rendering.tasks.render_still_task",
|
|
queue="asset_pipeline",
|
|
max_retries=2,
|
|
)
|
|
def render_still_task(
|
|
self,
|
|
step_path: str,
|
|
output_path: str,
|
|
engine: str = "cycles",
|
|
samples: int = 256,
|
|
smooth_angle: int = 30,
|
|
cycles_device: str = "auto",
|
|
width: int = 512,
|
|
height: int = 512,
|
|
transparent_bg: bool = False,
|
|
template_path: str | None = None,
|
|
target_collection: str = "Product",
|
|
material_library_path: str | None = None,
|
|
material_map: dict | None = None,
|
|
part_names_ordered: list | None = None,
|
|
lighting_only: bool = False,
|
|
shadow_catcher: bool = False,
|
|
rotation_x: float = 0.0,
|
|
rotation_y: float = 0.0,
|
|
rotation_z: float = 0.0,
|
|
noise_threshold: str = "",
|
|
denoiser: str = "",
|
|
denoising_input_passes: str = "",
|
|
denoising_prefilter: str = "",
|
|
denoising_quality: str = "",
|
|
denoising_use_gpu: str = "",
|
|
mesh_attributes: dict | None = None,
|
|
) -> dict:
|
|
"""Render a STEP file to a still PNG via Blender subprocess.
|
|
|
|
Returns render metadata dict on success.
|
|
Retries up to 2 times on failure (30s countdown).
|
|
"""
|
|
log_task_event(self.request.id, f"Starting render_still_task: {Path(step_path).name}", "info")
|
|
try:
|
|
from app.services.render_blender import render_still
|
|
result = render_still(
|
|
step_path=Path(step_path),
|
|
output_path=Path(output_path),
|
|
engine=engine,
|
|
samples=samples,
|
|
smooth_angle=smooth_angle,
|
|
cycles_device=cycles_device,
|
|
width=width,
|
|
height=height,
|
|
transparent_bg=transparent_bg,
|
|
template_path=template_path,
|
|
target_collection=target_collection,
|
|
material_library_path=material_library_path,
|
|
material_map=material_map,
|
|
part_names_ordered=part_names_ordered,
|
|
lighting_only=lighting_only,
|
|
shadow_catcher=shadow_catcher,
|
|
rotation_x=rotation_x,
|
|
rotation_y=rotation_y,
|
|
rotation_z=rotation_z,
|
|
noise_threshold=noise_threshold,
|
|
denoiser=denoiser,
|
|
denoising_input_passes=denoising_input_passes,
|
|
denoising_prefilter=denoising_prefilter,
|
|
denoising_quality=denoising_quality,
|
|
denoising_use_gpu=denoising_use_gpu,
|
|
mesh_attributes=mesh_attributes or {},
|
|
)
|
|
log_task_event(self.request.id, f"Completed successfully in {result.get('total_duration_s', 0):.1f}s", "done")
|
|
logger.info(
|
|
"render_still_task completed: %s → %s in %.1fs",
|
|
Path(step_path).name, Path(output_path).name,
|
|
result.get("total_duration_s", 0),
|
|
)
|
|
try:
|
|
from app.core.websocket import publish_event_sync
|
|
publish_event_sync(None, {
|
|
"type": "render.still.completed",
|
|
"step_path": Path(step_path).name,
|
|
"output": Path(output_path).name,
|
|
})
|
|
except Exception:
|
|
pass
|
|
return result
|
|
except Exception as exc:
|
|
log_task_event(self.request.id, f"Failed: {exc}", "error")
|
|
logger.error("render_still_task failed for %s: %s", step_path, exc)
|
|
try:
|
|
from app.core.websocket import publish_event_sync
|
|
publish_event_sync(None, {
|
|
"type": "render.still.failed",
|
|
"step_path": Path(step_path).name,
|
|
"error": str(exc),
|
|
})
|
|
except Exception:
|
|
pass
|
|
raise self.retry(exc=exc, countdown=30)
|
|
|
|
|
|
@celery_app.task(
|
|
bind=True,
|
|
name="app.domains.rendering.tasks.render_turntable_task",
|
|
queue="asset_pipeline",
|
|
max_retries=2,
|
|
)
|
|
def render_turntable_task(
|
|
self,
|
|
step_path: str,
|
|
output_dir: str,
|
|
output_name: str = "turntable",
|
|
engine: str = "cycles",
|
|
samples: int = 64,
|
|
smooth_angle: int = 30,
|
|
cycles_device: str = "auto",
|
|
width: int = 1920,
|
|
height: int = 1080,
|
|
frame_count: int = 120,
|
|
fps: int = 30,
|
|
turntable_degrees: float = 360.0,
|
|
turntable_axis: str = "world_z",
|
|
bg_color: str = "",
|
|
template_path: str | None = None,
|
|
target_collection: str = "Product",
|
|
material_library_path: str | None = None,
|
|
material_map: dict | None = None,
|
|
part_names_ordered: list | None = None,
|
|
lighting_only: bool = False,
|
|
shadow_catcher: bool = False,
|
|
camera_orbit: bool = True,
|
|
rotation_x: float = 0.0,
|
|
rotation_y: float = 0.0,
|
|
rotation_z: float = 0.0,
|
|
) -> dict:
|
|
"""Render a STEP file as a turntable animation (frames + FFmpeg composite).
|
|
|
|
Returns render metadata dict on success.
|
|
"""
|
|
log_task_event(self.request.id, f"Starting render_turntable_task: {Path(step_path).name}", "info")
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from app.services.render_blender import find_blender
|
|
|
|
blender_bin = find_blender()
|
|
if not blender_bin:
|
|
raise RuntimeError("Blender binary not found in render-worker container")
|
|
|
|
step = Path(step_path)
|
|
out_dir = Path(output_dir)
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
|
turntable_script = scripts_dir / "turntable_render.py"
|
|
|
|
# GLB generation via OCC — deflection from admin settings (scene_linear/angular_deflection)
|
|
from app.config import settings as app_settings
|
|
from sqlalchemy import create_engine as _create_engine, text as _text
|
|
from sqlalchemy.orm import Session as _Session
|
|
_db_engine = _create_engine(app_settings.database_url_sync)
|
|
with _Session(_db_engine) as _s:
|
|
_rows = _s.execute(_text("SELECT key, value FROM system_settings")).fetchall()
|
|
_sett = {r[0]: r[1] for r in _rows}
|
|
_db_engine.dispose()
|
|
linear_deflection = float(_sett.get("scene_linear_deflection", "0.1"))
|
|
angular_deflection = float(_sett.get("scene_angular_deflection", "0.1"))
|
|
glb_path = step.parent / f"{step.stem}_thumbnail.glb"
|
|
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
|
occ_script = scripts_dir / "export_step_to_gltf.py"
|
|
occ_cmd = [
|
|
sys.executable, str(occ_script),
|
|
"--step_path", str(step),
|
|
"--output_path", str(glb_path),
|
|
"--linear_deflection", str(linear_deflection),
|
|
"--angular_deflection", str(angular_deflection),
|
|
]
|
|
occ_result = subprocess.run(occ_cmd, capture_output=True, text=True, timeout=120)
|
|
if occ_result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"export_step_to_gltf.py failed:\n{occ_result.stderr[-500:]}"
|
|
)
|
|
logger.info("render_turntable_task: GLB generated: %s", glb_path.name)
|
|
|
|
# Build turntable render arguments
|
|
frames_dir = out_dir / "frames"
|
|
frames_dir.mkdir(exist_ok=True)
|
|
|
|
cmd = [
|
|
blender_bin, "--background",
|
|
"--python", str(turntable_script),
|
|
"--",
|
|
str(glb_path),
|
|
str(frames_dir),
|
|
output_name,
|
|
str(width), str(height),
|
|
engine, str(samples), str(smooth_angle), cycles_device,
|
|
str(frame_count), str(fps), str(turntable_degrees), turntable_axis,
|
|
template_path or "",
|
|
target_collection,
|
|
material_library_path or "",
|
|
json.dumps(material_map) if material_map else "{}",
|
|
json.dumps(part_names_ordered) if part_names_ordered else "[]",
|
|
"1" if lighting_only else "0",
|
|
"1" if shadow_catcher else "0",
|
|
"1" if camera_orbit else "0",
|
|
str(rotation_x), str(rotation_y), str(rotation_z),
|
|
]
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=3600
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"Blender turntable exited {result.returncode}:\n{result.stdout[-2000:]}"
|
|
)
|
|
except Exception as exc:
|
|
log_task_event(self.request.id, f"Failed: {exc}", "error")
|
|
logger.error("render_turntable_task failed: %s", exc)
|
|
try:
|
|
from app.core.websocket import publish_event_sync
|
|
publish_event_sync(None, {
|
|
"type": "render.turntable.failed",
|
|
"step_path": Path(step_path).name,
|
|
"error": str(exc),
|
|
})
|
|
except Exception:
|
|
pass
|
|
raise self.retry(exc=exc, countdown=60)
|
|
|
|
# FFmpeg composite: frames → MP4 with optional background
|
|
output_mp4 = out_dir / f"{output_name}.mp4"
|
|
ffmpeg_cmd = _build_ffmpeg_cmd(
|
|
frames_dir, output_mp4, fps=fps, bg_color=bg_color
|
|
)
|
|
try:
|
|
subprocess.run(ffmpeg_cmd, check=True, capture_output=True, text=True, timeout=300)
|
|
except subprocess.CalledProcessError as exc:
|
|
raise RuntimeError(f"FFmpeg composite failed: {exc.stderr[-500:]}")
|
|
|
|
log_task_event(self.request.id, "Completed successfully", "done")
|
|
try:
|
|
from app.core.websocket import publish_event_sync
|
|
publish_event_sync(None, {
|
|
"type": "render.turntable.completed",
|
|
"step_path": Path(step_path).name,
|
|
"output": Path(output_mp4).name,
|
|
})
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"output_mp4": str(output_mp4),
|
|
"frame_count": frame_count,
|
|
"fps": fps,
|
|
}
|
|
|
|
|
|
@celery_app.task(
|
|
name="rendering.publish_asset",
|
|
queue="step_processing",
|
|
)
|
|
def publish_asset(
|
|
order_line_id: str,
|
|
asset_type: str,
|
|
storage_key: str,
|
|
render_config: dict | None = None,
|
|
workflow_run_id: str | None = None,
|
|
) -> str | None:
|
|
"""Create a MediaAsset record after a successful render."""
|
|
import asyncio
|
|
|
|
async def _run() -> str | None:
|
|
from app.database import AsyncSessionLocal
|
|
from app.domains.media.models import MediaAsset, MediaAssetType
|
|
from app.domains.orders.models import OrderLine
|
|
from app.domains.products.models import Product
|
|
from sqlalchemy import select
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
res = await db.execute(select(OrderLine).where(OrderLine.id == order_line_id))
|
|
line = res.scalar_one_or_none()
|
|
if not line:
|
|
return None
|
|
|
|
# Resolve cad_file_id from the linked product
|
|
cad_file_id = None
|
|
if line.product_id:
|
|
prod_res = await db.execute(select(Product).where(Product.id == line.product_id))
|
|
product = prod_res.scalar_one_or_none()
|
|
if product:
|
|
cad_file_id = product.cad_file_id
|
|
|
|
asset = MediaAsset(
|
|
tenant_id=getattr(line, "tenant_id", None),
|
|
order_line_id=line.id,
|
|
product_id=line.product_id,
|
|
cad_file_id=cad_file_id,
|
|
workflow_run_id=workflow_run_id,
|
|
asset_type=MediaAssetType(asset_type),
|
|
storage_key=storage_key,
|
|
render_config=render_config,
|
|
)
|
|
db.add(asset)
|
|
await db.commit()
|
|
return str(asset.id)
|
|
|
|
return asyncio.get_event_loop().run_until_complete(_run())
|
|
|
|
|
|
def _resolve_step_path_for_order_line(order_line_id: str) -> tuple[str | None, str | None]:
|
|
"""Sync helper: resolves (step_path, cad_file_id) from an OrderLine via DB."""
|
|
import asyncio
|
|
|
|
async def _inner() -> tuple[str | None, str | None]:
|
|
from app.database import AsyncSessionLocal
|
|
from app.domains.orders.models import OrderLine
|
|
from app.domains.products.models import Product
|
|
from app.models.cad_file import CadFile
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
res = await db.execute(
|
|
select(OrderLine)
|
|
.options(selectinload(OrderLine.product))
|
|
.where(OrderLine.id == order_line_id)
|
|
)
|
|
line = res.scalar_one_or_none()
|
|
if not line or not line.product or not line.product.cad_file_id:
|
|
return None, None
|
|
cad_res = await db.execute(
|
|
select(CadFile).where(CadFile.id == line.product.cad_file_id)
|
|
)
|
|
cad = cad_res.scalar_one_or_none()
|
|
if not cad or not cad.stored_path:
|
|
return None, None
|
|
return cad.stored_path, str(line.product.cad_file_id)
|
|
|
|
return asyncio.get_event_loop().run_until_complete(_inner())
|
|
|
|
|
|
@celery_app.task(
|
|
bind=True,
|
|
name="app.domains.rendering.tasks.render_order_line_still_task",
|
|
queue="asset_pipeline",
|
|
max_retries=2,
|
|
)
|
|
def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
|
|
"""Render a still image for an order line, resolving STEP path from DB.
|
|
|
|
Wraps render_still_task logic but accepts order_line_id instead of step_path.
|
|
On success, creates a MediaAsset record via publish_asset.
|
|
"""
|
|
import asyncio
|
|
from app.domains.rendering.job_document import RenderJobDocument, JobState
|
|
from app.core.process_steps import StepName
|
|
|
|
workflow_run_id = params.pop("workflow_run_id", None)
|
|
workflow_node_id = params.pop("workflow_node_id", None)
|
|
publish_asset_enabled = bool(params.pop("publish_asset_enabled", True))
|
|
emit_events = bool(params.pop("emit_events", True))
|
|
job_document_enabled = bool(params.pop("job_document_enabled", True))
|
|
output_name_suffix = params.pop("output_name_suffix", None)
|
|
|
|
log_task_event(self.request.id, f"Starting render_order_line_still_task: order_line={order_line_id}", "info")
|
|
|
|
# Initialise job document and store real Celery task ID
|
|
job_doc = RenderJobDocument.new(order_line_id=order_line_id, celery_task_id=self.request.id)
|
|
job_doc.set_state(JobState.RUNNING)
|
|
|
|
def _save_job_doc():
|
|
async def _run():
|
|
from app.database import AsyncSessionLocal
|
|
from app.domains.orders.models import OrderLine
|
|
from sqlalchemy import update as _upd
|
|
async with AsyncSessionLocal() as db:
|
|
await db.execute(
|
|
_upd(OrderLine)
|
|
.where(OrderLine.id == order_line_id)
|
|
.values(render_job_doc=job_doc.to_dict())
|
|
)
|
|
await db.commit()
|
|
if not job_document_enabled:
|
|
return
|
|
try:
|
|
asyncio.get_event_loop().run_until_complete(_run())
|
|
except Exception as _exc:
|
|
logger.debug("_save_job_doc failed: %s", _exc)
|
|
|
|
_save_job_doc()
|
|
|
|
job_doc.begin_step(StepName.RESOLVE_STEP_PATH)
|
|
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
|
|
if not step_path_str:
|
|
job_doc.fail_step(StepName.RESOLVE_STEP_PATH, "product missing or has no linked CAD file")
|
|
job_doc.set_state(JobState.FAILED, error="Cannot resolve STEP path")
|
|
_save_job_doc()
|
|
log_task_event(self.request.id, f"Failed: cannot resolve STEP path for order_line {order_line_id}", "error")
|
|
raise RuntimeError(
|
|
f"Cannot resolve STEP path for order_line {order_line_id}: "
|
|
"product missing or has no linked CAD file"
|
|
)
|
|
job_doc.finish_step(StepName.RESOLVE_STEP_PATH, output={"step_path": step_path_str})
|
|
|
|
step = Path(step_path_str)
|
|
output_dir = step.parent / "renders"
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
output_filename = f"line_{order_line_id}.png"
|
|
if output_name_suffix:
|
|
output_filename = f"line_{order_line_id}_{output_name_suffix}.png"
|
|
output_path = output_dir / output_filename
|
|
|
|
try:
|
|
job_doc.begin_step(StepName.BLENDER_STILL)
|
|
from app.services.render_blender import render_still
|
|
result = render_still(
|
|
step_path=step,
|
|
output_path=output_path,
|
|
**params,
|
|
)
|
|
job_doc.finish_step(
|
|
StepName.BLENDER_STILL,
|
|
output={"output_path": str(output_path), "duration_s": result.get("total_duration_s")},
|
|
)
|
|
job_doc.set_state(JobState.COMPLETED, result={
|
|
"output_path": str(output_path),
|
|
"duration_s": result.get("total_duration_s"),
|
|
"engine_used": result.get("engine_used"),
|
|
})
|
|
_save_job_doc()
|
|
|
|
if publish_asset_enabled:
|
|
publish_asset.delay(
|
|
order_line_id,
|
|
"still",
|
|
str(output_path),
|
|
render_config=result,
|
|
workflow_run_id=workflow_run_id,
|
|
)
|
|
log_task_event(self.request.id, f"Completed successfully in {result.get('total_duration_s', 0):.1f}s", "done")
|
|
logger.info(
|
|
"render_order_line_still_task completed for line %s in %.1fs",
|
|
order_line_id, result.get("total_duration_s", 0),
|
|
)
|
|
try:
|
|
from app.core.websocket import publish_event_sync
|
|
if emit_events:
|
|
publish_event_sync(None, {
|
|
"type": "render.order_line.completed",
|
|
"order_line_id": order_line_id,
|
|
})
|
|
except Exception:
|
|
pass
|
|
_update_workflow_run_status(
|
|
order_line_id,
|
|
"completed",
|
|
workflow_run_id=workflow_run_id,
|
|
workflow_node_id=workflow_node_id,
|
|
)
|
|
return result
|
|
except Exception as exc:
|
|
job_doc.fail_step(StepName.BLENDER_STILL, str(exc))
|
|
job_doc.set_state(JobState.FAILED, error=str(exc))
|
|
_save_job_doc()
|
|
log_task_event(self.request.id, f"Failed: {exc}", "error")
|
|
logger.error("render_order_line_still_task failed for %s: %s", order_line_id, exc)
|
|
try:
|
|
from app.core.websocket import publish_event_sync
|
|
if emit_events:
|
|
publish_event_sync(None, {
|
|
"type": "render.order_line.failed",
|
|
"order_line_id": order_line_id,
|
|
"error": str(exc),
|
|
})
|
|
except Exception:
|
|
pass
|
|
_update_workflow_run_status(
|
|
order_line_id,
|
|
"failed",
|
|
str(exc),
|
|
workflow_run_id=workflow_run_id,
|
|
workflow_node_id=workflow_node_id,
|
|
)
|
|
raise self.retry(exc=exc, countdown=30)
|
|
|
|
|
|
@celery_app.task(
|
|
bind=True,
|
|
name="app.domains.rendering.tasks.export_blend_for_order_line_task",
|
|
queue="asset_pipeline",
|
|
max_retries=1,
|
|
)
|
|
def export_blend_for_order_line_task(
|
|
self,
|
|
order_line_id: str,
|
|
workflow_run_id: str | None = None,
|
|
workflow_node_id: str | None = None,
|
|
publish_asset_enabled: bool = True,
|
|
output_name_suffix: str | None = None,
|
|
**_kwargs,
|
|
) -> dict:
|
|
"""Export a production .blend file via Blender + asset library (export_blend.py).
|
|
|
|
Publishes a MediaAsset with asset_type='blend_production'.
|
|
Requires Blender + the render-scripts directory.
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
|
|
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
|
|
if not step_path_str:
|
|
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
|
|
|
|
step = Path(step_path_str)
|
|
# Use geometry GLB as input (generate if missing)
|
|
glb_path = step.parent / f"{step.stem}_geometry.glb"
|
|
if not glb_path.exists():
|
|
import subprocess as _sp
|
|
import sys as _sys
|
|
scripts_dir_tmp = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
|
occ_cmd = [
|
|
_sys.executable, str(scripts_dir_tmp / "export_step_to_gltf.py"),
|
|
"--step_path", str(step),
|
|
"--output_path", str(glb_path),
|
|
]
|
|
occ_res = _sp.run(occ_cmd, capture_output=True, text=True, timeout=120)
|
|
if occ_res.returncode != 0:
|
|
raise RuntimeError(f"GLB generation failed:\n{occ_res.stderr[-500:]}")
|
|
|
|
output_name = f"{step.stem}_production.blend"
|
|
if output_name_suffix:
|
|
output_name = f"{step.stem}_production_{output_name_suffix}.blend"
|
|
output_path = step.parent / output_name
|
|
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
|
export_script = scripts_dir / "export_blend.py"
|
|
|
|
from app.services.render_blender import find_blender
|
|
blender_bin = find_blender()
|
|
if not blender_bin:
|
|
raise RuntimeError("Blender binary not found — cannot run export_blend task")
|
|
|
|
# Resolve asset library path and material map from DB
|
|
asset_lib_path = ""
|
|
mat_map: dict = {}
|
|
try:
|
|
from sqlalchemy import create_engine, select as sql_select
|
|
from sqlalchemy.orm import Session
|
|
from app.config import settings as app_settings
|
|
from app.domains.orders.models import OrderLine
|
|
from app.domains.products.models import Product
|
|
|
|
engine = create_engine(app_settings.database_url_sync)
|
|
with Session(engine) as s:
|
|
line = s.execute(sql_select(OrderLine).where(OrderLine.id == order_line_id)).scalar_one_or_none()
|
|
if line:
|
|
product = s.execute(sql_select(Product).where(Product.id == line.product_id)).scalar_one_or_none()
|
|
if product:
|
|
mat_map = {
|
|
m.get("part_name", ""): m.get("material", "")
|
|
for m in (product.cad_part_materials or [])
|
|
}
|
|
except Exception as exc:
|
|
logger.warning("export_blend_for_order_line_task: DB resolution error (non-fatal): %s", exc)
|
|
|
|
try:
|
|
cmd = [
|
|
blender_bin, "--background",
|
|
"--python", str(export_script),
|
|
"--",
|
|
"--glb_path", str(glb_path),
|
|
"--output_path", str(output_path),
|
|
"--asset_library_blend", asset_lib_path,
|
|
"--material_map", json.dumps(mat_map),
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"export_blend.py exited {result.returncode}:\n{result.stderr[-500:]}"
|
|
)
|
|
if publish_asset_enabled:
|
|
publish_asset.delay(
|
|
order_line_id,
|
|
"blend_production",
|
|
str(output_path),
|
|
workflow_run_id=workflow_run_id,
|
|
)
|
|
logger.info("export_blend_for_order_line_task completed: %s", output_path.name)
|
|
_update_workflow_run_status(
|
|
order_line_id,
|
|
"completed",
|
|
workflow_run_id=workflow_run_id,
|
|
workflow_node_id=workflow_node_id,
|
|
)
|
|
return {"blend_path": str(output_path)}
|
|
except Exception as exc:
|
|
logger.error("export_blend_for_order_line_task failed for %s: %s", order_line_id, exc)
|
|
_update_workflow_run_status(
|
|
order_line_id,
|
|
"failed",
|
|
str(exc),
|
|
workflow_run_id=workflow_run_id,
|
|
workflow_node_id=workflow_node_id,
|
|
)
|
|
raise self.retry(exc=exc, countdown=30)
|
|
|
|
|
|
@celery_app.task(
|
|
bind=True,
|
|
name="app.domains.rendering.tasks.apply_asset_library_materials_task",
|
|
queue="asset_pipeline",
|
|
max_retries=1,
|
|
)
|
|
def apply_asset_library_materials_task(self, order_line_id: str, asset_library_id: str) -> dict:
|
|
"""Apply Blender asset library materials to a render via the asset_library.py script."""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from app.services.render_blender import find_blender
|
|
|
|
blender_bin = find_blender()
|
|
if not blender_bin:
|
|
raise RuntimeError("Blender not available")
|
|
|
|
# Resolve paths from DB
|
|
def _inner():
|
|
from sqlalchemy import create_engine, select as sql_select
|
|
from sqlalchemy.orm import Session
|
|
from app.config import settings
|
|
from app.domains.orders.models import OrderLine
|
|
from app.domains.products.models import CadFile, Product
|
|
|
|
engine = create_engine(settings.database_url_sync)
|
|
with Session(engine) as s:
|
|
line = s.execute(sql_select(OrderLine).where(OrderLine.id == order_line_id)).scalar_one_or_none()
|
|
if not line:
|
|
return None, None, None
|
|
product = s.execute(sql_select(Product).where(Product.id == line.product_id)).scalar_one_or_none()
|
|
if not product or not product.cad_file_id:
|
|
return None, None, None
|
|
cad = s.execute(sql_select(CadFile).where(CadFile.id == product.cad_file_id)).scalar_one_or_none()
|
|
glb_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_geometry.glb") if cad else None
|
|
|
|
# Resolve asset library blend path
|
|
try:
|
|
from app.domains.materials.models import AssetLibrary
|
|
lib = s.execute(sql_select(AssetLibrary).where(AssetLibrary.id == asset_library_id)).scalar_one_or_none()
|
|
blend_path = lib.blend_file_path if lib else None
|
|
except Exception:
|
|
blend_path = None
|
|
|
|
mat_map = {m.get("part_name", ""): m.get("material", "") for m in (product.cad_part_materials or [])}
|
|
return glb_path, blend_path, mat_map
|
|
|
|
result = _inner()
|
|
if result is None or result[0] is None:
|
|
logger.warning("apply_asset_library_materials_task: could not resolve paths for %s", order_line_id)
|
|
return {"status": "skipped"}
|
|
|
|
glb_path, blend_path, mat_map = result
|
|
if not glb_path or not Path(glb_path).exists():
|
|
logger.warning("Geometry GLB not found for %s", order_line_id)
|
|
return {"status": "skipped", "reason": "glb_not_found"}
|
|
|
|
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
|
script = scripts_dir / "asset_library.py"
|
|
|
|
cmd = [
|
|
blender_bin, "--background", "--python", str(script), "--",
|
|
"--glb_path", glb_path,
|
|
"--asset_library_blend", blend_path or "",
|
|
"--material_map", json.dumps(mat_map),
|
|
]
|
|
|
|
try:
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"asset_library.py failed: {proc.stderr[-500:]}")
|
|
except Exception as exc:
|
|
logger.error("apply_asset_library_materials_task failed for %s: %s", order_line_id, exc)
|
|
raise self.retry(exc=exc, countdown=15)
|
|
|
|
return {"status": "applied", "order_line_id": order_line_id}
|
|
|
|
|
|
def _build_ffmpeg_cmd(
|
|
frames_dir: Path, output_mp4: Path, fps: int = 30, bg_color: str = ""
|
|
) -> list:
|
|
"""Build FFmpeg command for compositing turntable frames to MP4."""
|
|
import shutil as _shutil
|
|
ffmpeg = _shutil.which("ffmpeg") or "ffmpeg"
|
|
frame_pattern = str(frames_dir / "%04d.png")
|
|
|
|
if bg_color:
|
|
# Overlay transparent frames onto solid color background
|
|
r = int(bg_color[1:3], 16) if bg_color.startswith("#") else 255
|
|
g = int(bg_color[3:5], 16) if bg_color.startswith("#") else 255
|
|
b = int(bg_color[5:7], 16) if bg_color.startswith("#") else 255
|
|
color_str = f"color=c=0x{r:02x}{g:02x}{b:02x}:s=1920x1080:r={fps}"
|
|
return [
|
|
ffmpeg, "-y",
|
|
"-f", "lavfi", "-i", color_str,
|
|
"-framerate", str(fps), "-i", frame_pattern,
|
|
"-filter_complex", "[0:v][1:v]overlay=0:0",
|
|
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
|
"-movflags", "+faststart",
|
|
str(output_mp4),
|
|
]
|
|
else:
|
|
return [
|
|
ffmpeg, "-y",
|
|
"-framerate", str(fps), "-i", frame_pattern,
|
|
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
|
"-movflags", "+faststart",
|
|
str(output_mp4),
|
|
]
|