"""Hugging Face Space entry point — venture-studio deployed as a ZeroGPU Space. Push this whole repo to a HF Space (sdk: gradio). The Space's Python build will install `requirements.txt` (torch + diffusers + transformers + gradio + spaces) and call: - `app.py:infer` — single image → motion (AnimateDiff MotionLoRA) - `app.py:infer_txt2img` — prompt → 512×512 sprite (SD 1.5) `@spaces.GPU` allocates an A10G only for the duration of the call (ZeroGPU model), so the Space is free for the maintainer and shared fairly across users. Locally this file is not imported — `studio` CLI uses `studio.backends.hf_space` to call this Space remotely via gradio_client. """ from __future__ import annotations import io import tempfile import gradio as gr import numpy as np import spaces from PIL import Image as PILImage from pixel_cursor import open_cursor, FrameStack from pixel_cursor.artifact import _new_image from studio.backends.animatediff import AnimateDiffAdapter, MOTION_LORA_MAP _adapter: AnimateDiffAdapter | None = None _sd_pipe = None def _get_adapter() -> AnimateDiffAdapter: global _adapter if _adapter is None: _adapter = AnimateDiffAdapter() _adapter.register() return _adapter PIXEL_ART_LORA_REPO = "artificialguybr/pixelartredmond-1-5v-pixel-art-loras-for-sd-1-5" PIXEL_ART_LORA_WEIGHT_FILE = "PixelArtRedmond15V-PixelArt-PIXARFK.safetensors" PIXEL_ART_LORA_ADAPTER = "pixart" # Trigger words: "pixel art, PixArFK" should appear in the prompt for the LoRA # to engage. The probe + UI include them by default. def _get_sd_pipe(): """Lazily load SD 1.5 txt2img pipeline + PixelArtRedmond LoRA (runs inside @spaces.GPU context). Cached at module scope so warm calls skip re-loading. Cold start is ~30-60s including weight downloads on first call ever (cached in persistent storage for subsequent cold starts). LoRA weight is set per-call via set_adapters(). """ global _sd_pipe if _sd_pipe is not None: return _sd_pipe import torch from diffusers import AutoPipelineForText2Image, DPMSolverMultistepScheduler pipe = AutoPipelineForText2Image.from_pretrained( "runwayml/stable-diffusion-v1-5", torch_dtype=torch.float16, safety_checker=None, requires_safety_checker=False, ) pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config) pipe = pipe.to("cuda") pipe.set_progress_bar_config(disable=True) try: pipe.load_lora_weights( PIXEL_ART_LORA_REPO, weight_name=PIXEL_ART_LORA_WEIGHT_FILE, adapter_name=PIXEL_ART_LORA_ADAPTER, ) pipe.set_adapters([PIXEL_ART_LORA_ADAPTER], adapter_weights=[0.9]) print(f"loaded pixel-art LoRA: {PIXEL_ART_LORA_REPO}", flush=True) except Exception as e: print(f"WARN: could not load LoRA {PIXEL_ART_LORA_REPO}: {e}", flush=True) _sd_pipe = pipe return pipe @spaces.GPU(duration=90) def infer( image: np.ndarray, preset: str, num_frames: int, num_inference_steps: int, guidance_scale: float, prompt: str, negative_prompt: str, seed: int, ) -> str: """Generate motion on a single image. Returns path to an mp4 file.""" import tempfile import imageio.v3 as iio adapter = _get_adapter() img = _new_image(image.astype(np.uint8)) cur = open_cursor(img).bind_motion_exemplar([preset], backend="animatediff") motion_spec = { "num_frames": int(num_frames), "num_inference_steps": int(num_inference_steps), "guidance_scale": float(guidance_scale), "prompt": prompt, "negative_prompt": negative_prompt, "seed": int(seed), } stack: FrameStack = cur.write_motion(motion_spec, backend="animatediff") out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) iio.imwrite(out.name, stack.frames, fps=stack.fps) return out.name @spaces.GPU(duration=45) def infer_txt2img( prompt: str, negative_prompt: str, num_inference_steps: int, guidance_scale: float, height: int, width: int, seed: int, lora_weight: float, ) -> str: """Generate a single sprite from a text prompt. Returns path to a PNG. Defaults tuned for pixel-art sprites: 512×512, 25 steps, guidance 7.5, LoRA strength 0.9. Caller downscales to target res (256×256 is the Dicer sprite size). Prompt must include "pixel art, PixArFK" to engage the LoRA. The UI pre-populates these tokens; the probe always includes them. """ import torch pipe = _get_sd_pipe() try: pipe.set_adapters([PIXEL_ART_LORA_ADAPTER], adapter_weights=[float(lora_weight)]) except Exception as e: print(f"WARN: set_adapters failed: {e}", flush=True) g = torch.Generator(device="cuda").manual_seed(int(seed)) out = pipe( prompt=prompt, negative_prompt=negative_prompt, num_inference_steps=int(num_inference_steps), guidance_scale=float(guidance_scale), height=int(height), width=int(width), generator=g, ) image = out.images[0] f = tempfile.NamedTemporaryFile(suffix=".png", delete=False) image.save(f.name) return f.name with gr.Blocks(title="Venture-Studio") as demo: gr.Markdown( "# Venture-Studio · Pixel-Cursor Animation\n" "Single image → 24fps animation, or text prompt → sprite, via PixelCursor.\n" "Running on Hugging Face ZeroGPU (free A10G)." ) with gr.Tabs(): with gr.Tab("Motion"): with gr.Row(): with gr.Column(): image_in = gr.Image(label="Source image", type="numpy", height=384) preset = gr.Dropdown( choices=sorted(MOTION_LORA_MAP), value="zoom_in", label="MotionLoRA preset", ) with gr.Accordion("Advanced", open=False): num_frames = gr.Slider(8, 24, value=16, step=2, label="num_frames") steps = gr.Slider(10, 50, value=25, step=1, label="num_inference_steps") guidance = gr.Slider(1.0, 15.0, value=7.5, step=0.5, label="guidance_scale") prompt = gr.Textbox(value="high quality, detailed", label="prompt") neg = gr.Textbox(value="bad quality, blurry", label="negative_prompt") seed = gr.Number(value=42, precision=0, label="seed") run = gr.Button("Generate", variant="primary") with gr.Column(): video_out = gr.Video(label="Output", autoplay=True, loop=True) run.click( infer, inputs=[image_in, preset, num_frames, steps, guidance, prompt, neg, seed], outputs=video_out, api_name="infer", ) with gr.Tab("Sprite gen (txt2img)"): with gr.Row(): with gr.Column(): t2i_prompt = gr.Textbox( value=( "pixel art, PixArFK, fantasy goblin warrior, green skin, " "leather armor, empty hands, unarmed, standing pose, " "full body, centered, white background, retro game sprite" ), lines=3, label="prompt (include 'pixel art, PixArFK' for LoRA)", ) t2i_neg = gr.Textbox( value=( "sword, weapon, dagger, axe, staff, blurry, soft, " "photorealistic, 3d render, extra limbs, distorted, " "multiple characters" ), lines=2, label="negative_prompt", ) with gr.Accordion("Advanced", open=False): t2i_steps = gr.Slider(10, 50, value=25, step=1, label="num_inference_steps") t2i_guidance = gr.Slider(1.0, 15.0, value=7.5, step=0.5, label="guidance_scale") t2i_height = gr.Slider(256, 768, value=512, step=64, label="height") t2i_width = gr.Slider(256, 768, value=512, step=64, label="width") t2i_seed = gr.Number(value=0, precision=0, label="seed (0 = random)") t2i_lora = gr.Slider(0.0, 1.5, value=0.9, step=0.05, label="LoRA weight (PixelArtRedmond)") t2i_run = gr.Button("Generate sprite", variant="primary") with gr.Column(): t2i_out = gr.Image(label="Generated sprite", height=512) t2i_run.click( infer_txt2img, inputs=[t2i_prompt, t2i_neg, t2i_steps, t2i_guidance, t2i_height, t2i_width, t2i_seed, t2i_lora], outputs=t2i_out, api_name="infer_txt2img", ) if __name__ == "__main__": demo.launch()