Files
ECC/skills/taste-distillation/scripts/taste/falapi.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

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}