"""Wan Studio — Gradio entry point (design v2/2: Linear-inspired). Refined dev-tool dark: warm near-black surface, Geist typography, hairline borders, restrained accent. T2V + I2V Generate buttons are wired to the real `pipelines.{t2v,i2v}` handles (Wave F+G). All other tabs (TI2V, FLF2V, V2V, VACE, S2V, Animate) still fire a no-op toast until their pipelines are wired in later waves. """ from __future__ import annotations # ── HF cache redirect (must precede every huggingface_hub touch) ──────── # /home/user/.cache/ on ZeroGPU isn't owned by the runtime user (preload # daemon owns it) so xet_get / snapshot_download permission-deny on writes. # /tmp/hf_cache is world-writable. Space-level env vars are also set via # api.add_space_variable for redundancy — these defaults are a belt+braces. import os as _os _os.environ.setdefault("HF_HUB_CACHE", "/tmp/hf_cache") _os.environ.setdefault("HF_HOME", "/tmp/hf_cache") # Fast downloads on huggingface_hub 1.x: hf_transfer is REMOVED there (the # HF_HUB_ENABLE_HF_TRANSFER flag is a no-op + warns), so Xet is the only # accelerator — and it only speeds Xet-backed repos. Re-enable Xet (the earlier # DISABLE was for a default-cache permission issue the /tmp redirect already # fixed) so any Xet-backed repo downloads fast. NOTE: the techfreakworm mirrors # are LFS-backed (not Xet) → they still fall back to regular HTTP (~150-300 MB/s); # the real fix for them is persistent storage (download once) or Xet-migration. _os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1") try: _os.makedirs("/tmp/hf_cache", exist_ok=True) except (PermissionError, OSError): pass # IMPORTANT: import `spaces` BEFORE any CUDA-related package (torch, diffusers, # transformers, peft) so the ZeroGPU runtime can fork CUDA correctly. Once # torch has touched CUDA, `import spaces` raises: # RuntimeError: CUDA has been initialized before importing the `spaces` package. # `pipelines` and `utils` both transitively import torch, so this MUST stay # at the top of the entry-point file. Outside ZeroGPU the import is a no-op. try: import spaces # noqa: F401 except ImportError: pass import os import gradio as gr from pipelines import modes_in from ui import build_all_tabs, build_header, build_sidebar from utils import detect # ── Startup probe: log filesystem permissions for cache paths ─────────── def _probe_filesystem() -> None: if os.getenv("SPACES_ZERO_GPU") is None: return print(f"=== FS PROBE: uid={os.getuid()} gid={os.getgid()} ===", flush=True) paths = ["/", "/tmp", "/home/user", "/home/user/.cache", "/home/user/.cache/huggingface", "/home/user/app", "/tmp/hf_cache", "/data", "/models", "/models/wan-lightning-loras"] for p in paths: try: st = os.stat(p) owner = f"uid={st.st_uid} gid={st.st_gid} mode={oct(st.st_mode)[-3:]}" try: tmp = os.path.join(p, f".test_write_{os.getpid()}") with open(tmp, "w") as f: f.write("ok") os.unlink(tmp) wr = "WRITABLE" except (PermissionError, OSError) as e: wr = f"NO-WRITE ({type(e).__name__})" print(f" {p:<45} {owner:<45} {wr}", flush=True) except FileNotFoundError: print(f" {p:<45} NOT-FOUND", flush=True) print("=== END FS PROBE ===", flush=True) # ── Stitch the Wan 2.2 T2V dir at startup ────────────────────────────── # Combines the read-only /models// volume mount (weights) with # bundled models_meta// JSONs (correct configs — mount truncates # small text files). Result is a /tmp/wan-stitched// dir that # from_pretrained can read directly. Zero downloads, zero container disk # for weights — they're symlinks pointing into the read-only mount. def _stitch_default_model() -> None: if os.getenv("SPACES_ZERO_GPU") is None: return try: from pipelines.handle import stitch_local_dir from pipelines.registry import BY_KEY import time as _t key = "wan2.2_t2v_a14b" print(f"=== STITCH {key} ===", flush=True) t0 = _t.time() path = stitch_local_dir(BY_KEY[key]) if path: print(f"=== STITCH done in {int(_t.time()-t0)}s → {path} ===", flush=True) else: print(f"=== STITCH SKIPPED ({key} mount or meta missing) ===", flush=True) except Exception as e: import traceback print(f"=== STITCH FAILED: {type(e).__name__}: {e} ===", flush=True) traceback.print_exc() # ── Module-level load of the default model (the canonical ZeroGPU pattern) ── # ZeroGPU loads models at MODULE/STARTUP level under CUDA-emulation, bounded by # the README startup_duration_timeout (30m) — NOT inside the @spaces.GPU window # (60-120s). Our earlier lazy-in-worker load was the documented anti-pattern and # is exactly why generations aborted: a 14B's ~9-min volume-mount read cannot fit # a per-request GPU budget. So we load + place on cuda HERE (called from main() # before launch) under emulation; the emulation offloads to disk and materializes # on the real GPU per request, so the @spaces.GPU handler is inference-only # (fast). The Space reaches RUNNING after this one-time load (APP_STARTING # meanwhile). Mirrors the multimodalart/wan-2-2-first-last-frame reference. # # Default is the Wan 2.1 14B single transformer (~28 GB, fits 48 GB `large` in # bf16 — no FP8 needed). The 70 GB MoE needs FP8 to fit VRAM (separate work). # Override the default model via WAN_STUDIO_DEFAULT_T2V. def _prefetch_default_t2v() -> None: """Download the default T2V model at STARTUP — module-level, UNMETERED (runs in the 30m startup_duration_timeout, no GPU attached) — so the metered @spaces.GPU call never downloads. `ensure_loaded` ONLY (CDN → disk + CPU RAM); cuda-attach happens per request in the worker. CDN is ~194 MB/s, so a 14B (~42 GB) prefetches in ~3.5 min — well within the startup budget. On by default now that the source is the fast CDN (set WAN_STUDIO_STARTUP_LOAD=0 to skip — the per-Generate prepare step still avoids metered downloads).""" if os.getenv("SPACES_ZERO_GPU") is None: return if os.getenv("WAN_STUDIO_STARTUP_LOAD", "1") != "1": print("=== startup prefetch disabled (WAN_STUDIO_STARTUP_LOAD=0) ===", flush=True) return try: from pipelines.t2v import T2VHandle import time as _t key = os.getenv("WAN_STUDIO_DEFAULT_T2V", "wan2.1_t2v_14b") print(f"=== STARTUP PREFETCH (unmetered): {key} ===", flush=True) t0 = _t.time() handle = T2VHandle.for_key(key) # Retry: the HF CDN occasionally drops a connection mid-stream # (RemoteProtocolError); completed shards stay cached so a retry resumes. for attempt in range(1, 5): try: handle.ensure_loaded() # CDN → disk + CPU RAM; NO cuda (per-request) break except Exception as e: print(f"=== PREFETCH attempt {attempt}/4 failed: {type(e).__name__}: {str(e)[:120]} ===", flush=True) if attempt == 4: raise T2V_HANDLES[key] = handle print(f"=== STARTUP PREFETCH done in {int(_t.time()-t0)}s — {key} cached on CPU ===", flush=True) except Exception as e: import traceback print(f"=== STARTUP PREFETCH FAILED: {type(e).__name__}: {e} (prepare step still covers it) ===", flush=True) traceback.print_exc() _probe_filesystem() _stitch_default_model() # NB: the default model is PREFETCHED (downloaded to disk/CPU) at startup by # _prefetch_default_t2v(), called from main() before demo.launch() (it needs # T2V_HANDLES, defined below). Per-Generate downloads are handled by the non-GPU # _prepare_* step so the metered @spaces.GPU call never downloads. # ──────────────────────────────────────────────────────────────────────────── # Generate-handler helpers — kept at module scope so the `@spaces.GPU(...)` # decorator can reference `duration=`/`size=` callables that match the wrapped # function's signature. Everything heavy (diffusers, torch, model handles) # stays lazy: nothing imports diffusers or instantiates a handle at module # import time, so `from app import build; build()` is cheap. # ──────────────────────────────────────────────────────────────────────────── # Per-key handle caches. Populated on first Generate click for that key. T2V_HANDLES: dict = {} I2V_HANDLES: dict = {} V2V_HANDLES: dict = {} FLF2V_HANDLES: dict = {} TI2V_HANDLES: dict = {} VACE_HANDLES: dict = {} # Every per-mode handle cache. `_evict_all_handles_except` scans all of them so # switching modes keeps at most ONE model resident (CPU RAM + VRAM). _ALL_HANDLE_CACHES: list[dict] = [ T2V_HANDLES, I2V_HANDLES, V2V_HANDLES, FLF2V_HANDLES, TI2V_HANDLES, VACE_HANDLES, ] def _t2v_key_for(generation: str) -> str: """Map generation selector → registry key for T2V.""" key = "wan2.2_t2v_a14b" if generation == "wan2.2" else "wan2.1_t2v_14b" # Local-dev override: allow forcing 1.3B for faster MPS smoke local_override = os.getenv("WAN_STUDIO_T2V_LOCAL_KEY") if local_override and os.getenv("SPACES_ZERO_GPU") is None: key = local_override return key def _i2v_key_for(generation: str, resolution_label: str) -> str: """Map (generation, resolution) → registry key for I2V.""" if generation == "wan2.2": key = "wan2.2_i2v_a14b" elif "720" in (resolution_label or ""): key = "wan2.1_i2v_14b_720p" else: key = "wan2.1_i2v_14b_480p" local_override = os.getenv("WAN_STUDIO_I2V_LOCAL_KEY") if local_override and os.getenv("SPACES_ZERO_GPU") is None: key = local_override return key def _get_t2v_handle(generation: str): """Lazy-load + cache a T2VHandle keyed by registry key.""" from pipelines.t2v import T2VHandle key = _t2v_key_for(generation) if key not in T2V_HANDLES: T2V_HANDLES[key] = T2VHandle.for_key(key) return T2V_HANDLES[key] def _get_i2v_handle(generation: str, resolution_label: str): """Lazy-load + cache an I2VHandle keyed by registry key.""" from pipelines.i2v import I2VHandle key = _i2v_key_for(generation, resolution_label) if key not in I2V_HANDLES: I2V_HANDLES[key] = I2VHandle.for_key(key) return I2V_HANDLES[key] def _parse_resolution(label: str) -> tuple[int, int]: """'1280 × 720 (16:9)' or '1280x720 (16:9)' → (height, width).""" import re m = re.search(r"(\d+)\s*[x×]\s*(\d+)", label or "") if not m: return 720, 1280 w, h = int(m.group(1)), int(m.group(2)) return h, w # --- @spaces.GPU(duration=callable) — duration is dynamic per-args, but size # MUST be a static literal ('large' | 'xlarge'). Passing a callable for size # silently serializes the function object into the /schedule POST body and HF # rejects with 422. Both T2V + I2V modes are bounded to 'large' on PRO tier # (utils.budget.MODE_BUDGET) so we hard-code 'large' on the decorator below. def _get_t2v_duration(prompt, generation, preset_label, resolution_label, duration_s, *args, **kwargs): from utils.budget import duration_for return duration_for(_t2v_key_for(generation), duration_s=float(duration_s or 3.0)) def _get_i2v_duration(image, prompt, generation, preset_label, resolution_label, duration_s, *args, **kwargs): from utils.budget import duration_for return duration_for(_i2v_key_for(generation, resolution_label), duration_s=float(duration_s or 3.0)) def _coerce_preset(preset_label: str) -> str: """Header `preset_state` may carry either the literal 'fast'/'quality' string or a user-facing 'Fast'/'Quality' label — normalize to lower.""" return "fast" if preset_label and str(preset_label).lower().startswith("fast") else "quality" def _raise_user_error(e: BaseException) -> None: """Translate a low-level exception into a gr.Error toast for the user.""" # OOM: hard to recover from in-handler, so message the user with a hint. try: import torch if hasattr(torch, "cuda") and hasattr(torch.cuda, "OutOfMemoryError") and isinstance(e, torch.cuda.OutOfMemoryError): raise gr.Error(f"GPU out of memory. Try a smaller resolution or shorter duration. ({e})") from e except ImportError: pass if isinstance(e, FileNotFoundError): raise gr.Error(f"Model files not found — volume mount may be missing on the Space. ({e})") from e import traceback print(traceback.format_exc()) raise gr.Error(f"Generation failed: {type(e).__name__}: {e}") from e def _evict_all_handles_except(keep_handle) -> None: """Bound resident memory to ONE model: drop every cached handle except `keep_handle` (across ALL per-mode caches), freeing CPU RAM + VRAM. Evicts by handle IDENTITY, not by registry key, because some modes share a key — e.g. V2V reuses the Wan 2.1 T2V-14B card — so a key-based evict would leave two distinct pipelines resident under the same key. Downloaded weight files stay in the disk cache, so re-selecting a model reloads from disk without re-downloading.""" import gc import torch for cache in _ALL_HANDLE_CACHES: for k in list(cache): if cache[k] is keep_handle: continue try: cache[k].unload_to_cpu() except Exception: pass del cache[k] gc.collect() if torch.cuda.is_available(): torch.cuda.empty_cache() def _prepare_t2v(generation: str): """NON-GPU prepare step (UNMETERED): download + CPU-load the selected T2V model BEFORE the @spaces.GPU step, so the metered call never downloads. Chained via .click(_prepare_t2v, …).then(, …).""" key = _t2v_key_for(generation) handle = _get_t2v_handle(generation) _evict_all_handles_except(handle) if handle.pipe is None: gr.Info("Preparing model (first use downloads weights — not billed to GPU time)…", duration=8) handle.ensure_loaded() # CDN → disk + CPU RAM (unmetered) print(f"=== PREPARE T2V ready on CPU: {key} ===", flush=True) def _prepare_i2v(generation: str, resolution_label: str): """NON-GPU prepare step for I2V (see _prepare_t2v).""" key = _i2v_key_for(generation, resolution_label) handle = _get_i2v_handle(generation, resolution_label) _evict_all_handles_except(handle) if handle.pipe is None: gr.Info("Preparing model (first use downloads weights — not billed to GPU time)…", duration=8) handle.ensure_loaded() print(f"=== PREPARE I2V ready on CPU: {key} ===", flush=True) def _build_t2v_handler(): """T2V Generate handler with large/xlarge slice routing. Wan 2.1 (single transformer, ~42 GB bf16) → `large` (48 GB, 1× quota). Wan 2.2 MoE (~70 GB bf16) → `xlarge` (96 GB, 2× quota). `@spaces.GPU` `size` must be a STATIC literal (a callable serializes the fn → 422), so we wrap a shared impl in two decorated GPU functions and pick the slice in a plain (non-GPU) dispatcher by generation. Progress bar dropped (function-over- polish; also avoids passing gr.Progress across the slice router). """ from utils.backend import spaces_gpu_or_noop gpu = spaces_gpu_or_noop() def _impl(prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override): import random import tempfile from diffusers.utils import export_to_video if not prompt or not str(prompt).strip(): raise gr.Error("Prompt is required.") try: if randomize: seed = random.randint(0, 2**31 - 1) preset = _coerce_preset(preset_label) handle = _get_t2v_handle(generation) print(f"=== T2V generate: gen={generation} preset={preset} ===", flush=True) preset_kwargs = handle.configure_preset(preset) inference_kwargs = { "num_inference_steps": ( int(steps_override) if steps_override and int(steps_override) > 0 else preset_kwargs.num_inference_steps ), "guidance_scale": ( float(cfg_override) if cfg_override and float(cfg_override) > 0 else preset_kwargs.guidance_scale ), } if preset_kwargs.guidance_scale_2 is not None: inference_kwargs["guidance_scale_2"] = ( float(cfg_2_override) if cfg_2_override and float(cfg_2_override) > 0 else preset_kwargs.guidance_scale_2 ) height, width = _parse_resolution(resolution_label) num_frames = max(17, int(float(duration_s) * 16) // 4 * 4 + 1) frames = handle.generate( prompt=prompt, negative_prompt=negative_prompt or "", height=height, width=width, num_frames=num_frames, seed=int(seed), preset_kwargs=inference_kwargs, ) fd, out_path = tempfile.mkstemp(suffix=".mp4", prefix="wan_t2v_") os.close(fd) export_to_video(frames, out_path, fps=16) if preset_kwargs.fallback_message: gr.Info(preset_kwargs.fallback_message, duration=8) return out_path except gr.Error: raise except Exception as e: _raise_user_error(e) @gpu(duration=_get_t2v_duration, size="large") def _run_large(prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override): return _impl(prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override) @gpu(duration=_get_t2v_duration, size="xlarge") def _run_xlarge(prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override): return _impl(prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override) def router(prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override): args = (prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override) if str(generation) == "wan2.2": # MoE → xlarge (2× quota) gr.Info("⚡ Wan 2.2 runs on the 96 GB xlarge GPU — it uses your daily GPU quota ~2× faster.", duration=7) return _run_xlarge(*args) return _run_large(*args) return router def _build_i2v_handler(): """I2V Generate handler with large/xlarge slice routing (mirror of T2V). Wan 2.1 I2V-14B (480P/720P) → `large`; Wan 2.2 I2V-A14B MoE → `xlarge` (2× quota). Auto-picks the checkpoint by (generation × resolution). """ from utils.backend import spaces_gpu_or_noop gpu = spaces_gpu_or_noop() def _impl(image, prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override): import random import tempfile from diffusers.utils import export_to_video from PIL import Image if image is None: raise gr.Error("Please upload an image.") if not prompt or not str(prompt).strip(): raise gr.Error("Motion prompt is required.") try: if isinstance(image, str): image = Image.open(image).convert("RGB") elif hasattr(image, "convert"): image = image.convert("RGB") if randomize: seed = random.randint(0, 2**31 - 1) preset = _coerce_preset(preset_label) handle = _get_i2v_handle(generation, resolution_label) print(f"=== I2V generate: gen={generation} preset={preset} ===", flush=True) preset_kwargs = handle.configure_preset(preset) inference_kwargs = { "num_inference_steps": ( int(steps_override) if steps_override and int(steps_override) > 0 else preset_kwargs.num_inference_steps ), "guidance_scale": ( float(cfg_override) if cfg_override and float(cfg_override) > 0 else preset_kwargs.guidance_scale ), } if preset_kwargs.guidance_scale_2 is not None: inference_kwargs["guidance_scale_2"] = ( float(cfg_2_override) if cfg_2_override and float(cfg_2_override) > 0 else preset_kwargs.guidance_scale_2 ) h_label, w_label = _parse_resolution(resolution_label) max_area = h_label * w_label num_frames = max(17, int(float(duration_s) * 16) // 4 * 4 + 1) frames = handle.generate( image=image, prompt=prompt, negative_prompt=negative_prompt or "", max_area=max_area, num_frames=num_frames, seed=int(seed), preset_kwargs=inference_kwargs, ) fd, out_path = tempfile.mkstemp(suffix=".mp4", prefix="wan_i2v_") os.close(fd) export_to_video(frames, out_path, fps=16) if preset_kwargs.fallback_message: gr.Info(preset_kwargs.fallback_message, duration=8) return out_path except gr.Error: raise except Exception as e: _raise_user_error(e) @gpu(duration=_get_i2v_duration, size="large") def _run_large(image, prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override): return _impl(image, prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override) @gpu(duration=_get_i2v_duration, size="xlarge") def _run_xlarge(image, prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override): return _impl(image, prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override) def router(image, prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override): args = (image, prompt, generation, preset_label, resolution_label, duration_s, negative_prompt, seed, randomize, steps_override, cfg_override, cfg_2_override) if str(generation) == "wan2.2": # MoE → xlarge (2× quota) gr.Info("⚡ Wan 2.2 runs on the 96 GB xlarge GPU — it uses your daily GPU quota ~2× faster.", duration=7) return _run_xlarge(*args) return _run_large(*args) return router # ──────────────────────────────────────────────────────────────────────────── # Shared inference helpers — used by the newer mode handlers (V2V/FLF2V/TI2V/…). # The proven T2V/I2V handlers above intentionally keep their own inlined logic # (they're the confirmed-green floor; not refactoring them avoids regression). # ──────────────────────────────────────────────────────────────────────────── def _preset_to_kwargs(pk) -> dict: """PresetKwargs → the inference-kwargs dict handle.generate(...) expects. Uses the preset's resolved step/guidance values directly. The newer modes do NOT honor the Advanced steps/CFG sliders: those default to 4/1.0 (Lightning values), which would silently under-denoise a quality-only model (TI2V/FLF2V/ VACE run 30-50 steps). Wiring the sliders for those modes is deferred until the slider default can mean 'auto' without touching the shared accordion.""" d = {"num_inference_steps": pk.num_inference_steps, "guidance_scale": pk.guidance_scale} if pk.guidance_scale_2 is not None: d["guidance_scale_2"] = pk.guidance_scale_2 return d def _export_frames(frames, prefix: str, fps: int) -> str: """Write a list of frames to a temp .mp4 and return the path.""" import tempfile from diffusers.utils import export_to_video fd, out_path = tempfile.mkstemp(suffix=".mp4", prefix=prefix) os.close(fd) export_to_video(frames, out_path, fps=fps) return out_path def _decode_video(path: str, *, max_frames: int = 81, size: tuple[int, int] = (832, 480)) -> list: """Decode an uploaded video file → list of PIL RGB frames for V2V. Resizes each frame to `size` (W, H — a multiple of the Wan VAE grid) and trims the count to the largest 4k+1 ≤ max_frames (the Wan temporal VAE needs (num_frames - 1) % 4 == 0).""" import imageio.v3 as iio from PIL import Image raw = [] for i, frame in enumerate(iio.imiter(path)): if i >= max_frames: break raw.append(Image.fromarray(frame).convert("RGB").resize(size)) if len(raw) >= 5: n = ((len(raw) - 1) // 4) * 4 + 1 # coerce to 4k+1 raw = raw[:n] return raw # ──────────────────────────────────────────────────────────────────────────── # V2V — Wan 2.1 video restyle on the T2V-14B backbone (2.1-only → `large`). # ──────────────────────────────────────────────────────────────────────────── def _get_v2v_handle(): from pipelines.v2v import V2VHandle key = "wan2.1_t2v_14b" # V2V reuses the T2V-14B card; no separate checkpoint if key not in V2V_HANDLES: V2V_HANDLES[key] = V2VHandle.for_key(key) return V2V_HANDLES[key] def _v2v_duration(*_args, **_kwargs): from utils.budget import duration_for return duration_for("wan2.1_t2v_14b", duration_s=3.0) def _prepare_v2v(): handle = _get_v2v_handle() _evict_all_handles_except(handle) if handle.pipe is None: gr.Info("Preparing V2V (first use downloads the Wan 2.1 backbone — not billed to GPU time)…", duration=8) handle.ensure_loaded() print("=== PREPARE V2V ready on CPU: wan2.1_t2v_14b ===", flush=True) def _build_v2v_handler(): from utils.backend import spaces_gpu_or_noop gpu = spaces_gpu_or_noop() def _impl(video_path, prompt, strength, preset_label, negative_prompt, seed, randomize): import random if not video_path: raise gr.Error("Please upload a source video.") if not prompt or not str(prompt).strip(): raise gr.Error("Restyle prompt is required.") try: if randomize: seed = random.randint(0, 2**31 - 1) preset = _coerce_preset(preset_label) handle = _get_v2v_handle() print(f"=== V2V generate: preset={preset} strength={strength} ===", flush=True) preset_kwargs = handle.configure_preset(preset) frames = _decode_video(video_path, max_frames=81) if len(frames) < 5: raise gr.Error("Source video too short — need at least 5 frames.") out_frames = handle.generate( video=frames, prompt=prompt, negative_prompt=negative_prompt or "", strength=float(strength), seed=int(seed), preset_kwargs=_preset_to_kwargs(preset_kwargs), ) out_path = _export_frames(out_frames, "wan_v2v_", fps=16) if preset_kwargs.fallback_message: gr.Info(preset_kwargs.fallback_message, duration=8) return out_path except gr.Error: raise except Exception as e: _raise_user_error(e) @gpu(duration=_v2v_duration, size="large") def _run_large(video_path, prompt, strength, preset_label, negative_prompt, seed, randomize): return _impl(video_path, prompt, strength, preset_label, negative_prompt, seed, randomize) return _run_large # ──────────────────────────────────────────────────────────────────────────── # FLF2V — Wan 2.1 first-last-frame interpolation (2.1-only → `large`; quality). # ──────────────────────────────────────────────────────────────────────────── def _get_flf2v_handle(): from pipelines.flf2v import FLF2VHandle key = "wan2.1_flf2v_14b_720p" if key not in FLF2V_HANDLES: FLF2V_HANDLES[key] = FLF2VHandle.for_key(key) return FLF2V_HANDLES[key] def _flf2v_duration(*_args, **_kwargs): from utils.budget import duration_for return duration_for("wan2.1_flf2v_14b_720p", duration_s=3.0) def _prepare_flf2v(): handle = _get_flf2v_handle() _evict_all_handles_except(handle) if handle.pipe is None: gr.Info("Preparing FLF2V (first use downloads weights — not billed to GPU time)…", duration=8) handle.ensure_loaded() print("=== PREPARE FLF2V ready on CPU: wan2.1_flf2v_14b_720p ===", flush=True) def _build_flf2v_handler(): from utils.backend import spaces_gpu_or_noop gpu = spaces_gpu_or_noop() def _coerce_img(img): from PIL import Image if isinstance(img, str): return Image.open(img).convert("RGB") if hasattr(img, "convert"): return img.convert("RGB") return img def _impl(first_image, last_image, prompt, preset_label, negative_prompt, seed, randomize): import random if first_image is None or last_image is None: raise gr.Error("Please provide both a start frame and an end frame.") if not prompt or not str(prompt).strip(): raise gr.Error("Transition prompt is required.") try: first_image = _coerce_img(first_image) last_image = _coerce_img(last_image) if randomize: seed = random.randint(0, 2**31 - 1) preset = _coerce_preset(preset_label) handle = _get_flf2v_handle() print(f"=== FLF2V generate: preset={preset} ===", flush=True) preset_kwargs = handle.configure_preset(preset) out_frames = handle.generate( first_image=first_image, last_image=last_image, prompt=prompt, negative_prompt=negative_prompt or "", num_frames=49, seed=int(seed), preset_kwargs=_preset_to_kwargs(preset_kwargs), ) out_path = _export_frames(out_frames, "wan_flf2v_", fps=16) if preset_kwargs.fallback_message: gr.Info(preset_kwargs.fallback_message, duration=8) return out_path except gr.Error: raise except Exception as e: _raise_user_error(e) @gpu(duration=_flf2v_duration, size="large") def _run_large(first_image, last_image, prompt, preset_label, negative_prompt, seed, randomize): return _impl(first_image, last_image, prompt, preset_label, negative_prompt, seed, randomize) return _run_large # ──────────────────────────────────────────────────────────────────────────── # TI2V — Wan 2.2 TI2V-5B via WanPipeline (own VAE). Small (~24 GB) → `large`. # WanPipeline (diffusers 0.38) has no image= kwarg, so this is the T2V path of # the 5B model; the tab's image input is accepted but not yet conditioned on. # ──────────────────────────────────────────────────────────────────────────── def _get_ti2v_handle(): from pipelines.ti2v import TI2VHandle key = "wan2.2_ti2v_5b" if key not in TI2V_HANDLES: TI2V_HANDLES[key] = TI2VHandle.for_key(key) return TI2V_HANDLES[key] def _ti2v_duration(*_args, **_kwargs): from utils.budget import duration_for return duration_for("wan2.2_ti2v_5b", duration_s=3.0) def _prepare_ti2v(): handle = _get_ti2v_handle() _evict_all_handles_except(handle) if handle.pipe is None: gr.Info("Preparing TI2V-5B (first use downloads weights — not billed to GPU time)…", duration=8) handle.ensure_loaded() print("=== PREPARE TI2V ready on CPU: wan2.2_ti2v_5b ===", flush=True) def _build_ti2v_handler(): from utils.backend import spaces_gpu_or_noop gpu = spaces_gpu_or_noop() def _impl(prompt, orientation, image, preset_label, seed, randomize): import random if not prompt or not str(prompt).strip(): raise gr.Error("Prompt is required.") try: if image is not None: gr.Info("TI2V image-conditioning isn't in diffusers 0.38 yet — generating from text only.", duration=7) if randomize: seed = random.randint(0, 2**31 - 1) preset = _coerce_preset(preset_label) handle = _get_ti2v_handle() # TI2V-5B supports only 1280×704 (landscape) / 704×1280 (portrait). if orientation and "ortrait" in str(orientation): width, height = 704, 1280 else: width, height = 1280, 704 print(f"=== TI2V generate: preset={preset} {width}x{height} ===", flush=True) preset_kwargs = handle.configure_preset(preset) out_frames = handle.generate( prompt=prompt, image=None, height=height, width=width, num_frames=49, seed=int(seed), preset_kwargs=_preset_to_kwargs(preset_kwargs), ) out_path = _export_frames(out_frames, "wan_ti2v_", fps=24) if preset_kwargs.fallback_message: gr.Info(preset_kwargs.fallback_message, duration=8) return out_path except gr.Error: raise except Exception as e: _raise_user_error(e) @gpu(duration=_ti2v_duration, size="large") def _run_large(prompt, orientation, image, preset_label, seed, randomize): return _impl(prompt, orientation, image, preset_label, seed, randomize) return _run_large # ──────────────────────────────────────────────────────────────────────────── # Theme — Linear-faithful warm near-black palette with electric-blue accent. # Built off gr.themes.Base (lowest preset noise) and overridden in CSS below. # ──────────────────────────────────────────────────────────────────────────── THEME = gr.themes.Base( primary_hue=gr.themes.Color( c50="#eef2ff", c100="#e0e7ff", c200="#c7d2fe", c300="#a5b4fc", c400="#818cf8", c500="#5e84ff", c600="#4f6fe5", c700="#4055bf", c800="#323f95", c900="#23306e", c950="#161e4a", ), neutral_hue=gr.themes.Color( c50="#f7f8f8", c100="#e6e7e9", c200="#c1c4cc", c300="#8a8f98", c400="#62666d", c500="#44484e", c600="#2a2d33", c700="#1e2024", c800="#141518", c900="#0d0e10", c950="#08090a", ), font=(gr.themes.GoogleFont("Geist"), "ui-sans-serif", "system-ui", "sans-serif"), font_mono=(gr.themes.GoogleFont("Geist Mono"), "ui-monospace", "monospace"), radius_size=gr.themes.sizes.radius_sm, spacing_size=gr.themes.sizes.spacing_md, text_size=gr.themes.sizes.text_sm, ).set( body_background_fill="#08090a", body_background_fill_dark="#08090a", background_fill_primary="#08090a", background_fill_primary_dark="#08090a", background_fill_secondary="#0d0e10", background_fill_secondary_dark="#0d0e10", block_background_fill="#0d0e10", block_background_fill_dark="#0d0e10", block_border_color="#1e2024", block_border_color_dark="#1e2024", block_border_width="1px", block_label_background_fill="transparent", block_label_background_fill_dark="transparent", block_label_text_color="#8a8f98", block_label_text_color_dark="#8a8f98", block_label_text_size="11px", block_label_text_weight="500", block_title_text_color="#f7f8f8", block_title_text_color_dark="#f7f8f8", body_text_color="#f7f8f8", body_text_color_dark="#f7f8f8", body_text_color_subdued="#8a8f98", body_text_color_subdued_dark="#8a8f98", border_color_primary="#1e2024", border_color_primary_dark="#1e2024", border_color_accent="#5e84ff", border_color_accent_dark="#5e84ff", input_background_fill="#101114", input_background_fill_dark="#101114", input_background_fill_focus="#141518", input_background_fill_focus_dark="#141518", input_border_color="#1e2024", input_border_color_dark="#1e2024", input_border_color_focus="#5e84ff", input_border_color_focus_dark="#5e84ff", input_placeholder_color="#62666d", input_placeholder_color_dark="#62666d", button_primary_background_fill="#5e84ff", button_primary_background_fill_dark="#5e84ff", button_primary_background_fill_hover="#7497ff", button_primary_background_fill_hover_dark="#7497ff", button_primary_text_color="#ffffff", button_primary_text_color_dark="#ffffff", button_primary_border_color="transparent", button_primary_border_color_dark="transparent", button_secondary_background_fill="#141518", button_secondary_background_fill_dark="#141518", button_secondary_background_fill_hover="#1c1e22", button_secondary_background_fill_hover_dark="#1c1e22", button_secondary_text_color="#e6e7e9", button_secondary_text_color_dark="#e6e7e9", button_secondary_border_color="#1e2024", button_secondary_border_color_dark="#1e2024", panel_background_fill="#0d0e10", panel_background_fill_dark="#0d0e10", panel_border_color="#1e2024", panel_border_color_dark="#1e2024", color_accent="#5e84ff", color_accent_soft="#1a2238", color_accent_soft_dark="#1a2238", link_text_color="#7497ff", link_text_color_dark="#7497ff", link_text_color_hover="#a8bcff", link_text_color_hover_dark="#a8bcff", ) # ──────────────────────────────────────────────────────────────────────────── # CSS — Linear-faithful chrome. Heavy use of !important to defeat Gradio's # default specificity. # ──────────────────────────────────────────────────────────────────────────── CSS = """ /* ─── Root surface ─────────────────────────────────────────────────── */ :root, html, body, gradio-app, .gradio-container { --ws-bg: #08090a !important; --ws-surface: #0d0e10 !important; --ws-surface-2: #101114 !important; --ws-elev: #16181c !important; --ws-border: #1e2024 !important; --ws-border-strong: #2a2d33 !important; --ws-fg: #f7f8f8 !important; --ws-fg-dim: #c1c4cc !important; --ws-fg-muted: #8a8f98 !important; --ws-fg-faint: #62666d !important; --ws-accent: #5e84ff !important; --ws-accent-soft: rgba(94, 132, 255, 0.12) !important; --ws-accent-line: rgba(94, 132, 255, 0.30) !important; --ws-amber: #f5a524 !important; --ws-pad: 16px !important; } body, gradio-app, .gradio-container { background: #08090a !important; color: #f7f8f8 !important; font-family: "Geist", "Inter", ui-sans-serif, system-ui, sans-serif !important; font-feature-settings: "cv11", "ss01", "ss03" !important; -webkit-font-smoothing: antialiased !important; -moz-osx-font-smoothing: grayscale !important; } .gradio-container { max-width: 100% !important; padding: 0 !important; margin: 0 !important; } .gradio-container .main, .gradio-container > .wrap, .gradio-container .contain { background: #08090a !important; } /* Reset Gradio's universal ".block" wrapper — we apply card styling selectively below. */ .gradio-container .block { background: transparent !important; border: 0 !important; border-radius: 0 !important; padding: 0 !important; box-shadow: none !important; } /* Suppress Gradio's status-tracker overlay (demo mode — no real generation). */ #ws-content [data-testid="status-tracker"], #ws-content .wrap.generating, #ws-content .wrap.full.generating { display: none !important; } /* Hide Gradio's footer noise. */ footer, .footer, .gradio-container > footer { display: none !important; } .api-docs, .built-with, .show-api { display: none !important; } /* ─── Header chrome ────────────────────────────────────────────────── */ #ws-header { display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important; background: rgba(8, 9, 10, 0.92) !important; backdrop-filter: saturate(180%) blur(14px) !important; -webkit-backdrop-filter: saturate(180%) blur(14px) !important; border-bottom: 1px solid var(--ws-border) !important; padding: 10px 20px !important; margin: 0 !important; position: sticky !important; top: 0 !important; z-index: 50 !important; align-items: center !important; gap: 22px !important; min-height: 64px !important; border-radius: 0 !important; width: 100% !important; overflow: visible !important; } #ws-header > * { background: transparent !important; border: 0 !important; box-shadow: none !important; } .ws-chrome-col { padding: 0 !important; margin: 0 !important; background: transparent !important; border: 0 !important; min-width: 0 !important; flex: 0 0 auto !important; display: flex !important; flex-direction: column !important; justify-content: center !important; gap: 2px !important; } .ws-chrome-col > * { background: transparent !important; border: 0 !important; padding: 0 !important; box-shadow: none !important; } .ws-chrome-col .block, .ws-chrome-col .form { background: transparent !important; border: 0 !important; padding: 0 !important; } .ws-brand-col { flex: 1 1 auto !important; min-width: 0 !important; } .ws-chrome-right { margin-left: auto !important; } #ws-brand-html { background: transparent !important; padding: 0 !important; border: 0 !important; } .ws-brand { display: flex !important; align-items: center !important; gap: 10px !important; } .ws-brand-mark { width: 22px !important; height: 22px !important; border-radius: 6px !important; background: radial-gradient(120% 80% at 30% 20%, #aeb8ff 0%, #5e84ff 40%, #3a52b8 100%) !important; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18), 0 1px 2px rgba(0,0,0,0.6) !important; } .ws-brand-text { display: flex !important; flex-direction: column !important; line-height: 1.1 !important; } .ws-brand-name { color: var(--ws-fg) !important; font-weight: 510 !important; font-size: 14px !important; letter-spacing: -0.012em !important; } .ws-brand-sub { color: var(--ws-fg-muted) !important; font-size: 11px !important; font-weight: 400 !important; letter-spacing: 0 !important; } /* ─── Field labels in header ───────────────────────────────────────── */ .ws-field-label { color: var(--ws-fg-muted) !important; font-size: 11px !important; text-transform: uppercase !important; letter-spacing: 0.06em !important; font-weight: 510 !important; margin: 0 0 6px 0 !important; padding: 0 !important; } /* Hide gradio's own labels for the header dropdown (we provide our own visual hierarchy). */ .ws-chrome-col .ws-dropdown label > span, .ws-chrome-col .ws-dropdown .label-wrap, .ws-chrome-col .ws-dropdown > label > span:first-child { display: none !important; } .ws-chrome-col .ws-dropdown > label { gap: 0 !important; } /* ─── Generation dropdown ──────────────────────────────────────────── */ .ws-dropdown { background: transparent !important; border: 0 !important; padding: 0 !important; } .ws-dropdown .wrap, .ws-dropdown .secondary-wrap, .ws-dropdown .container { background: var(--ws-surface-2) !important; border: 1px solid var(--ws-border) !important; border-radius: 6px !important; min-height: 32px !important; box-shadow: none !important; } .ws-dropdown input, .ws-dropdown .single-select, .ws-dropdown .token { background: transparent !important; color: var(--ws-fg) !important; font-size: 13px !important; font-weight: 510 !important; padding: 5px 10px !important; height: auto !important; } .ws-dropdown:hover .wrap { border-color: var(--ws-border-strong) !important; } .ws-dropdown .wrap:focus-within { border-color: var(--ws-accent) !important; box-shadow: 0 0 0 3px var(--ws-accent-soft) !important; } /* ─── Preset pill toggle ───────────────────────────────────────────── */ .ws-preset-group { display: inline-flex !important; background: var(--ws-surface-2) !important; border: 1px solid var(--ws-border) !important; border-radius: 7px !important; padding: 2px !important; gap: 0 !important; width: max-content !important; flex-wrap: nowrap !important; } .ws-preset-group > * { flex: 0 0 auto !important; } button.ws-pill { background: transparent !important; color: var(--ws-fg-muted) !important; border: 0 !important; border-radius: 5px !important; padding: 4px 12px !important; min-width: 0 !important; min-height: 26px !important; font-size: 12px !important; font-weight: 510 !important; letter-spacing: 0 !important; box-shadow: none !important; transition: background 120ms ease, color 120ms ease !important; } button.ws-pill:hover { color: var(--ws-fg-dim) !important; background: transparent !important; } button.ws-pill-active { background: rgba(94, 132, 255, 0.14) !important; color: #ffffff !important; /* Drop the outer 0 1px 0 shadow that created a faint horizontal line under the segmented control — the sticky header already owns the bottom hairline. */ box-shadow: inset 0 0 0 1px #5e84ff !important; } /* ─── Chrome nav buttons ───────────────────────────────────────────── */ .ws-chrome-actions { gap: 4px !important; justify-content: flex-end !important; flex-wrap: nowrap !important; } button.ws-nav-btn { background: transparent !important; color: var(--ws-fg-dim) !important; border: 1px solid transparent !important; border-radius: 6px !important; padding: 5px 10px !important; font-size: 12px !important; font-weight: 500 !important; min-height: 30px !important; min-width: 0 !important; letter-spacing: 0 !important; box-shadow: none !important; transition: background 120ms ease, border-color 120ms ease, color 120ms ease !important; } button.ws-nav-btn:hover { background: var(--ws-surface-2) !important; border-color: var(--ws-border) !important; color: var(--ws-fg) !important; } /* ─── Sidebar ──────────────────────────────────────────────────────── */ #ws-sidebar { background: #0a0b0d !important; border-right: 1px solid var(--ws-border) !important; padding: 16px 12px !important; min-height: calc(100vh - 56px) !important; flex-shrink: 0 !important; gap: 2px !important; } .ws-side-heading { padding: 6px 10px 4px 10px !important; margin-top: 2px !important; } .ws-side-heading-divider { margin-top: 18px !important; } .ws-side-heading-text { color: var(--ws-fg-muted) !important; font-size: 10.5px !important; font-weight: 510 !important; text-transform: uppercase !important; letter-spacing: 0.08em !important; } button.ws-side-btn { background: transparent !important; color: var(--ws-fg-dim) !important; border: 0 !important; border-radius: 6px !important; padding: 6px 10px 6px 22px !important; margin: 1px 0 !important; width: 100% !important; text-align: left !important; justify-content: flex-start !important; font-size: 13px !important; font-weight: 450 !important; min-height: 28px !important; letter-spacing: -0.005em !important; position: relative !important; box-shadow: none !important; transition: background 120ms ease, color 120ms ease !important; } /* Inactive nav rows have no leading glyph — Linear's nav rests on the selected-state blue marker alone. */ button.ws-side-btn::before { content: "" !important; position: absolute !important; left: 10px !important; top: 50% !important; width: 4px !important; height: 4px !important; border-radius: 50% !important; background: transparent !important; transform: translateY(-50%) !important; opacity: 0 !important; transition: background 120ms ease, opacity 120ms ease, box-shadow 120ms ease !important; } button.ws-side-btn:hover { background: var(--ws-surface-2) !important; color: var(--ws-fg) !important; } /* No `:hover::before` rule — hovering must not override the active dot's `::before` (which has lower specificity than :hover would have). The base `button.ws-side-btn::before` keeps inactive dots invisible regardless. */ button.ws-side-btn-active { background: var(--ws-elev) !important; color: var(--ws-fg) !important; font-weight: 510 !important; } button.ws-side-btn-active::before { background: var(--ws-accent) !important; opacity: 1 !important; box-shadow: 0 0 0 3px rgba(94, 132, 255, 0.18) !important; } .ws-side-footer { margin-top: auto !important; padding: 14px 10px 4px 10px !important; border-top: 1px solid var(--ws-border) !important; margin-top: 24px !important; } .ws-side-footer-row { display: flex !important; justify-content: space-between !important; align-items: center !important; font-size: 10.5px !important; color: var(--ws-fg-muted) !important; font-family: "Geist Mono", ui-monospace, monospace !important; } .ws-side-footer-status { color: #5fa881 !important; } .ws-side-footer-hint { font-size: 10.5px !important; color: var(--ws-fg-faint) !important; margin-top: 3px !important; letter-spacing: -0.005em !important; } /* ─── Tab/content area ─────────────────────────────────────────────── */ #ws-content { background: #08090a !important; padding: 24px 28px !important; min-height: calc(100vh - 56px) !important; } #ws-content > * { background: transparent !important; } #ws-content h2 { font-size: 22px !important; font-weight: 510 !important; letter-spacing: -0.018em !important; color: var(--ws-fg) !important; margin: 0 0 4px 0 !important; } #ws-content h3 { font-size: 14px !important; font-weight: 510 !important; letter-spacing: -0.005em !important; color: var(--ws-fg) !important; margin: 4px 0 !important; } #ws-content p, #ws-content li { color: var(--ws-fg-dim) !important; font-size: 13px !important; line-height: 1.55 !important; } /* Markdown blocks */ .markdown, .markdown * { background: transparent !important; } /* Per-tab heading row — replace plain h2 with a leader rule. */ #ws-content div[id^="tab-"] h2 { display: flex !important; align-items: center !important; gap: 14px !important; padding-bottom: 14px !important; margin: 4px 0 22px 0 !important; border-bottom: 1px solid var(--ws-border) !important; font-size: 22px !important; font-weight: 510 !important; letter-spacing: -0.018em !important; color: var(--ws-fg) !important; } #ws-content div[id^="tab-"] h2::before { content: "" !important; display: inline-block !important; width: 3px !important; height: 18px !important; background: var(--ws-accent) !important; border-radius: 2px !important; flex-shrink: 0 !important; } /* ─── Form blocks (inputs / textareas / dropdowns) ─────────────────── */ .gradio-container input[type="text"], .gradio-container input[type="number"], .gradio-container textarea, .gradio-container .input-text, .gradio-container .scroll-hide { background: var(--ws-surface-2) !important; color: var(--ws-fg) !important; border: 1px solid var(--ws-border) !important; border-radius: 6px !important; font-family: "Geist", ui-sans-serif, system-ui, sans-serif !important; font-size: 13px !important; padding: 8px 10px !important; transition: border-color 120ms ease, box-shadow 120ms ease !important; } .gradio-container input:focus, .gradio-container textarea:focus { outline: 0 !important; border-color: var(--ws-accent) !important; box-shadow: 0 0 0 3px var(--ws-accent-soft) !important; } .gradio-container ::placeholder { color: var(--ws-fg-faint) !important; } /* ─── Card styling (Gradio 6 — .block based) ────────────────────────── */ /* Padded inputs (textbox, slider, dropdown, etc.) get a refined card. */ #ws-content .block.padded:not(.hide-container) { background: var(--ws-surface) !important; border: 1px solid var(--ws-border) !important; border-radius: 8px !important; padding: 14px !important; box-shadow: none !important; } /* Media blocks (Image, Video, Audio, Gallery) — frame without internal padding. */ #ws-content .block:not(.padded):not(.hide-container) { background: linear-gradient(180deg, rgba(94,132,255,0.035) 0%, rgba(8,9,10,0) 60%), var(--ws-surface) !important; border: 1px solid var(--ws-border) !important; border-radius: 8px !important; overflow: hidden !important; position: relative !important; min-height: 180px !important; } #ws-content .block:not(.padded):not(.hide-container) > .wrap { background: transparent !important; min-height: 320px !important; } /* Floating overlay label for media blocks. */ #ws-content .block:not(.padded):not(.hide-container) > [data-testid="block-label"] { position: absolute !important; top: 10px !important; left: 12px !important; background: rgba(8,9,10,0.72) !important; backdrop-filter: blur(6px) !important; border: 1px solid var(--ws-border) !important; border-radius: 4px !important; padding: 3px 8px !important; font-size: 10.5px !important; letter-spacing: 0.07em !important; text-transform: uppercase !important; color: var(--ws-fg-muted) !important; z-index: 5 !important; margin: 0 !important; } /* The form wrapper (Gradio groups consecutive inputs into a form) — give it spacing only. */ #ws-content .form { background: transparent !important; gap: 12px !important; } /* Inputs INSIDE accordions: strip the nested card. */ #ws-content [data-testid="accordion-content"] .block.padded { background: transparent !important; border: 0 !important; padding: 0 !important; } /* Generate button — full-width on tab inputs. The ▶ glyph prefix was dropped (it was rendering near-black on electric blue and reading as a hairline). */ #ws-content button.primary, #ws-content button[class*="primary"] { width: 100% !important; text-align: center !important; color: #ffffff !important; font-weight: 510 !important; } #ws-content button.primary::before, #ws-content button[class*="primary"]::before { content: none !important; } /* Accordions in tab content are their own card. */ #ws-content .gradio-accordion { background: var(--ws-surface) !important; border: 1px solid var(--ws-border) !important; border-radius: 8px !important; padding: 0 !important; } #ws-content .gradio-accordion > .label-wrap { padding: 10px 14px !important; } #ws-content .gradio-accordion .wrap { padding: 0 14px 14px 14px !important; } /* Don't card-style nested cards (e.g. accordion children). */ #ws-content .gradio-accordion .gradio-textbox, #ws-content .gradio-accordion .gradio-slider, #ws-content .gradio-accordion .gradio-checkbox, #ws-content .gradio-accordion .gradio-radio, #ws-content .gradio-accordion .gradio-dropdown { background: transparent !important; border: 0 !important; padding: 0 !important; } .gap, .ws-stack { gap: 14px !important; } /* Labels above inputs (Gradio 6 — [data-testid="block-label"]). */ #ws-content [data-testid="block-label"], #ws-content [data-testid="block-label"] span { color: var(--ws-fg-muted) !important; font-size: 10.5px !important; text-transform: uppercase !important; letter-spacing: 0.07em !important; font-weight: 510 !important; margin: 0 0 8px 0 !important; font-family: "Geist", ui-sans-serif, system-ui, sans-serif !important; background: transparent !important; padding: 0 !important; border: 0 !important; } /* Accordion label — slightly larger, mixed case. */ #ws-content [data-testid="accordion-content"] ~ button, #ws-content button.label-wrap, #ws-content .label-wrap { background: transparent !important; } #ws-content .accordion .label-wrap, #ws-content [class*="accordion"] button.label-wrap { font-size: 12px !important; font-weight: 510 !important; color: var(--ws-fg) !important; text-transform: none !important; letter-spacing: 0 !important; padding: 12px 14px !important; } /* Sliders */ .gradio-container input[type="range"] { height: 4px !important; background: var(--ws-border-strong) !important; border-radius: 999px !important; padding: 0 !important; } .gradio-container .wrap.svelte-1cl284s .head .min, .gradio-container .wrap.svelte-1cl284s .head .max { color: var(--ws-fg-muted) !important; font-size: 11px !important; } /* Accordions */ .accordion, .gradio-accordion { background: var(--ws-surface) !important; border: 1px solid var(--ws-border) !important; border-radius: 8px !important; } .accordion .label-wrap, .gradio-accordion .label-wrap { font-size: 12px !important; font-weight: 510 !important; color: var(--ws-fg) !important; padding: 10px 14px !important; } /* Radio inputs INSIDE tabs (not chrome) — Linear-style chip list */ .gradio-container .gr-radio, .gradio-container .wrap.gr-radio { background: transparent !important; border: 0 !important; padding: 0 !important; gap: 6px !important; flex-wrap: wrap !important; } .gradio-container .gr-radio label, .gradio-container .wrap.gr-radio label { background: var(--ws-surface-2) !important; border: 1px solid var(--ws-border) !important; border-radius: 6px !important; padding: 5px 10px !important; font-size: 12px !important; font-weight: 500 !important; color: var(--ws-fg-dim) !important; transition: all 120ms ease !important; } .gradio-container .gr-radio label:hover { border-color: var(--ws-border-strong) !important; color: var(--ws-fg) !important; } .gradio-container .gr-radio input[type="radio"] { display: none !important; } .gradio-container .gr-radio label.selected { background: var(--ws-accent-soft) !important; border-color: var(--ws-accent-line) !important; color: var(--ws-fg) !important; } /* Primary "Generate" buttons inside tabs — accent fill, white label. */ button[class*="primary"], button.lg.primary, .gradio-container .primary, .gr-button-primary, button[variant="primary"] { background: var(--ws-accent) !important; color: #ffffff !important; border: 0 !important; border-radius: 6px !important; font-weight: 510 !important; font-size: 13px !important; padding: 10px 16px !important; min-height: 38px !important; letter-spacing: -0.005em !important; box-shadow: 0 1px 2px rgba(0,0,0,0.35) !important; transition: filter 120ms ease, transform 80ms ease !important; } button[class*="primary"]:hover { filter: brightness(1.06) !important; } button[class*="primary"]:active { transform: translateY(1px) !important; } /* Generic secondary buttons — exclude chrome buttons (ws-pill, ws-side-btn, ws-nav-btn) explicitly so the segmented control / sidebar can claim their own visual treatment without specificity wars. */ button.secondary:not(.ws-pill):not(.ws-side-btn):not(.ws-nav-btn), .gradio-container button:not([class*="primary"]):not(.ws-pill):not(.ws-side-btn):not(.ws-nav-btn) { background: var(--ws-surface-2) !important; color: var(--ws-fg-dim) !important; border: 1px solid var(--ws-border) !important; border-radius: 6px !important; font-size: 12px !important; font-weight: 500 !important; letter-spacing: 0 !important; box-shadow: none !important; } .gradio-container button:not([class*="primary"]):hover { background: var(--ws-elev) !important; border-color: var(--ws-border-strong) !important; color: var(--ws-fg) !important; } /* ─── Warning banner (preserve original semantics, Linear-tinted) ──── */ .warning-banner { background: rgba(245, 165, 36, 0.08) !important; border: 1px solid rgba(245, 165, 36, 0.25) !important; border-left: 3px solid var(--ws-amber) !important; border-radius: 6px !important; padding: 10px 14px !important; color: #f5d18c !important; font-size: 12px !important; } /* ─── Dev banner (top of page, all viewports) ─────────────────────── */ .ws-dev-banner { display: flex !important; align-items: center !important; justify-content: center !important; gap: 10px !important; padding: 9px 16px !important; background: linear-gradient(180deg, #2d2418 0%, #261f15 100%) !important; border-bottom: 1px solid #5a4520 !important; color: #f1b863 !important; font-family: "Geist", "Inter", ui-sans-serif, system-ui, sans-serif !important; font-size: 12.5px !important; font-weight: 500 !important; letter-spacing: -0.005em !important; text-align: center !important; line-height: 1.4 !important; } .ws-dev-banner .ws-dev-icon { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #f1b863; box-shadow: 0 0 0 3px rgba(241,184,99,0.18); flex-shrink: 0; } .ws-dev-banner b { color: #ffd494 !important; font-weight: 600 !important; } .ws-dev-banner a { color: #ffd494 !important; text-decoration: underline !important; text-decoration-color: rgba(255,212,148,0.4) !important; text-underline-offset: 2px !important; } .ws-dev-banner a:hover { text-decoration-color: #ffd494 !important; } @media (max-width: 767px) { .ws-dev-banner { font-size: 11.5px !important; padding: 8px 12px !important; } } /* ─── Local backend banner ─────────────────────────────────────────── */ .ws-local-banner { display: inline-flex !important; align-items: center !important; gap: 8px !important; padding: 5px 10px !important; background: var(--ws-surface-2) !important; border: 1px solid var(--ws-border) !important; border-radius: 6px !important; font-size: 11.5px !important; color: var(--ws-fg-dim) !important; font-family: "Geist Mono", ui-monospace, monospace !important; margin-bottom: 14px !important; } .ws-local-banner .dot { width: 6px !important; height: 6px !important; border-radius: 50% !important; background: #5fa881 !important; box-shadow: 0 0 0 3px rgba(95,168,129,0.15) !important; } /* ─── Tertiary row spacing ─────────────────────────────────────────── */ #ws-content .row { gap: 10px !important; } /* Small buttons (Send-to: I2V/VACE/Animate, etc) — readable inline pills. */ #ws-content button.sm, #ws-content button[class*=" sm "], #ws-content button.small, #ws-content button[size="sm"] { padding: 6px 12px !important; font-size: 11.5px !important; font-weight: 500 !important; min-height: 30px !important; min-width: 60px !important; } /* ─── Toasts ───────────────────────────────────────────────────────── */ .toast, .toast-body, .toast-wrap, .gradio-toast { background: var(--ws-elev) !important; border: 1px solid var(--ws-border-strong) !important; border-radius: 8px !important; color: var(--ws-fg) !important; font-size: 13px !important; font-weight: 500 !important; box-shadow: 0 10px 30px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4) !important; } /* ─── Scrollbar tuning ─────────────────────────────────────────────── */ ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: #1c1e22; border-radius: 999px; border: 2px solid #08090a; } ::-webkit-scrollbar-thumb:hover { background: #2a2d33; } /* ─── Gradio body row layout glue ──────────────────────────────────── */ #ws-body { gap: 0 !important; align-items: stretch !important; } #ws-body > * { background: transparent !important; } /* ─── Mode panel show/hide — driven purely by JS class toggle ────────── All 10 panels are mounted with `visible=True` (Gradio never touches their display). JS adds `ws-mode-panel-active` to the chosen one. */ .ws-mode-panel { display: none !important; } .ws-mode-panel.ws-mode-panel-active { display: flex !important; /* gr.Column renders as flex column by default */ flex-direction: column !important; } /* ─── Inline code chips inside mode-title headings ──────────────────── Backticks were rendering as bordered pills sized to the 22px heading, which made S2V/TI2V titles look broken. Render them as plain text. */ #ws-content .mode-title code, #ws-content h2 code, #ws-content h2 :is(code, kbd, samp) { background: transparent !important; border: 0 !important; font-size: inherit !important; font-family: inherit !important; padding: 0 !important; color: inherit !important; border-radius: 0 !important; } /* ─── Gallery (read-only display, not upload zone) ───────────────────── `interactive=False` already hides the upload affordance, but suppress any lingering "Drop Media Here" overlay defensively. */ .ws-gallery-readonly [data-testid="upload-button"], .ws-gallery-readonly .upload-container, .ws-gallery-readonly .upload-button, .ws-gallery-readonly button.upload, .ws-gallery-readonly .icon-button-wrapper.upload-button-wrapper { display: none !important; } .ws-gallery-empty { color: var(--ws-fg-muted) !important; font-size: 12.5px !important; font-style: italic !important; padding: 4px 2px 8px 2px !important; } /* ─── Preset pill specificity overrides ──────────────────────────────── gr.Button defaults to variant="secondary", which lands `class="secondary"` on the rendered