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>
265 lines
12 KiB
Python
265 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Mint 3D props from a style pack, and render them back into footage.
|
|
|
|
Stage 2b of taste-forge, and the branch that used to dead-end.
|
|
|
|
Two ways in:
|
|
|
|
``--from-stills``
|
|
Lift a prop out of the reference itself. The pack's stills are frames of
|
|
the same world from different shots, so several of them can be passed as
|
|
multi-view input, which is the single biggest quality lever on the
|
|
endpoint - given one view the model invents the back of the object, and
|
|
invents it wrong.
|
|
``--prompt``
|
|
Generate a prop the reference implies but never shows cleanly. The pack's
|
|
distilled spec supplies the world; the prompt names the object in it.
|
|
|
|
Then the part that makes it a pipeline rather than an asset dump: the minted
|
|
mesh is rendered to a turntable locally and encoded to a clip. fal has no
|
|
endpoint that renders a mesh - the whole 3D category consumes 2D and emits
|
|
3D, or consumes 3D and emits 3D - so without a local renderer a minted GLB
|
|
can never re-enter the video graph. With one, a prop becomes footage, and
|
|
footage is something every later stage already handles: 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.
|
|
|
|
python mint3d.py --genre flashethereal --from-stills 3 --render
|
|
python mint3d.py --genre flashethereal --prompt "a cracked chrome visor" --render
|
|
|
|
``--retopo`` adds a quad-remesh pass, which is what makes the prop editable
|
|
and riggable in Blender rather than merely renderable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from taste import falapi
|
|
from taste import pack as pack_mod
|
|
from taste import render3d as r3
|
|
|
|
|
|
# The endpoint's own input guidance, turned into prompt text: "simple
|
|
# background, single object, object >50% of frame". This is not stylistic - a
|
|
# busy plate produces a busy mesh. The flashethereal stills are glitch collages
|
|
# with several subjects and heavy overlay graphics, which is close to the worst
|
|
# possible input, so lifting a prop straight from them yields sculpted noise.
|
|
#
|
|
# Generating a clean plate first costs ~$0.15 and is the difference between a
|
|
# usable mesh and a discarded one.
|
|
PLATE_RULES = (
|
|
"A single isolated object centred on a plain neutral mid-grey seamless "
|
|
"background, filling most of the frame, evenly lit from three quarters, no "
|
|
"other objects, no text, no logos, no props, no shadows cast on the "
|
|
"backdrop, product-photography framing, sharp focus edge to edge, the whole "
|
|
"object visible with nothing cropped. Neutral colour, no colour grading."
|
|
)
|
|
|
|
|
|
def _asset_name(name: str | None, prompt: str) -> str:
|
|
words = prompt.split()
|
|
asset = name if name is not None else "prop_" + (words[0] if words else "lifted")
|
|
if (not asset or asset in {".", ".."} or len(asset) > 120
|
|
or any(not (c.isalnum() or c in "_-. ") for c in asset)
|
|
or asset != asset.strip()):
|
|
raise ValueError("asset name must be a simple filename stem (letters, digits, spaces, _.-)")
|
|
return asset
|
|
|
|
|
|
def _check_outputs(sp, asset: str) -> None:
|
|
"""Reject existing artifacts for this stem before any billable work."""
|
|
props = sp.dir / "props"
|
|
candidates = [props / f"{asset}{suffix}" for suffix in
|
|
(".glb", ".json", "_plate.png", "_retopo.glb")]
|
|
candidates += list(props.glob(f"{asset}_part*.glb"))
|
|
candidates += [sp.dir / "turntables" / asset,
|
|
sp.dir / "turntables" / f"{asset}.mp4"]
|
|
for path in candidates:
|
|
if path.exists() or path.is_symlink():
|
|
raise FileExistsError(f"asset output already exists; choose a new --name: {path}")
|
|
|
|
|
|
def mint3d(
|
|
genre: str,
|
|
root: str = "stylepacks",
|
|
from_stills: int = 0,
|
|
prompt: str = "",
|
|
plate: bool = False,
|
|
name: str | None = None,
|
|
pbr: bool = True,
|
|
retopo: bool = False,
|
|
split: bool = False,
|
|
render: bool = True,
|
|
frames: int = 48,
|
|
size: int = 768,
|
|
backend: str = "auto",
|
|
face_count: int | None = None,
|
|
) -> dict:
|
|
asset = _asset_name(name, prompt)
|
|
sp = pack_mod.load(genre, root=root)
|
|
_check_outputs(sp, asset)
|
|
props_dir = sp.dir / "props"
|
|
props_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
mode = "DRY RUN" if falapi.is_dry_run() else "live"
|
|
print(f"minting 3D for '{genre}' [{mode}] -> {asset}")
|
|
|
|
record: dict = {
|
|
"genre": genre,
|
|
"asset": asset,
|
|
"created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"dry_run": falapi.is_dry_run(),
|
|
"endpoints": {k: falapi.ENDPOINTS[k] for k in
|
|
("image_to_3d", "text_to_3d", "retopology", "part_split")},
|
|
}
|
|
|
|
if prompt and not plate:
|
|
spec = sp.read_json(sp.spec_path) or {}
|
|
# Ground the prompt in the pack so the prop belongs to the same world
|
|
# the footage does. Colour is deliberately excluded for the same
|
|
# reason apply.py excludes it: the LUT owns colour, and colour words
|
|
# here would bake a cast into the texture that then gets graded twice.
|
|
world = ", ".join(
|
|
str(v) for v in (spec.get("mood_adjectives") or [])[:3]
|
|
)
|
|
full = prompt if not world else f"{prompt}. Setting: {world}. Neutral colour, PBR materials."
|
|
print(f" text-to-3d : {full[:90]}")
|
|
mesh_url = falapi.text_to_3d(full, pbr=pbr)
|
|
record["prompt"] = full
|
|
elif plate:
|
|
# Two-step: text -> clean single-object plate -> mesh. This is the
|
|
# path to use unless the pack's stills genuinely are clean product
|
|
# shots, which reference reels almost never are.
|
|
spec = sp.read_json(sp.spec_path) or {}
|
|
world = ", ".join(str(v) for v in (spec.get("mood_adjectives") or [])[:3])
|
|
plate_prompt = f"{prompt}. {PLATE_RULES}"
|
|
if world:
|
|
plate_prompt += f" The object belongs to a world that reads as: {world}."
|
|
print(f" plate : generating clean single-object reference ...")
|
|
plate_urls = falapi.text_to_image(plate_prompt)
|
|
record["plate_prompt"] = plate_prompt
|
|
record["plate_url"] = plate_urls[0]
|
|
if not falapi.is_dry_run():
|
|
plate_path = props_dir / f"{asset}_plate.png"
|
|
falapi.download(plate_urls[0], plate_path)
|
|
record["plate"] = str(plate_path)
|
|
print(f" plate -> {plate_path}")
|
|
print(f" image-to-3d : from generated plate")
|
|
mesh_url = falapi.image_to_3d(plate_urls[0], pbr=pbr, face_count=face_count)
|
|
else:
|
|
stills = sp.stills()
|
|
if not stills:
|
|
raise SystemExit(f"pack '{genre}' has no stills - run mint.py first")
|
|
n = max(1, min(int(from_stills or 1), 8, len(stills)))
|
|
chosen = stills[:n]
|
|
print(f" image-to-3d : {n} view(s) - {', '.join(p.name for p in chosen)}")
|
|
urls = [falapi.upload(p) for p in chosen]
|
|
mesh_url = falapi.image_to_3d(urls, pbr=pbr, face_count=face_count)
|
|
record["stills"] = [p.name for p in chosen]
|
|
|
|
record["mesh_url"] = mesh_url
|
|
# The generated PBR original remains canonical, even if remeshing loses
|
|
# materials. Persist it before requesting any optional derivative.
|
|
mesh_path = props_dir / f"{asset}.glb"
|
|
falapi.download(mesh_url, mesh_path)
|
|
record["mesh"] = str(mesh_path)
|
|
print(f" mesh : {mesh_path}")
|
|
|
|
if retopo:
|
|
print(" retopology : quad remesh ...")
|
|
try:
|
|
retopo_url = falapi.retopologize(mesh_url, quad=True)
|
|
record["retopo_url"] = retopo_url
|
|
retopo_path = props_dir / f"{asset}_retopo.glb"
|
|
falapi.download(retopo_url, retopo_path)
|
|
record["retopo_mesh"] = str(retopo_path)
|
|
except falapi.FalError as exc:
|
|
print(f" !! retopology failed, keeping raw mesh: {exc}", file=sys.stderr)
|
|
|
|
if split:
|
|
print(" part split : segmenting ...")
|
|
try:
|
|
parts = falapi.split_parts(mesh_url)
|
|
paths = []
|
|
for i, u in enumerate(parts):
|
|
pp = props_dir / f"{asset}_part{i:02d}.glb"
|
|
falapi.download(u, pp)
|
|
paths.append(str(pp))
|
|
record["parts"] = paths
|
|
print(f" {len(paths)} part(s)")
|
|
except falapi.FalError as exc:
|
|
print(f" !! part split failed: {exc}", file=sys.stderr)
|
|
|
|
if render:
|
|
# The step that closes the loop. Skipped automatically on a dry run,
|
|
# where the "mesh" on disk is a text placeholder rather than a GLB.
|
|
if falapi.is_dry_run():
|
|
print(" render : skipped (dry run mesh is a placeholder)")
|
|
else:
|
|
turn_dir = sp.dir / "turntables" / asset
|
|
print(f" render : {frames} frames @ {size}px ...")
|
|
fr, used = r3.turntable(mesh_path, turn_dir, n_frames=frames,
|
|
size=size, backend=backend)
|
|
clip = sp.dir / "turntables" / f"{asset}.mp4"
|
|
r3.frames_to_video(fr, clip)
|
|
record["turntable"] = {"backend": used, "frames": len(fr), "clip": str(clip)}
|
|
print(f" {used} backend, {len(fr)} frames -> {clip}")
|
|
print(" this clip is now ordinary footage: grade it, cut it, "
|
|
"screen it, or use it as a conditioning reference")
|
|
|
|
manifest = props_dir / f"{asset}.json"
|
|
manifest.write_text(json.dumps(record, indent=2), encoding="utf-8")
|
|
print(f" manifest : {manifest}")
|
|
return record
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description="Mint 3D props from a style pack (stage 2b).")
|
|
ap.add_argument("--genre", required=True)
|
|
ap.add_argument("--root", default="stylepacks")
|
|
ap.add_argument("--from-stills", type=int, default=0,
|
|
help="lift a prop from N pack stills as multi-view input (1-8)")
|
|
ap.add_argument("--prompt", default="", help="generate a prop from text instead")
|
|
ap.add_argument("--plate", action="store_true",
|
|
help="with --prompt: generate a clean single-object image first, "
|
|
"then mesh THAT. Almost always better than text-to-3d or than "
|
|
"lifting from busy reference stills")
|
|
ap.add_argument("--name", default=None, help="asset name (default derived)")
|
|
ap.add_argument("--no-pbr", action="store_true", help="skip PBR texture maps")
|
|
ap.add_argument("--retopo", action="store_true", help="quad remesh for editability")
|
|
ap.add_argument("--split", action="store_true", help="segment into editable parts")
|
|
ap.add_argument("--no-render", action="store_true", help="skip the turntable render")
|
|
ap.add_argument("--frames", type=int, default=48)
|
|
ap.add_argument("--size", type=int, default=768)
|
|
ap.add_argument("--backend", default="auto", choices=["auto", "blender", "software"])
|
|
ap.add_argument("--face-count", type=int, default=None,
|
|
help="polygon budget, 40k-1.5M on the pro endpoint")
|
|
ap.add_argument("--tier", default=None, choices=["best", "fast", "value", "game"],
|
|
help="cost/quality tier for the 3D endpoints")
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
a = ap.parse_args()
|
|
|
|
if a.dry_run:
|
|
falapi.enable_dry_run()
|
|
if a.tier:
|
|
for slot in ("image_to_3d", "text_to_3d"):
|
|
try:
|
|
print(f" {slot} -> {falapi.use_tier(slot, a.tier)}")
|
|
except falapi.FalError:
|
|
pass
|
|
if not a.prompt and not a.from_stills:
|
|
a.from_stills = 3
|
|
|
|
mint3d(a.genre, a.root, a.from_stills, a.prompt, a.plate, a.name, not a.no_pbr,
|
|
a.retopo, a.split, not a.no_render, a.frames, a.size, a.backend,
|
|
a.face_count)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|