Files
ECC/skills/taste-application/scripts/taste/render3d.py
T
928c1dea72 feat(tasteforge): package reusable workflows and preserve native edits (#3033)
* feat: bundle standalone taste distillation and application workflows

* docs: fix imported taste skill markdown lint

* docs: align Turkish agent catalog with taste skills

* refactor: make ECC the canonical reusable video engine

* fix: preserve video duration when applying image overlays

* fix: preserve background colors in image compositing

* fix: report best-effort duration targets and shortfalls

* feat: ship verified Fusion presets with compatibility provenance

* feat(tasteforge): preserve native edits in application bundles

* feat(tasteforge): compile local preservation without hosted input

* fix: update js-yaml to patched 4.3.2

* test: report bounded Stop wrapper failure diagnostics

* fix(tasteforge): fail closed on unsafe output names, missing overlays and cadence

- cli: default report and spec paths are derived from pack name and profile
  genre; require the manifest's name pattern before using either as a
  filename part so a traversal string cannot write outside cwd/out.
- apply_local: a pack without cadence.json, or with no measured shots and
  no explicit mean_shot, raises instead of silently planning 1.0s shots and
  reporting a measured cadence.
- legacy apply: a missing overlay aborts before any paid upload; forge()
  would have rejected it after every take was generated.
- requirements-live: pin fal-client>=0.13.0, the first release whose
  subscribe() accepts client_timeout.

Addresses the five P1 findings from the independent review of #3033.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fxHRsydPqEcYngGbqkgt1

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 15:31:36 +01:00

290 lines
10 KiB
Python

"""Render a minted mesh to frames, so 3D can re-enter the video pipeline.
This module exists because of a hard platform limit. fal splits 3D into
``image-to-3d``, ``text-to-3d`` and ``3d-to-3d``, and every endpoint in
``3d-to-3d`` emits another mesh - there is no ``3d-to-image`` or
``3d-to-video`` category anywhere in the catalogue. A GLB minted on fal
therefore cannot be fed back into a fal video graph: nothing there can look
at it.
Rendering locally closes the loop. Once a turntable exists as frames it is
just footage, and everything downstream already knows what to do with
footage: grade it with the pack, cut it at the reference's cadence, screen it
over a shot as an element, or upload it as a conditioning reference for the
video model.
Two backends, tried in order:
``blender``
Used when a ``blender`` binary is on PATH. Real PBR shading, so the
material maps that cost $0.15 extra on the mint actually show up.
``software``
A dependency-light rasteriser built on trimesh + numpy. No GPU, no GL
context, no system packages - it runs in any container. Flat-shaded with
a key/rim setup rather than PBR, which is enough for a conditioning
reference or a matte element, and honest about being a preview.
The software path is the default because a headless GL context is the single
most common thing missing from a container, and a renderer that only works on
a workstation is not part of a pipeline.
"""
from __future__ import annotations
import json
import math
import shutil
import subprocess
import tempfile
from pathlib import Path
import numpy as np
def have_blender() -> bool:
return shutil.which("blender") is not None
# --------------------------------------------------------------------------
# software rasteriser
# --------------------------------------------------------------------------
def _load_mesh(path: str | Path):
import trimesh
scene = trimesh.load(str(path), force="scene")
if hasattr(scene, "dump"):
geoms = [g for g in scene.dump() if hasattr(g, "faces")]
if not geoms:
raise ValueError(f"no triangle geometry in {path}")
mesh = geoms[0] if len(geoms) == 1 else trimesh.util.concatenate(geoms)
else:
mesh = scene
mesh = mesh.copy()
# Normalise to a unit sphere at the origin so framing does not depend on
# whatever scale the generator happened to emit - meshes come back in
# metres, centimetres and arbitrary units with no way to tell which.
mesh.vertices -= mesh.vertices.mean(axis=0)
radius = float(np.linalg.norm(mesh.vertices, axis=1).max()) or 1.0
mesh.vertices /= radius
return mesh
def _shade(normals: np.ndarray, base: np.ndarray) -> np.ndarray:
"""Key + rim + ambient on face normals.
A rim term matters more than it looks: with a key light alone, a mesh
rendered on black loses its silhouette entirely wherever it turns away
from the light, which is exactly the framing this pack uses.
"""
key = np.array([0.4, 0.7, 0.6]); key /= np.linalg.norm(key)
rim = np.array([-0.6, 0.2, -0.7]); rim /= np.linalg.norm(rim)
kd = np.clip(normals @ key, 0, 1)
kr = np.clip(normals @ rim, 0, 1) ** 3
lit = 0.08 + 0.85 * kd[:, None] * base + 0.55 * kr[:, None] * np.array([0.55, 0.75, 1.0])
return np.clip(lit, 0, 1)
def _render_frame(mesh, angle: float, size: int, elevation: float, base_rgb) -> np.ndarray:
"""Painter's-algorithm rasterisation of one view. Returns float RGB [0,1]."""
import cv2
ca, sa = math.cos(angle), math.sin(angle)
ce, se = math.cos(elevation), math.sin(elevation)
Ry = np.array([[ca, 0, sa], [0, 1, 0], [-sa, 0, ca]])
Rx = np.array([[1, 0, 0], [0, ce, -se], [0, se, ce]])
R = Rx @ Ry
V = mesh.vertices @ R.T
N = mesh.face_normals @ R.T
# Weak perspective: enough to read as dimensional, cheap enough to stay
# a pure matrix multiply.
z = V[:, 2]
f = 2.6
scale = f / (f - z)
x = V[:, 0] * scale
y = V[:, 1] * scale
px = ((x * 0.42 + 0.5) * size).astype(np.int32)
py = ((-y * 0.42 + 0.5) * size).astype(np.int32)
pts = np.stack([px, py], axis=1)
colors = _shade(N, np.asarray(base_rgb, dtype=float)[None, :])
faces = mesh.faces
depth = V[faces][:, :, 2].mean(axis=1)
order = np.argsort(depth) # far to near
img = np.zeros((size, size, 3), np.float32)
# Back-face culling before sorting halves the fill work and removes the
# interior surfaces that otherwise punch through thin geometry.
front = N[:, 2] > -0.15
for fi in order:
if not front[fi]:
continue
tri = pts[faces[fi]]
cv2.fillConvexPoly(img, tri, tuple(float(c) for c in colors[fi]), lineType=cv2.LINE_AA)
return img
def turntable_software(
mesh_path: str | Path,
dest: str | Path,
n_frames: int = 48,
size: int = 768,
elevation_deg: float = 12.0,
base_rgb=(0.72, 0.74, 0.82),
) -> list[Path]:
mesh = _load_mesh(mesh_path)
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
import cv2
out: list[Path] = []
for i in range(n_frames):
img = _render_frame(mesh, 2 * math.pi * i / n_frames, size,
math.radians(elevation_deg), base_rgb)
p = dest / f"turn_{i:04d}.png"
cv2.imwrite(str(p), cv2.cvtColor((img * 255).astype(np.uint8), cv2.COLOR_RGB2BGR))
out.append(p)
return out
# --------------------------------------------------------------------------
# blender backend
# --------------------------------------------------------------------------
_BLENDER_SCRIPT = r'''
import bpy, sys, math, json
argv = sys.argv[sys.argv.index("--") + 1:]
cfg = json.loads(argv[0])
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=cfg["mesh"])
objs = [o for o in bpy.context.scene.objects if o.type == "MESH"]
if not objs:
raise SystemExit("no mesh in file")
import mathutils
mn = mathutils.Vector((1e9,) * 3); mx = mathutils.Vector((-1e9,) * 3)
for o in objs:
for c in o.bound_box:
w = o.matrix_world @ mathutils.Vector(c)
mn = mathutils.Vector((min(mn[i], w[i]) for i in range(3)))
mx = mathutils.Vector((max(mx[i], w[i]) for i in range(3)))
center = (mn + mx) / 2.0
radius = max((mx - mn).length / 2.0, 1e-4)
pivot = bpy.data.objects.new("pivot", None)
bpy.context.collection.objects.link(pivot)
pivot.location = center
for o in objs:
o.parent = pivot
o.matrix_parent_inverse = pivot.matrix_world.inverted()
cam_data = bpy.data.cameras.new("cam"); cam = bpy.data.objects.new("cam", cam_data)
bpy.context.collection.objects.link(cam); bpy.context.scene.camera = cam
cam.location = center + mathutils.Vector((0, -radius * 3.2, radius * 0.8))
tr = cam.constraints.new(type="TRACK_TO"); tr.target = pivot
tr.track_axis = "TRACK_NEGATIVE_Z"; tr.up_axis = "UP_Y"
# Two area lights, key and rim. A single sun leaves the silhouette to die
# against a black world, which is the background this pack renders onto.
for name, loc, energy, sz in (
("key", (radius*2.5, -radius*2.0, radius*2.5), 900.0, radius*2),
("rim", (-radius*2.5, radius*1.5, radius*1.2), 600.0, radius*2),
):
ld = bpy.data.lights.new(name, type="AREA"); ld.energy = energy; ld.size = sz
lo = bpy.data.objects.new(name, ld); bpy.context.collection.objects.link(lo)
lo.location = center + mathutils.Vector(loc)
c = lo.constraints.new(type="TRACK_TO"); c.target = pivot
c.track_axis = "TRACK_NEGATIVE_Z"; c.up_axis = "UP_Y"
sc = bpy.context.scene
sc.render.engine = cfg.get("engine", "BLENDER_EEVEE_NEXT")
sc.render.resolution_x = sc.render.resolution_y = cfg["size"]
sc.render.film_transparent = True
sc.render.image_settings.file_format = "PNG"
sc.render.image_settings.color_mode = "RGBA"
sc.world = bpy.data.worlds.new("w")
sc.world.use_nodes = True
sc.world.node_tree.nodes["Background"].inputs[1].default_value = 0.0
n = cfg["frames"]
for i in range(n):
pivot.rotation_euler = (0.0, 0.0, 2 * math.pi * i / n)
sc.render.filepath = cfg["dest"] + "/turn_%04d" % i
bpy.ops.render.render(write_still=True)
'''
def turntable_blender(
mesh_path: str | Path,
dest: str | Path,
n_frames: int = 48,
size: int = 768,
engine: str = "BLENDER_EEVEE_NEXT",
timeout: int = 1800,
) -> list[Path]:
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
fh.write(_BLENDER_SCRIPT)
script = fh.name
cfg = json.dumps({
"mesh": str(Path(mesh_path).resolve()),
"dest": str(dest.resolve()),
"frames": n_frames, "size": size, "engine": engine,
})
proc = subprocess.run(
["blender", "-b", "--python", script, "--", cfg],
capture_output=True, text=True, timeout=timeout,
)
Path(script).unlink(missing_ok=True)
frames = sorted(dest.glob("turn_*.png"))
if not frames:
raise RuntimeError(f"blender rendered nothing:\n{proc.stdout[-800:]}\n{proc.stderr[-800:]}")
return frames
def turntable(
mesh_path: str | Path,
dest: str | Path,
n_frames: int = 48,
size: int = 768,
backend: str = "auto",
) -> tuple[list[Path], str]:
"""Render a turntable. Returns ``(frames, backend_used)``."""
if backend == "auto":
backend = "blender" if have_blender() else "software"
if backend == "blender":
try:
return turntable_blender(mesh_path, dest, n_frames, size), "blender"
except Exception:
# A failed Blender render must not lose the asset; the software
# path always works, so degrade instead of raising.
pass
return turntable_software(mesh_path, dest, n_frames, size), "software"
def frames_to_video(frames: list[Path], dst: str | Path, fps: float = 24.0) -> Path:
"""Encode rendered frames into a clip the rest of the pipeline can eat."""
dst = Path(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
pattern = str(frames[0].parent / "turn_%04d.png")
proc = subprocess.run([
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-framerate", f"{fps:g}", "-i", pattern,
"-c:v", "libx264", "-crf", "14", "-pix_fmt", "yuv420p", str(dst),
], capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {proc.stderr[-400:]}")
return dst