Download app.py from jblast94/expression-pack-studio: direct link, hf CLI and curl.
- Browser
- Download file 19.6 kB
-
https://huggingface.co/spaces/jblast94/expression-pack-studio/resolve/main/app.py
- Command line
-
hf download hf://spaces/jblast94/expression-pack-studio/app.py
-
curl -L -o app.py https://huggingface.co/spaces/jblast94/expression-pack-studio/resolve/main/app.py
19.6 kB
| from __future__ import annotations | |
| import base64 | |
| import io | |
| import json | |
| import os | |
| import re | |
| import tempfile | |
| import time | |
| import zipfile | |
| from pathlib import Path | |
| from typing import Any | |
| import gradio as gr | |
| from gradio_client import Client, handle_file | |
| from huggingface_hub import InferenceClient | |
| from PIL import Image, PngImagePlugin | |
| APP_TITLE = "Expression Pack Studio" | |
| BACKEND_SPACE = os.getenv("BACKEND_SPACE", "jblast94/Qwen-Image-Edit-NSFW") | |
| HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN") | |
| TEXT_TO_IMAGE_MODEL = os.getenv("TEXT_TO_IMAGE_MODEL", "black-forest-labs/FLUX.1-schnell") | |
| DEFAULT_NEGATIVE = "blurry, low quality, deformed, extra limbs, watermark, text, logo, duplicate face" | |
| EXPRESSION_PRESETS: dict[str, dict[str, str]] = { | |
| "Starter 8 - fast test": { | |
| "neutral": "Change only the expression to a relaxed neutral face. Preserve identity, hair, outfit, pose, lighting, and background.", | |
| "joy": "Change only the expression to a genuine happy smile with bright eyes. Preserve identity, hair, outfit, pose, lighting, and background.", | |
| "sadness": "Change only the expression to soft sadness with slightly downturned eyes and mouth. Preserve everything else.", | |
| "anger": "Change only the expression to anger with furrowed brows and an intense stare. Preserve everything else.", | |
| "surprise": "Change only the expression to wide-eyed surprise with a slightly open mouth. Preserve everything else.", | |
| "fear": "Change only the expression to fear with worried wide eyes and a tense mouth. Preserve everything else.", | |
| "confused": "Change only the expression to confusion with slightly furrowed brows and uncertain eyes. Preserve everything else.", | |
| "smug": "Change only the expression to a smug confident smirk. Preserve everything else.", | |
| }, | |
| "Standard 20": { | |
| "neutral": "Change only the expression to a relaxed neutral face. Preserve identity, hair, outfit, pose, lighting, and background.", | |
| "joy": "Change only the expression to a genuine happy smile with bright eyes. Preserve everything else.", | |
| "happiness": "Change only the expression to warm content happiness with a relaxed smile. Preserve everything else.", | |
| "sadness": "Change only the expression to soft sadness with slightly downturned eyes and mouth. Preserve everything else.", | |
| "sorrow": "Change only the expression to sorrow with wet eyes and a slight frown. Preserve everything else.", | |
| "anger": "Change only the expression to anger with furrowed brows and an intense stare. Preserve everything else.", | |
| "rage": "Change only the expression to rage with fierce eyes and a clenched jaw. Preserve everything else.", | |
| "surprise": "Change only the expression to wide-eyed surprise with a slightly open mouth. Preserve everything else.", | |
| "shock": "Change only the expression to shock with raised eyebrows and an open mouth. Preserve everything else.", | |
| "fear": "Change only the expression to fear with worried wide eyes and a tense mouth. Preserve everything else.", | |
| "disgust": "Change only the expression to mild disgust with a wrinkled nose and downturned mouth. Preserve everything else.", | |
| "calm": "Change only the expression to calm peaceful softness with relaxed eyes. Preserve everything else.", | |
| "confused": "Change only the expression to confusion with slightly furrowed brows and uncertain eyes. Preserve everything else.", | |
| "curious": "Change only the expression to curiosity with raised brows and attentive eyes. Preserve everything else.", | |
| "thinking": "Change only the expression to thoughtful contemplation with eyes slightly upward. Preserve everything else.", | |
| "serious": "Change only the expression to serious focused intensity. Preserve everything else.", | |
| "smug": "Change only the expression to a smug confident smirk. Preserve everything else.", | |
| "embarrassed": "Change only the expression to embarrassment with a slight blush and averted eyes. Preserve everything else.", | |
| "shy": "Change only the expression to shy softness with a gentle smile and lowered gaze. Preserve everything else.", | |
| "tired": "Change only the expression to tired weariness with half-closed eyes. Preserve everything else.", | |
| }, | |
| "Full 28": { | |
| "neutral": "Change only the expression to a relaxed neutral face. Preserve identity, hair, outfit, pose, lighting, and background.", | |
| "joy": "Change only the expression to a genuine happy smile with bright eyes. Preserve everything else.", | |
| "happiness": "Change only the expression to warm content happiness with a relaxed smile. Preserve everything else.", | |
| "sadness": "Change only the expression to soft sadness with slightly downturned eyes and mouth. Preserve everything else.", | |
| "sorrow": "Change only the expression to sorrow with wet eyes and a slight frown. Preserve everything else.", | |
| "anger": "Change only the expression to anger with furrowed brows and an intense stare. Preserve everything else.", | |
| "rage": "Change only the expression to rage with fierce eyes and a clenched jaw. Preserve everything else.", | |
| "surprise": "Change only the expression to wide-eyed surprise with a slightly open mouth. Preserve everything else.", | |
| "shock": "Change only the expression to shock with raised eyebrows and an open mouth. Preserve everything else.", | |
| "fear": "Change only the expression to fear with worried wide eyes and a tense mouth. Preserve everything else.", | |
| "terror": "Change only the expression to terror with wide eyes and a strained tense face. Preserve everything else.", | |
| "disgust": "Change only the expression to mild disgust with a wrinkled nose and downturned mouth. Preserve everything else.", | |
| "contempt": "Change only the expression to contempt with a slight sneer and narrowed eyes. Preserve everything else.", | |
| "calm": "Change only the expression to calm peaceful softness with relaxed eyes. Preserve everything else.", | |
| "confused": "Change only the expression to confusion with slightly furrowed brows and uncertain eyes. Preserve everything else.", | |
| "curious": "Change only the expression to curiosity with raised brows and attentive eyes. Preserve everything else.", | |
| "thinking": "Change only the expression to thoughtful contemplation with eyes slightly upward. Preserve everything else.", | |
| "serious": "Change only the expression to serious focused intensity. Preserve everything else.", | |
| "smug": "Change only the expression to a smug confident smirk. Preserve everything else.", | |
| "confident": "Change only the expression to confident direct eye contact with a subtle smile. Preserve everything else.", | |
| "embarrassed": "Change only the expression to embarrassment with a slight blush and averted eyes. Preserve everything else.", | |
| "shy": "Change only the expression to shy softness with a gentle smile and lowered gaze. Preserve everything else.", | |
| "love": "Change only the expression to loving affection with warm eyes and a gentle smile. Preserve everything else.", | |
| "pout": "Change only the expression to a cute pout with pursed lips and big eyes. Preserve everything else.", | |
| "wink": "Change only the expression to a playful wink with one eye closed and a small smile. Preserve everything else.", | |
| "pain": "Change only the expression to mild pain with a slight grimace and tense brow. Preserve everything else.", | |
| "tired": "Change only the expression to tired weariness with half-closed eyes. Preserve everything else.", | |
| "bored": "Change only the expression to boredom with uninterested eyes and a flat mouth. Preserve everything else.", | |
| }, | |
| } | |
| def slugify(value: str) -> str: | |
| value = re.sub(r"[^a-zA-Z0-9 _-]+", "", value or "character").strip() | |
| value = re.sub(r"\s+", "_", value) | |
| return value or "character" | |
| def load_image_bytes(path: str | None) -> bytes | None: | |
| if not path: | |
| return None | |
| try: | |
| with Image.open(path) as image: | |
| image = image.convert("RGBA") | |
| buffer = io.BytesIO() | |
| image.save(buffer, format="PNG") | |
| return buffer.getvalue() | |
| except Exception: | |
| return None | |
| def extract_card_text(path: str | None, pasted_text: str) -> str: | |
| pieces: list[str] = [] | |
| if pasted_text.strip(): | |
| pieces.append(pasted_text.strip()) | |
| if path: | |
| suffix = Path(path).suffix.lower() | |
| if suffix in {".json", ".txt"}: | |
| try: | |
| pieces.append(Path(path).read_text(encoding="utf-8", errors="ignore")) | |
| except Exception: | |
| pass | |
| try: | |
| with Image.open(path) as image: | |
| info = dict(image.info) | |
| for key in ("chara", "ccv3", "Description", "parameters", "comment"): | |
| raw = info.get(key) | |
| if not raw: | |
| continue | |
| text = str(raw) | |
| if key in {"chara", "ccv3"}: | |
| try: | |
| text = base64.b64decode(text).decode("utf-8", errors="ignore") | |
| except Exception: | |
| pass | |
| pieces.append(text) | |
| except Exception: | |
| pass | |
| return "\n\n".join(pieces) | |
| def summarize_character(card_text: str, name: str) -> str: | |
| if not card_text.strip(): | |
| return f"portrait of {name or 'a character'}, expressive face, clean character art, high quality" | |
| try: | |
| data = json.loads(card_text) | |
| fields = [] | |
| for key in ("name", "description", "personality", "scenario", "first_mes"): | |
| value = data.get(key) or data.get("data", {}).get(key) | |
| if value: | |
| fields.append(f"{key}: {value}") | |
| if fields: | |
| return "\n".join(fields)[:2500] | |
| except Exception: | |
| pass | |
| return card_text[:2500] | |
| def text_to_reference_image(character_name: str, card_text: str, style: str) -> bytes: | |
| if not HF_TOKEN: | |
| raise RuntimeError("Text-only mode needs HF_TOKEN set as a Space secret or environment variable.") | |
| summary = summarize_character(card_text, character_name) | |
| prompt = ( | |
| f"single centered character portrait of {character_name or 'the character'}, {summary}, " | |
| f"{style}, detailed face, expressive eyes, clean background, high quality, no text, no watermark" | |
| ) | |
| client = InferenceClient(model=TEXT_TO_IMAGE_MODEL, token=HF_TOKEN) | |
| image = client.text_to_image(prompt=prompt, negative_prompt=DEFAULT_NEGATIVE) | |
| buffer = io.BytesIO() | |
| image.convert("RGBA").save(buffer, format="PNG") | |
| return buffer.getvalue() | |
| def write_temp_png(image_bytes: bytes) -> str: | |
| handle = tempfile.NamedTemporaryFile(delete=False, suffix=".png") | |
| handle.write(image_bytes) | |
| handle.close() | |
| return handle.name | |
| def result_to_bytes(result: Any) -> bytes: | |
| candidate = result | |
| if isinstance(result, (list, tuple)): | |
| # /infer returns: result_gallery, seed, history_gallery, state. | |
| candidate = result[0] | |
| if isinstance(candidate, list): | |
| if not candidate: | |
| raise RuntimeError("Backend returned an empty result gallery.") | |
| candidate = candidate[0] | |
| if isinstance(candidate, (list, tuple)): | |
| if not candidate: | |
| raise RuntimeError("Backend returned an empty nested result gallery item.") | |
| candidate = candidate[0] | |
| if isinstance(candidate, dict): | |
| candidate = candidate.get("path") or candidate.get("name") or candidate.get("url") | |
| if isinstance(candidate, str) and candidate.startswith("http"): | |
| import requests | |
| response = requests.get(candidate, timeout=120) | |
| response.raise_for_status() | |
| return response.content | |
| if isinstance(candidate, str): | |
| return Path(candidate).read_bytes() | |
| raise RuntimeError(f"Unsupported image result from backend: {type(result).__name__}") | |
| def generate_expression(reference_path: str, prompt: str, steps: int, guidance: float) -> bytes: | |
| client = Client(BACKEND_SPACE, hf_token=HF_TOKEN or None) | |
| uploaded_image = handle_file(reference_path) | |
| result = client.predict( | |
| None, # start state | |
| [uploaded_image], # Input Images gallery | |
| prompt, # Prompt | |
| DEFAULT_NEGATIVE, # Negative prompt | |
| 0, # Seed | |
| True, # Randomize seed | |
| float(guidance), # True guidance scale | |
| int(steps), # Number of inference steps | |
| 768, # Height | |
| 768, # Width | |
| False, # Rewrite prompt | |
| 0, # ZeroGPU max duration | |
| api_name="/infer", | |
| ) | |
| return result_to_bytes(result) | |
| def ensure_png(image_bytes: bytes) -> bytes: | |
| with Image.open(io.BytesIO(image_bytes)) as image: | |
| image = image.convert("RGBA") | |
| buffer = io.BytesIO() | |
| image.save(buffer, format="PNG", optimize=True) | |
| return buffer.getvalue() | |
| def build_zip(character_name: str, reference: bytes, images: dict[str, bytes], metadata: dict[str, Any]) -> str: | |
| out_dir = Path(tempfile.mkdtemp(prefix="expression-pack-")) | |
| zip_path = out_dir / f"{slugify(character_name)}_expression_pack.zip" | |
| with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: | |
| zf.writestr("reference.png", ensure_png(reference)) | |
| for name, image_bytes in images.items(): | |
| if image_bytes: | |
| zf.writestr(f"expressions/{name.lower()}.png", ensure_png(image_bytes)) | |
| zf.writestr(f"{name.lower()}.png", ensure_png(image_bytes)) | |
| zf.writestr("manifest.json", json.dumps(metadata, indent=2)) | |
| zf.writestr("README.txt", "This ZIP contains root-level expression PNGs plus expressions/*.png and manifest.json.\n") | |
| return str(zip_path) | |
| def generate_pack( | |
| reference_image: str | None, | |
| character_card_file: str | None, | |
| character_text: str, | |
| character_name: str, | |
| preset_name: str, | |
| custom_expressions: str, | |
| style: str, | |
| steps: int, | |
| guidance: float, | |
| progress=gr.Progress(track_tqdm=False), | |
| ): | |
| start = time.time() | |
| card_text = extract_card_text(character_card_file, character_text) | |
| reference = load_image_bytes(reference_image) or load_image_bytes(character_card_file) | |
| if reference is None: | |
| progress(0, desc="Creating reference portrait from text...") | |
| reference = text_to_reference_image(character_name, card_text, style) | |
| expressions = dict(EXPRESSION_PRESETS[preset_name]) | |
| for line in custom_expressions.splitlines(): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| if ":" in line: | |
| key, value = line.split(":", 1) | |
| expressions[slugify(key).lower()] = value.strip() | |
| else: | |
| key = slugify(line).lower() | |
| expressions[key] = f"Change only the expression to {line}. Preserve identity, hair, outfit, pose, lighting, and background." | |
| ref_path = write_temp_png(reference) | |
| images: dict[str, bytes] = {} | |
| failures: dict[str, str] = {} | |
| gallery: list[tuple[bytes, str]] = [] | |
| total = len(expressions) | |
| for idx, (name, prompt) in enumerate(expressions.items(), start=1): | |
| progress((idx - 1) / total, desc=f"Generating {idx}/{total}: {name}") | |
| try: | |
| image_bytes = generate_expression(ref_path, prompt, steps, guidance) | |
| images[name] = image_bytes | |
| gallery.append((image_bytes, name)) | |
| except Exception as exc: | |
| failures[name] = str(exc) | |
| Path(ref_path).unlink(missing_ok=True) | |
| metadata = { | |
| "character_name": character_name or "character", | |
| "preset": preset_name, | |
| "backend_space": BACKEND_SPACE, | |
| "text_to_image_model": TEXT_TO_IMAGE_MODEL, | |
| "generated": sorted(images.keys()), | |
| "failed": failures, | |
| "seconds": round(time.time() - start, 2), | |
| } | |
| zip_path = build_zip(character_name or "character", reference, images, metadata) | |
| status = f"Generated {len(images)}/{total} expressions." | |
| if failures: | |
| status += f" {len(failures)} failed and were skipped; ZIP still created." | |
| return gallery, zip_path, json.dumps(metadata, indent=2), status | |
| CSS = """ | |
| .gradio-container { max-width: 1180px !important; } | |
| .hero { padding: 28px; border-radius: 24px; background: linear-gradient(135deg,#1f1235,#47216f,#a7357d); color: white; box-shadow: 0 24px 80px rgba(0,0,0,.22); } | |
| .hero h1 { font-size: 42px; margin: 0 0 8px; } | |
| .hero p { font-size: 16px; opacity: .92; margin: 0; } | |
| .note { padding: 12px 16px; border-radius: 14px; background: rgba(125,85,255,.12); border: 1px solid rgba(125,85,255,.25); } | |
| """ | |
| with gr.Blocks(title=APP_TITLE, css=CSS, theme=gr.themes.Soft(primary_hue="purple", neutral_hue="slate")) as demo: | |
| gr.HTML( | |
| """ | |
| <div class="hero"> | |
| <h1>Expression Pack Studio</h1> | |
| <p>Upload a character photo/card or paste a description. Generate a clean downloadable ZIP of expression PNGs.</p> | |
| </div> | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.Markdown("### Input") | |
| reference_image = gr.Image(label="Reference photo / portrait", type="filepath", height=330) | |
| character_card_file = gr.File(label="Optional character card PNG / JSON / TXT", file_types=[".png", ".json", ".txt"], type="filepath") | |
| character_name = gr.Textbox(label="Character name", value="character") | |
| character_text = gr.Textbox(label="Character card text / description", lines=7, placeholder="Paste character description, card JSON, personality, visual details, etc.") | |
| style = gr.Textbox(label="Style for text-only reference generation", value="anime visual novel character portrait, polished digital art") | |
| with gr.Column(scale=1): | |
| gr.Markdown("### Pack Settings") | |
| preset_name = gr.Dropdown(label="Expression set", choices=list(EXPRESSION_PRESETS), value="Starter 8 - fast test") | |
| custom_expressions = gr.Textbox(label="Optional custom expressions", lines=8, placeholder="One per line, or name: prompt\nexample: skeptical: narrowed eyes, doubtful half-smile") | |
| with gr.Row(): | |
| steps = gr.Slider(1, 12, value=4, step=1, label="Steps") | |
| guidance = gr.Slider(0.5, 3.0, value=1.0, step=0.1, label="Guidance") | |
| generate_button = gr.Button("Generate Expression Pack", variant="primary", size="lg") | |
| gr.Markdown("<div class='note'>Start with Starter 8. If it looks good, run Standard 20 or Full 28.</div>") | |
| with gr.Row(): | |
| gallery = gr.Gallery(label="Generated expressions", columns=4, height=460, object_fit="contain") | |
| with gr.Column(scale=1): | |
| zip_file = gr.File(label="Download ZIP") | |
| status = gr.Textbox(label="Status", interactive=False) | |
| manifest = gr.Code(label="Manifest", language="json") | |
| generate_button.click( | |
| fn=generate_pack, | |
| inputs=[reference_image, character_card_file, character_text, character_name, preset_name, custom_expressions, style, steps, guidance], | |
| outputs=[gallery, zip_file, manifest, status], | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue(default_concurrency_limit=1).launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860"))) | |