mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
* 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>
290 lines
10 KiB
Python
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
|