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>
791 lines
30 KiB
Python
791 lines
30 KiB
Python
"""Thin, auditable wrapper over ``fal_client``.
|
|
|
|
Everything in taste-forge that touches the network goes through here, for
|
|
three reasons:
|
|
|
|
* **Swappability.** Hosted model IDs churn. Every endpoint lives in one
|
|
``ENDPOINTS`` dict at the top of this module, so re-pointing the pipeline at
|
|
a newer model is a one-line edit rather than a grep across the codebase.
|
|
* **Dry runs.** Setting ``TASTE_FORGE_DRY_RUN=1`` makes every call return a
|
|
plausible, deterministic stub instead of hitting the network. The whole
|
|
pipeline can then be exercised end-to-end with no API key and no spend,
|
|
which is what makes the CLIs testable.
|
|
* **Auditability.** Uploads are cached; submissions are attempted once.
|
|
Live transport requires ``TASTE_FORGE_ALLOW_LIVE=1``. Logs omit provider
|
|
payloads, signed URL details and raw transport exceptions.
|
|
|
|
Credentials are read from the ``FAL_KEY`` environment variable and are never
|
|
written to disk, logged, or embedded in a payload.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import random
|
|
import shutil
|
|
import threading
|
|
import time
|
|
import urllib.request
|
|
import urllib.parse
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
log = logging.getLogger("taste.falapi")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# endpoints
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# These are DEFAULTS, not guarantees. fal.ai model ids, their payload keys and
|
|
# their response shapes drift faster than this repo will; treat any entry here
|
|
# as something to verify against https://fal.ai/models before a production run
|
|
# and update in place. Nothing else in the codebase hardcodes an endpoint id,
|
|
# so a swap here propagates everywhere.
|
|
ENDPOINTS: dict[str, str] = {
|
|
# Vision-language description of reference stills -> style spec JSON.
|
|
"vlm": "fal-ai/any-llm/vision",
|
|
# Style/character reference image + prompt -> short video shot.
|
|
"reference_to_video": "bytedance/seedance-2.5/reference-to-video",
|
|
# Still -> textured GLB, used to mint reusable props.
|
|
"image_to_3d": "fal-ai/hunyuan-3d/v3.1/pro/image-to-3d",
|
|
# Prompt -> textured GLB, for props the reference implies but never shows.
|
|
"text_to_3d": "fal-ai/hunyuan-3d/v3.1/pro/text-to-3d",
|
|
# Mesh post-processing.
|
|
"retopology": "fal-ai/hunyuan-3d/v3.1/smart-topology",
|
|
"part_split": "tripo3d/tripo/segment",
|
|
"retexture": "fal-ai/meshy/v5/retexture",
|
|
# Prompt (+ optional reference images) -> still image.
|
|
"text_to_image": "fal-ai/nano-banana-pro",
|
|
"image_edit": "fal-ai/nano-banana-pro/edit",
|
|
# ffmpeg utility endpoints.
|
|
"extract_frame": "fal-ai/ffmpeg-api/extract-frame",
|
|
"compose": "fal-ai/ffmpeg-api/compose",
|
|
"merge_videos": "fal-ai/ffmpeg-api/merge-videos",
|
|
# Locally rendered turntable frames -> video. This is the only way a 3D
|
|
# asset gets back into the video pipeline (see TIERS notes below).
|
|
"images_to_video": "fal-ai/ffmpeg-api/images-to-video",
|
|
}
|
|
|
|
# Alternates, verified live, kept as a table rather than as prose because the
|
|
# right choice is a budget decision the caller should be able to make per run.
|
|
#
|
|
# The reference-to-video line is where the money goes and where the naming is
|
|
# most treacherous. Two specific traps, both confirmed against fal's catalogue:
|
|
#
|
|
# * There is no Kling 3.0 reference-to-video. The v3 line is text-to-video,
|
|
# image-to-video and motion-control only; reference-to-video exists solely
|
|
# on the o3 line.
|
|
# * Seedance 2.5 is roughly 4x the price of Kling o3 pro for the same 5
|
|
# seconds ($2.37 vs $0.56 at 720p), which it earns on multi-reference
|
|
# fidelity - it takes up to 50 mixed image/video/audio references - and
|
|
# does not earn if you are conditioning on a single still, which is what
|
|
# this pipeline does by default.
|
|
TIERS: dict[str, dict[str, str]] = {
|
|
"reference_to_video": {
|
|
"best": "bytedance/seedance-2.5/reference-to-video", # ~$0.473/s @720p
|
|
"value": "fal-ai/kling-video/o3/pro/reference-to-video", # ~$0.112/s
|
|
"audio": "fal-ai/veo3.1/reference-to-video", # native dialogue
|
|
"cheap": "minimax/h3/reference-to-video", # ~$0.05/s @480p
|
|
},
|
|
"image_to_3d": {
|
|
"best": "fal-ai/hunyuan-3d/v3.1/pro/image-to-3d", # $0.375, up to 8 views
|
|
"fast": "fal-ai/hunyuan-3d/v3.1/rapid/image-to-3d", # $0.225, single view
|
|
"value": "tripo3d/h3.1/image-to-3d", # $0.20, quad option
|
|
"game": "meshy/v7/image-to-3d", # $1.20, rig + anim
|
|
},
|
|
"text_to_3d": {
|
|
"best": "fal-ai/hunyuan-3d/v3.1/pro/text-to-3d",
|
|
"fast": "fal-ai/hunyuan-3d/v3.1/rapid/text-to-3d",
|
|
"value": "tripo3d/h3.1/text-to-3d",
|
|
},
|
|
"text_to_image": {
|
|
"best": "fal-ai/nano-banana-pro", # $0.15 flat, strongest identity
|
|
"value": "fal-ai/flux-2-pro", # $0.03 first MP
|
|
"instruct": "openai/gpt-image-2", # best typography / instructions
|
|
},
|
|
}
|
|
|
|
|
|
def use_tier(slot: str, tier: str) -> str:
|
|
"""Repoint one slot at a named tier. Returns the endpoint now in use."""
|
|
table = TIERS.get(slot)
|
|
if not table or tier not in table:
|
|
raise FalError(
|
|
f"no tier '{tier}' for slot '{slot}'; "
|
|
f"have {sorted(table) if table else 'no tiers'}"
|
|
)
|
|
ENDPOINTS[slot] = table[tier]
|
|
return ENDPOINTS[slot]
|
|
|
|
|
|
# fal has NO endpoint that renders a mesh to images or video. The catalogue
|
|
# splits 3D into image-to-3d, text-to-3d and 3d-to-3d, and every member of
|
|
# 3d-to-3d emits another mesh - there is no 3d-to-image or 3d-to-video
|
|
# category at all. So a minted GLB cannot re-enter the video graph on fal.
|
|
#
|
|
# It can re-enter locally: render a turntable here (taste/render3d.py), then
|
|
# either assemble the frames with local ffmpeg or push them through
|
|
# ``images_to_video`` above. That is why the 3D branch is not a dead end even
|
|
# though the platform has no renderer.
|
|
NO_RENDER_ENDPOINT = True
|
|
|
|
# Model id used with the multi-provider VLM endpoint above. Also a default.
|
|
VLM_MODEL = "google/gemini-flash-2.5"
|
|
|
|
DRY_RUN_ENV = "TASTE_FORGE_DRY_RUN"
|
|
DRY_RUN_HOST = "https://dry-run.taste-forge.local"
|
|
|
|
DEFAULT_TIMEOUT = 600
|
|
MAX_ATTEMPTS = 1
|
|
BACKOFF_BASE = 2.0
|
|
|
|
# Statuses worth retrying: rate limits, queue hiccups, upstream 5xx. Anything
|
|
# else (401/403 bad key, 404 dead endpoint, 422 bad payload) is a permanent
|
|
# failure and retrying it just burns wall-clock time.
|
|
_TRANSIENT_STATUS = {408, 409, 425, 429, 500, 502, 503, 504}
|
|
|
|
|
|
class FalError(RuntimeError):
|
|
"""Any failure originating from the fal layer."""
|
|
|
|
|
|
class MissingKeyError(FalError):
|
|
"""``FAL_KEY`` is not set and this is not a dry run."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mode + credentials
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def is_dry_run() -> bool:
|
|
"""True when ``TASTE_FORGE_DRY_RUN`` is set to a truthy value.
|
|
|
|
Read live rather than snapshotted at import so a CLI's ``--dry-run`` flag
|
|
can enable it after this module is already imported.
|
|
"""
|
|
return os.environ.get(DRY_RUN_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def enable_dry_run() -> None:
|
|
"""Turn on dry-run mode for this process (what ``--dry-run`` calls)."""
|
|
os.environ[DRY_RUN_ENV] = "1"
|
|
|
|
|
|
def require_live() -> None:
|
|
"""Require explicit process-level authorization before any live transport."""
|
|
if os.environ.get("TASTE_FORGE_ALLOW_LIVE") != "1":
|
|
raise FalError("live transport requires TASTE_FORGE_ALLOW_LIVE=1")
|
|
|
|
|
|
def safe_url(url: str) -> str:
|
|
"""Log only origin: paths, queries and userinfo can carry signed secrets."""
|
|
try:
|
|
parsed = urllib.parse.urlsplit(url)
|
|
return f"{parsed.scheme}://{parsed.hostname or '[invalid-host]'}"
|
|
except ValueError:
|
|
return "[invalid-url]"
|
|
|
|
|
|
def api_key() -> str:
|
|
"""Return ``FAL_KEY`` after live opt-in. Never logs the value."""
|
|
require_live()
|
|
key = os.environ.get("FAL_KEY", "").strip()
|
|
if not key:
|
|
raise MissingKeyError(
|
|
"FAL_KEY is not set.\n"
|
|
" Get a key at https://fal.ai/dashboard/keys, then either:\n"
|
|
" export FAL_KEY='...'\n"
|
|
" or run the pipeline offline with no key and no spend:\n"
|
|
f" export {DRY_RUN_ENV}=1 (or pass --dry-run)"
|
|
)
|
|
return key
|
|
|
|
|
|
def _fal():
|
|
"""Import ``fal_client`` lazily so dry runs work even if it is absent."""
|
|
try:
|
|
import fal_client # noqa: PLC0415 - deliberate lazy import
|
|
except ImportError as exc: # pragma: no cover - environment dependent
|
|
raise FalError(
|
|
"the 'fal_client' package is required for live calls: pip install fal-client"
|
|
) from exc
|
|
return fal_client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# core: submit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _is_transient(exc: BaseException) -> bool:
|
|
status = getattr(exc, "status_code", None)
|
|
if status is None:
|
|
status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
if isinstance(status, int):
|
|
return status in _TRANSIENT_STATUS
|
|
name = type(exc).__name__.lower()
|
|
if "timeout" in name or "connection" in name:
|
|
return True
|
|
return isinstance(exc, (TimeoutError, ConnectionError))
|
|
|
|
|
|
def _preview(payload: dict, limit: int = 600) -> str:
|
|
try:
|
|
text = json.dumps(payload, default=str)
|
|
except Exception: # pragma: no cover - defensive
|
|
text = repr(payload)
|
|
return text if len(text) <= limit else text[:limit] + f"... (+{len(text) - limit} chars)"
|
|
|
|
|
|
def submit(
|
|
endpoint: str,
|
|
payload: dict,
|
|
timeout: int = DEFAULT_TIMEOUT,
|
|
*,
|
|
max_attempts: int = MAX_ATTEMPTS,
|
|
) -> dict:
|
|
"""Submit once. Ambiguous failures must be reconciled before another job.
|
|
|
|
``max_attempts`` is retained for call compatibility but never resubmits.
|
|
"""
|
|
if is_dry_run():
|
|
log.info("[dry-run] model request (payload omitted)")
|
|
return _stub(endpoint, payload)
|
|
|
|
require_live()
|
|
api_key()
|
|
try:
|
|
result = _fal().subscribe(
|
|
endpoint, arguments=payload, with_logs=False, client_timeout=timeout,
|
|
)
|
|
return result if isinstance(result, dict) else {"output": result}
|
|
except Exception:
|
|
# Exception strings can include keys, signed URLs and provider payloads.
|
|
# Do not print or chain them into caller tracebacks.
|
|
raise FalError(
|
|
"fal call failed after one attempt; job acceptance may be unknown. "
|
|
"Reconcile provider job status before requesting another generation."
|
|
) from None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# uploads (cached)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_UPLOAD_CACHE: dict[tuple[str, int, int], str] = {}
|
|
_UPLOAD_LOCK = threading.Lock()
|
|
|
|
|
|
def _cache_key(path: Path) -> tuple[str, int, int]:
|
|
st = path.stat()
|
|
return (str(path.resolve()), st.st_mtime_ns, st.st_size)
|
|
|
|
|
|
def upload(path: str | Path) -> str:
|
|
"""Upload a local file and return its URL, memoized per (path, mtime, size).
|
|
|
|
apply.py reuses the same handful of stills across every shot in a run and
|
|
across concurrent workers; without this cache each of those becomes a
|
|
redundant multi-megabyte POST.
|
|
"""
|
|
if not is_dry_run():
|
|
require_live()
|
|
p = Path(path)
|
|
if not p.exists():
|
|
raise FalError(f"cannot upload, file does not exist: {p}")
|
|
|
|
key = _cache_key(p)
|
|
with _UPLOAD_LOCK:
|
|
hit = _UPLOAD_CACHE.get(key)
|
|
if hit and (is_dry_run() == hit.startswith(DRY_RUN_HOST + "/")):
|
|
log.debug("upload cache hit: %s", p.name)
|
|
return hit
|
|
|
|
if is_dry_run():
|
|
url = f"{DRY_RUN_HOST}/uploads/{_digest(str(key))}/{p.name}"
|
|
log.info("[dry-run] would upload %s (%d bytes) -> %s", p, key[2], url)
|
|
else:
|
|
api_key()
|
|
try:
|
|
url = _fal().upload_file(str(p))
|
|
except Exception:
|
|
raise FalError("fal upload failed; provider details omitted") from None
|
|
log.info("uploaded %s -> %s", p.name, safe_url(url))
|
|
|
|
with _UPLOAD_LOCK:
|
|
_UPLOAD_CACHE[key] = url
|
|
return url
|
|
|
|
|
|
def upload_many(paths: Iterable[str | Path]) -> list[str]:
|
|
return [upload(p) for p in paths]
|
|
|
|
|
|
def clear_upload_cache() -> None:
|
|
with _UPLOAD_LOCK:
|
|
_UPLOAD_CACHE.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# response parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def parse_urls(result: Any) -> list[str]:
|
|
"""Collect every URL in a response, depth-first, in order.
|
|
|
|
Response envelopes differ per endpoint (``video.url``, ``images[].url``,
|
|
``model_mesh.url``, bare strings). Walking for URLs rather than indexing a
|
|
fixed path means an endpoint swap does not silently return ``None``.
|
|
"""
|
|
found: list[str] = []
|
|
|
|
def walk(node: Any) -> None:
|
|
if isinstance(node, str):
|
|
if node.startswith(("http://", "https://", "data:")):
|
|
found.append(node)
|
|
elif isinstance(node, dict):
|
|
if isinstance(node.get("url"), str):
|
|
found.append(node["url"])
|
|
for k, v in node.items():
|
|
if k != "url":
|
|
walk(v)
|
|
elif isinstance(node, (list, tuple)):
|
|
for v in node:
|
|
walk(v)
|
|
|
|
walk(result)
|
|
seen: set[str] = set()
|
|
return [u for u in found if not (u in seen or seen.add(u))]
|
|
|
|
|
|
def first_url(result: Any, endpoint: str) -> str:
|
|
urls = parse_urls(result)
|
|
if not urls:
|
|
raise FalError(
|
|
"no URL in provider response; response shape may have changed "
|
|
"(provider payload omitted)"
|
|
)
|
|
return urls[0]
|
|
|
|
|
|
def _mesh_url(result: Any, endpoint: str) -> str:
|
|
"""The GLB out of a 3D response, addressed by key rather than by position.
|
|
|
|
``first_url`` would work only as long as ``model_glb`` happens to be the
|
|
first URL-bearing key in the response. It is today; the response also
|
|
carries a ``thumbnail`` PNG and a ``model_urls`` block with obj/fbx/mtl,
|
|
so a key reordering upstream would quietly start returning a preview image
|
|
where a mesh is expected - and a preview image downloads fine, so nothing
|
|
would fail until Blender refused to open it.
|
|
"""
|
|
if isinstance(result, dict):
|
|
for path in (("model_glb", "url"), ("model_urls", "glb", "url"),
|
|
("model_mesh", "url"), ("model", "url")):
|
|
node: Any = result
|
|
for key in path:
|
|
node = node.get(key) if isinstance(node, dict) else None
|
|
if node is None:
|
|
break
|
|
if isinstance(node, str) and node:
|
|
return node
|
|
return first_url(result, endpoint)
|
|
|
|
|
|
def _text_of(result: dict) -> str:
|
|
"""Best-effort extraction of the text body from an LLM/VLM response."""
|
|
for key in ("output", "text", "response", "content", "answer"):
|
|
val = result.get(key)
|
|
if isinstance(val, str) and val.strip():
|
|
return val
|
|
choices = result.get("choices")
|
|
if isinstance(choices, list) and choices:
|
|
msg = choices[0].get("message") if isinstance(choices[0], dict) else None
|
|
if isinstance(msg, dict) and isinstance(msg.get("content"), str):
|
|
return msg["content"]
|
|
return json.dumps(result)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# named helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def vlm_describe(
|
|
image_urls: list[str],
|
|
prompt: str,
|
|
schema_hint: dict | str | None = None,
|
|
*,
|
|
timeout: int = 240,
|
|
) -> str:
|
|
"""Describe reference stills. Returns the model's raw text output.
|
|
|
|
``schema_hint`` should be a dict of ``field -> example value``; it is
|
|
rendered into the prompt as the required output shape and doubles as the
|
|
template for the dry-run stub, so callers get back something that actually
|
|
parses without a key.
|
|
"""
|
|
full = prompt
|
|
if schema_hint:
|
|
shape = (
|
|
json.dumps(schema_hint, indent=2)
|
|
if isinstance(schema_hint, dict)
|
|
else str(schema_hint)
|
|
)
|
|
full = f"{prompt}\n\nReturn ONLY JSON matching this shape:\n{shape}"
|
|
|
|
payload = {
|
|
"model": VLM_MODEL,
|
|
"prompt": full,
|
|
"image_urls": list(image_urls),
|
|
}
|
|
if image_urls:
|
|
# Some VLM endpoints take a single image_url instead of a list; sending
|
|
# both is harmless and makes the call survive that variation.
|
|
payload["image_url"] = image_urls[0]
|
|
|
|
result = submit(ENDPOINTS["vlm"], payload, timeout)
|
|
if is_dry_run() and isinstance(schema_hint, dict):
|
|
# Shape the stub to the caller's own schema so downstream JSON parsing
|
|
# and validation are genuinely exercised offline.
|
|
return json.dumps(_stub_from_schema(schema_hint), indent=2)
|
|
return _text_of(result)
|
|
|
|
|
|
# Hunyuan v3.1 takes multi-view as NAMED PER-ANGLE FIELDS, not as a list.
|
|
# There is no `input_image_urls` and no `multi_view` flag - an earlier version
|
|
# of this module invented both, which would have silently degraded every
|
|
# multi-view mint to single-view (only `input_image_url` is read) while
|
|
# appearing to work. Order matters: this is the sequence the endpoint's own
|
|
# docs list, and it is roughly the order of usefulness.
|
|
VIEW_FIELDS = (
|
|
"input_image_url", # front - the only required one
|
|
"back_image_url",
|
|
"left_image_url",
|
|
"right_image_url",
|
|
"left_front_image_url", # 45-degree, v3.1 exclusive
|
|
"right_front_image_url",
|
|
"top_image_url",
|
|
"bottom_image_url",
|
|
)
|
|
|
|
|
|
def image_to_3d(
|
|
image_url: str | list[str],
|
|
*,
|
|
pbr: bool = True,
|
|
face_count: int | None = None,
|
|
geometry_only: bool = False,
|
|
views: dict[str, str] | None = None,
|
|
timeout: int = 900,
|
|
) -> str:
|
|
"""Mint a textured GLB from one still, or from up to 8 named views.
|
|
|
|
Multi-view is the biggest quality lever on this endpoint: given only a
|
|
front view the model has to invent the back of the object, and it invents
|
|
something plausible and wrong.
|
|
|
|
Pass ``views`` when you know which angle each image is - e.g.
|
|
``{"input_image_url": front, "back_image_url": back}``. Passing a bare
|
|
list assigns images to :data:`VIEW_FIELDS` in order, which is a guess and
|
|
is only correct if the caller actually sorted them that way; a wrong angle
|
|
label is worse than omitting the view entirely, because the model trusts
|
|
it. When in doubt, send one image.
|
|
|
|
``pbr`` requests physically-based maps (metallic, roughness, normal). Without
|
|
them the mesh lights like painted cardboard in Blender, which defeats the
|
|
point of minting it. It is ignored when ``geometry_only`` is set.
|
|
|
|
Note the endpoint's own input guidance: simple background, single object,
|
|
object filling >50% of frame. Busy reference stills - collages, wide shots,
|
|
anything with several subjects - produce garbage meshes. Generate a clean
|
|
single-object plate first if the pack's stills are not that.
|
|
"""
|
|
if views:
|
|
payload: dict = {k: v for k, v in views.items() if k in VIEW_FIELDS and v}
|
|
if "input_image_url" not in payload:
|
|
raise FalError("views must include 'input_image_url' (the front view)")
|
|
else:
|
|
urls = [image_url] if isinstance(image_url, str) else list(image_url)
|
|
if not urls:
|
|
raise FalError("image_to_3d needs at least one image")
|
|
payload = {f: u for f, u in zip(VIEW_FIELDS, urls[:len(VIEW_FIELDS)])}
|
|
|
|
payload["generate_type"] = "Geometry" if geometry_only else "Normal"
|
|
if not geometry_only:
|
|
payload["enable_pbr"] = bool(pbr)
|
|
if face_count:
|
|
# Endpoint range is 40k-1.5M; clamp rather than let it 422.
|
|
payload["face_count"] = int(max(40_000, min(1_500_000, face_count)))
|
|
|
|
result = submit(ENDPOINTS["image_to_3d"], payload, timeout)
|
|
return _mesh_url(result, ENDPOINTS["image_to_3d"])
|
|
|
|
|
|
def text_to_3d(prompt: str, *, pbr: bool = True, timeout: int = 900) -> str:
|
|
"""Mint a textured GLB from a description. Returns the mesh URL.
|
|
|
|
The complement to image_to_3d: use it for props the reference *implies*
|
|
but never shows cleanly enough to lift - the pack's spec describes the
|
|
world, and this generates objects that belong in it.
|
|
"""
|
|
payload = {"prompt": prompt, "text": prompt, "pbr": pbr}
|
|
result = submit(ENDPOINTS["text_to_3d"], payload, timeout)
|
|
return _mesh_url(result, ENDPOINTS["text_to_3d"])
|
|
|
|
|
|
def retopologize(mesh_url: str, *, quad: bool = True, timeout: int = 900) -> str:
|
|
"""Rebuild a generated mesh's topology as clean quads (or tris).
|
|
|
|
Generated meshes are dense and chaotic - fine for a render, painful to
|
|
edit or rig. This is what makes a minted prop actually usable in Blender.
|
|
"""
|
|
payload = {"mesh_url": mesh_url, "input_mesh_url": mesh_url,
|
|
"topology": "quad" if quad else "triangle"}
|
|
result = submit(ENDPOINTS["retopology"], payload, timeout)
|
|
return first_url(result, ENDPOINTS["retopology"])
|
|
|
|
|
|
def split_parts(mesh_url: str, *, timeout: int = 900) -> list[str]:
|
|
"""Segment a mesh into separately editable parts. Returns part URLs."""
|
|
payload = {"mesh_url": mesh_url, "input_mesh_url": mesh_url}
|
|
result = submit(ENDPOINTS["part_split"], payload, timeout)
|
|
parts = result.get("parts") or result.get("meshes") or []
|
|
urls = [p.get("url") for p in parts if isinstance(p, dict) and p.get("url")]
|
|
return urls or [first_url(result, ENDPOINTS["part_split"])]
|
|
|
|
|
|
def images_to_video(
|
|
image_urls: list[str], *, fps: float = 24.0, timeout: int = 900
|
|
) -> str:
|
|
"""Assemble ordered frames into a video.
|
|
|
|
Exists here for one reason: fal cannot render a mesh, so a turntable has
|
|
to be rendered locally and then re-enter the graph as frames.
|
|
"""
|
|
payload = {"image_urls": image_urls, "fps": fps}
|
|
result = submit(ENDPOINTS["images_to_video"], payload, timeout)
|
|
return first_url(result, ENDPOINTS["images_to_video"])
|
|
|
|
|
|
def reference_to_video(
|
|
image_url: str,
|
|
prompt: str,
|
|
duration: float,
|
|
*,
|
|
resolution: str = "1080p",
|
|
timeout: int = 900,
|
|
) -> str:
|
|
"""Generate one shot from a style-reference image. Returns the video URL.
|
|
|
|
``duration`` arrives as a float from ``Cadence.plan_shots`` but hosted
|
|
video models quantize to whole seconds within a supported range, so it is
|
|
rounded and clamped here. Callers that care about the discrepancy should
|
|
record both values (apply.py does).
|
|
"""
|
|
payload = {
|
|
"prompt": prompt,
|
|
"reference_image_urls": [image_url],
|
|
# Same reasoning as vlm_describe: cover both singular and plural key
|
|
# spellings so a payload-schema drift does not break the run.
|
|
"image_url": image_url,
|
|
"duration": quantize_duration(duration),
|
|
"resolution": resolution,
|
|
}
|
|
result = submit(ENDPOINTS["reference_to_video"], payload, timeout)
|
|
return first_url(result, ENDPOINTS["reference_to_video"])
|
|
|
|
|
|
def quantize_duration(duration: float, lo: int = 3, hi: int = 12) -> int:
|
|
"""Round a planned shot length onto the video model's supported grid."""
|
|
return int(max(lo, min(hi, round(float(duration)))))
|
|
|
|
|
|
def text_to_image(
|
|
prompt: str,
|
|
image_refs: list[str] | None = None,
|
|
*,
|
|
timeout: int = 300,
|
|
) -> list[str]:
|
|
"""Generate stills, optionally conditioned on reference images."""
|
|
payload: dict[str, Any] = {"prompt": prompt, "num_images": 1}
|
|
if image_refs:
|
|
payload["image_urls"] = list(image_refs)
|
|
result = submit(ENDPOINTS["text_to_image"], payload, timeout)
|
|
urls = parse_urls(result)
|
|
if not urls:
|
|
raise FalError(f"no image URL in response from {ENDPOINTS['text_to_image']}")
|
|
return urls
|
|
|
|
|
|
def extract_frame(video_url: str, timestamp: float, *, timeout: int = 300) -> str:
|
|
"""Pull a single frame out of a hosted video. Returns the image URL."""
|
|
payload = {"video_url": video_url, "timestamp": round(float(timestamp), 3)}
|
|
result = submit(ENDPOINTS["extract_frame"], payload, timeout)
|
|
return first_url(result, ENDPOINTS["extract_frame"])
|
|
|
|
|
|
def compose(tracks: list[dict], *, timeout: int = 900) -> str:
|
|
"""Composite timeline tracks into one video. Returns the output URL.
|
|
|
|
``tracks`` is passed straight through so the caller owns the timeline
|
|
shape; the ffmpeg-api track schema is another default worth verifying
|
|
before a live run.
|
|
"""
|
|
result = submit(ENDPOINTS["compose"], {"tracks": tracks}, timeout)
|
|
return first_url(result, ENDPOINTS["compose"])
|
|
|
|
|
|
def merge_videos(video_urls: list[str], *, timeout: int = 900) -> str:
|
|
"""Concatenate videos end to end. Returns the merged URL."""
|
|
if not video_urls:
|
|
raise FalError("merge_videos() needs at least one video URL")
|
|
payload = {"video_urls": list(video_urls)}
|
|
result = submit(ENDPOINTS["merge_videos"], payload, timeout)
|
|
return first_url(result, ENDPOINTS["merge_videos"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# download
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 * 1024 # bounded large video/GLB downloads
|
|
|
|
|
|
def _validate_download_url(url: str) -> None:
|
|
try:
|
|
parsed = urllib.parse.urlsplit(url)
|
|
host = parsed.hostname or ""
|
|
valid = (parsed.scheme == "https" and not parsed.username
|
|
and not parsed.password and parsed.port in (None, 443)
|
|
and (host == "fal.media" or host.endswith(".fal.media")))
|
|
except ValueError:
|
|
valid = False
|
|
if not valid:
|
|
raise FalError("download requires HTTPS on an approved fal.media host")
|
|
|
|
|
|
class _SafeRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
_validate_download_url(newurl)
|
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
|
|
|
|
def download(url: str, dest: str | Path) -> Path:
|
|
"""Bounded HTTPS download; failed transfers preserve existing destinations."""
|
|
dest = Path(dest)
|
|
if is_dry_run():
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_bytes(b"taste-forge dry-run placeholder\n")
|
|
log.info("[dry-run] would download from %s", safe_url(url))
|
|
return dest
|
|
|
|
require_live()
|
|
_validate_download_url(url)
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
log.info("downloading from %s", safe_url(url))
|
|
req = urllib.request.Request(url, headers={"User-Agent": "taste-forge"})
|
|
opener = urllib.request.build_opener(_SafeRedirect())
|
|
temporary = None
|
|
try:
|
|
with opener.open(req, timeout=300) as resp:
|
|
declared = getattr(resp, "headers", {}).get("Content-Length")
|
|
expected = int(declared) if declared is not None else None
|
|
if expected is not None and not 0 <= expected <= MAX_DOWNLOAD_BYTES:
|
|
raise FalError("download declares an invalid or excessive size")
|
|
with tempfile.NamedTemporaryFile(dir=dest.parent, prefix=".taste-download-",
|
|
delete=False) as fh:
|
|
temporary = Path(fh.name)
|
|
total = 0
|
|
while True:
|
|
chunk = resp.read(min(1024 * 1024, MAX_DOWNLOAD_BYTES - total + 1))
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > MAX_DOWNLOAD_BYTES:
|
|
raise FalError("download exceeds maximum allowed size")
|
|
fh.write(chunk)
|
|
if expected is not None and total != expected:
|
|
raise FalError("download length does not match declared size")
|
|
os.replace(temporary, dest)
|
|
temporary = None
|
|
except FalError:
|
|
raise
|
|
except Exception:
|
|
raise FalError("download failed; existing destination preserved") from None
|
|
finally:
|
|
if temporary is not None:
|
|
temporary.unlink(missing_ok=True)
|
|
return dest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dry-run stubs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _digest(*parts: Any) -> str:
|
|
h = hashlib.sha256("|".join(str(p) for p in parts).encode("utf-8"))
|
|
return h.hexdigest()[:12]
|
|
|
|
|
|
def _stub_from_schema(schema: dict) -> dict:
|
|
"""Build a stub object with the same keys and types as ``schema``."""
|
|
out: dict[str, Any] = {}
|
|
for key, example in schema.items():
|
|
if isinstance(example, list):
|
|
out[key] = [f"dry-run-{key}-{i}" for i in range(1, 4)]
|
|
elif isinstance(example, bool):
|
|
out[key] = example
|
|
elif isinstance(example, (int, float)):
|
|
out[key] = example
|
|
else:
|
|
out[key] = f"dry-run {key}: {example}" if example else f"dry-run {key}"
|
|
return out
|
|
|
|
|
|
def _stub(endpoint: str, payload: dict) -> dict:
|
|
"""A plausible, deterministic response for ``endpoint``.
|
|
|
|
Deterministic because it is keyed on the payload digest: two different
|
|
shots get two different URLs, so a dry-run manifest still demonstrates
|
|
that every shot was distinct and reproducible.
|
|
"""
|
|
tag = _digest(endpoint, sorted(payload.items(), key=lambda kv: kv[0]))
|
|
base = f"{DRY_RUN_HOST}/{tag}"
|
|
|
|
if endpoint == ENDPOINTS["vlm"]:
|
|
return {"output": json.dumps({"note": "dry-run VLM output", "payload_digest": tag})}
|
|
if endpoint in (ENDPOINTS["retopology"], ENDPOINTS["part_split"]):
|
|
return {"parts": [{"url": f"{base}/part_{i}.glb"} for i in range(3)],
|
|
"model_mesh": {"url": f"{base}/retopo.glb"}}
|
|
if endpoint in (ENDPOINTS["image_to_3d"], ENDPOINTS["text_to_3d"]):
|
|
return {
|
|
"model_mesh": {
|
|
"url": f"{base}/mesh.glb",
|
|
"file_name": "mesh.glb",
|
|
"content_type": "model/gltf-binary",
|
|
"file_size": 1_048_576,
|
|
}
|
|
}
|
|
if endpoint == ENDPOINTS["reference_to_video"]:
|
|
return {
|
|
"video": {"url": f"{base}/shot.mp4", "content_type": "video/mp4"},
|
|
"seed": int(tag[:6], 16),
|
|
}
|
|
if endpoint == ENDPOINTS["text_to_image"]:
|
|
return {"images": [{"url": f"{base}/image.png", "width": 1920, "height": 1080}]}
|
|
if endpoint == ENDPOINTS["extract_frame"]:
|
|
return {"image": {"url": f"{base}/frame.png", "content_type": "image/png"}}
|
|
if endpoint in (ENDPOINTS["compose"], ENDPOINTS["merge_videos"],
|
|
ENDPOINTS["images_to_video"]):
|
|
return {"video": {"url": f"{base}/out.mp4", "content_type": "video/mp4"}}
|
|
|
|
return {"output": {"url": f"{base}/output.bin"}, "endpoint": endpoint}
|