Instructions to use Baragi-AI/LPC-FourDirection-Walk-Flux-Klein-9B with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use Baragi-AI/LPC-FourDirection-Walk-Flux-Klein-9B with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline from diffusers.utils import load_image # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("black-forest-labs/FLUX.2-klein-base-9B", dtype=torch.bfloat16, device_map="cuda") pipe.load_lora_weights("Baragi-AI/LPC-FourDirection-Walk-Flux-Klein-9B") prompt = "Turn this cat into a dog" input_image = load_image("https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png") image = pipe(image=input_image, prompt=prompt).images[0] - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- Draw Things
| # THESIS: Turn five model tasks into one character-to-animation workbench; | |
| # refuse the generic prompt form as the product's primary structure. | |
| # OWN-WORLD: White proof sheets, near-black ink rails, cobalt registration | |
| # marks, vermilion actions, square frame cells, and indexed palette strips. | |
| # STORY: Choose a real LPC base, dress it, derive directions and walks, correct | |
| # pixels and palette, then export; every result becomes the next stage's input. | |
| # FIRST VIEWPORT: Source rail left, dominant active proof center, next action | |
| # right, with the palette and proof log below. | |
| # FORM: Screenprint registration workbench; staged per tab from approved comps | |
| # A+B+C. Direction seed e120291a. | |
| import argparse | |
| import copy | |
| import json | |
| import os | |
| import secrets | |
| import shutil | |
| import tempfile | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from datetime import datetime | |
| from pathlib import Path | |
| from urllib.parse import urlsplit, urlunsplit | |
| import gradio as gr | |
| import requests | |
| from dotenv import load_dotenv | |
| from PIL import Image, ImageDraw | |
| from postprocess import ( | |
| apply_shared_palette, | |
| process_output, | |
| reference_palette, | |
| save_gif, | |
| shared_palette, | |
| ) | |
| ROOT = Path(__file__).parent | |
| WORKFLOW = ROOT / "workflows" / "LPC_FourDirection_Walk_API.json" | |
| BASE_DIR = ROOT / "assets" / "bases" | |
| OUTPUT_DIR = ROOT / "outputs" | |
| BASE_STANDING = BASE_DIR / "standing_south.png" | |
| BASE_WALKS = { | |
| "north": BASE_DIR / "walk_north_4x2.png", | |
| "west": BASE_DIR / "walk_west_4x2.png", | |
| "south": BASE_DIR / "walk_south_4x2.png", | |
| "east": BASE_DIR / "walk_east_4x2.png", | |
| } | |
| DIRECTIONS = ("north", "west", "south", "east") | |
| MAX_PARALLEL_REQUESTS = 4 | |
| DIRECTION_LABELS = { | |
| "north": "북쪽", | |
| "west": "서쪽", | |
| "south": "남쪽", | |
| "east": "동쪽", | |
| } | |
| def load_environment(): | |
| for directory in (ROOT, *ROOT.parents): | |
| candidate = directory / ".env" | |
| if candidate.exists(): | |
| load_dotenv(candidate, override=False) | |
| return candidate | |
| return None | |
| ENV_PATH = load_environment() | |
| TASKS = { | |
| "Rotate standing character": ( | |
| "TASK_ROTATE_STANDING: Turn the south-facing standing female LPC " | |
| "character to face {direction}. Preserve the exact hairstyle, hair " | |
| "color, clothing, shoes and accessories. Keep the character centered " | |
| "on a pure white background." | |
| ), | |
| "Standing to walk frame 1": ( | |
| "TASK_STANDING_TO_WALK_FIRST_FRAME: Convert this {direction}-facing " | |
| "standing LPC character into the first frame of the {direction}-facing " | |
| "walking animation. Preserve the exact appearance and pure white background." | |
| ), | |
| "Propagate frame 1 appearance": ( | |
| "TASK_PROPAGATE_APPEARANCE: Use frame 1 as the appearance reference. " | |
| "Apply exactly the same hairstyle, hair color, clothing, shoes and " | |
| "accessories to frames 2 through 8. Preserve every walking pose, frame " | |
| "order, 4 by 2 layout and pure white background." | |
| ), | |
| "Dress 4x2 walk sheet": ( | |
| "TASK_DRESS_WALK_SHEET: Dress the female LPC character in all 8 " | |
| "{direction}-facing walking frames with {appearance}. Preserve every " | |
| "pose, frame order, 4 by 2 layout and pure white background." | |
| ), | |
| "Dress standing character": ( | |
| "TASK_DRESS_STANDING: Dress the south-facing standing female LPC " | |
| "character with {appearance}. Preserve the pose and pure white background." | |
| ), | |
| } | |
| TASK_LABELS = { | |
| "Rotate standing character": "Standing 방향 바꾸기", | |
| "Standing to walk frame 1": "걷기 첫 프레임 만들기", | |
| "Propagate frame 1 appearance": "첫 프레임 외형 전파", | |
| "Dress 4x2 walk sheet": "4×2 걷기 시트 단장", | |
| "Dress standing character": "Standing 캐릭터 단장", | |
| } | |
| PALETTE_CHOICES = [ | |
| ("기준 팔레트 고정", "Lock reference palette"), | |
| ("새 색상 허용 · 최종 32색", "Allow new colors (32)"), | |
| ] | |
| RESOLUTION_CHOICES = [ | |
| ("업스케일 유지", "Upscaled"), | |
| ("LPC 원본 크기", "Native LPC"), | |
| ] | |
| FORMAT_CHOICES = [ | |
| ("PNG 시트", "PNG sheet"), | |
| ("GIF 애니메이션", "GIF"), | |
| ] | |
| def initial_state(): | |
| return { | |
| "active_standing": "", | |
| "active_palette": "", | |
| "directions": {}, | |
| "walks": {}, | |
| "history": [], | |
| } | |
| def state_copy(state): | |
| return copy.deepcopy(state) if state else initial_state() | |
| def make_prompt(task, direction, appearance): | |
| if task not in TASKS: | |
| raise ValueError(f"알 수 없는 작업입니다: {task}") | |
| if task.startswith("Dress") and not appearance.strip(): | |
| raise ValueError("머리, 옷, 신발, 장식을 설명해 주세요.") | |
| return TASKS[task].format( | |
| direction=direction.lower(), appearance=appearance.strip() | |
| ) | |
| def normalize_server(value): | |
| raw = (value or "").strip() | |
| if not raw: | |
| raise ValueError("ComfyUI 서버 주소를 입력해 주세요.") | |
| if raw.count("://") != 1: | |
| raise ValueError( | |
| "서버 주소가 중복되었거나 형식이 잘못되었습니다. " | |
| "예: https://cloud.comfy.org" | |
| ) | |
| parsed = urlsplit(raw) | |
| if parsed.scheme not in {"http", "https"} or not parsed.hostname: | |
| raise ValueError("서버 주소는 http:// 또는 https://로 시작해야 합니다.") | |
| clean = parsed._replace(query="", fragment="") | |
| return urlunsplit(clean).rstrip("/") | |
| def resolve_api_key(value): | |
| return ( | |
| (value or "").strip() | |
| or os.getenv("COMFY_API_KEY", "").strip() | |
| or os.getenv("COMFY_CLOUD_API_KEY", "").strip() | |
| ) | |
| def is_cloud(server): | |
| return urlsplit(server).hostname == "cloud.comfy.org" | |
| def headers(api_key): | |
| return {"X-API-Key": api_key} if api_key else {} | |
| def request_error(error): | |
| if isinstance(error, requests.exceptions.ConnectionError): | |
| return ( | |
| "서버에 연결하지 못했습니다. 주소가 중복 입력되지 않았는지와 " | |
| "인터넷 연결을 확인해 주세요." | |
| ) | |
| if isinstance(error, requests.exceptions.Timeout): | |
| return "서버 응답 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요." | |
| if isinstance(error, requests.exceptions.HTTPError): | |
| status = error.response.status_code if error.response is not None else "알 수 없음" | |
| if status in {401, 403}: | |
| return "API 키가 거부되었습니다. Comfy Cloud 키를 다시 확인해 주세요." | |
| return f"ComfyUI가 HTTP {status} 오류를 반환했습니다." | |
| return str(error) | |
| def upload_image(server, api_key, image_path): | |
| with open(image_path, "rb") as image: | |
| response = requests.post( | |
| f"{server}/api/upload/image", | |
| headers=headers(api_key), | |
| files={"image": (Path(image_path).name, image, "image/png")}, | |
| data={"type": "input", "overwrite": "true"}, | |
| timeout=120, | |
| ) | |
| response.raise_for_status() | |
| return response.json()["name"] | |
| def submit(server, api_key, workflow): | |
| response = requests.post( | |
| f"{server}/api/prompt", | |
| headers=headers(api_key), | |
| json={"prompt": workflow}, | |
| timeout=120, | |
| ) | |
| response.raise_for_status() | |
| data = response.json() | |
| return data.get("prompt_id") or data["job_id"] | |
| def first_image(value): | |
| if isinstance(value, dict): | |
| if {"filename", "type"} <= value.keys(): | |
| return value | |
| for child in value.values(): | |
| found = first_image(child) | |
| if found: | |
| return found | |
| elif isinstance(value, list): | |
| for child in value: | |
| found = first_image(child) | |
| if found: | |
| return found | |
| return None | |
| def wait_for_result(server, api_key, job_id, cloud): | |
| deadline = time.time() + 900 | |
| while time.time() < deadline: | |
| if cloud: | |
| response = requests.get( | |
| f"{server}/api/jobs/{job_id}", | |
| headers=headers(api_key), | |
| timeout=60, | |
| ) | |
| response.raise_for_status() | |
| data = response.json() | |
| status = data.get("status") | |
| if status == "completed": | |
| return data | |
| if status in {"failed", "cancelled"}: | |
| raise RuntimeError(data.get("error") or f"작업이 {status} 상태입니다.") | |
| else: | |
| response = requests.get( | |
| f"{server}/api/history/{job_id}", | |
| headers=headers(api_key), | |
| timeout=60, | |
| ) | |
| response.raise_for_status() | |
| data = response.json() | |
| if job_id in data: | |
| return data[job_id] | |
| time.sleep(2) | |
| raise TimeoutError("15분 안에 생성이 완료되지 않았습니다.") | |
| def download_image(server, api_key, output): | |
| response = requests.get( | |
| f"{server}/api/view", | |
| headers=headers(api_key), | |
| params={ | |
| "filename": output["filename"], | |
| "subfolder": output.get("subfolder", ""), | |
| "type": output.get("type", "output"), | |
| }, | |
| timeout=120, | |
| ) | |
| response.raise_for_status() | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as handle: | |
| handle.write(response.content) | |
| return handle.name | |
| def persist(path, prefix): | |
| OUTPUT_DIR.mkdir(exist_ok=True) | |
| source = Path(path) | |
| stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") | |
| target = OUTPUT_DIR / f"{prefix}_{stamp}{source.suffix.lower()}" | |
| shutil.copy2(source, target) | |
| return str(target) | |
| def add_history(state, path, label): | |
| state["history"].insert(0, {"path": path, "label": label}) | |
| state["history"] = state["history"][:24] | |
| def history_value(state): | |
| return [ | |
| (entry["path"], entry["label"]) | |
| for entry in (state or {}).get("history", []) | |
| if Path(entry["path"]).exists() | |
| ] | |
| def direction_values(state): | |
| directions = (state or {}).get("directions", {}) | |
| return tuple(directions.get(direction) for direction in DIRECTIONS) | |
| def walk_value(state): | |
| walks = (state or {}).get("walks", {}) | |
| return [ | |
| (walks[direction], f"{DIRECTION_LABELS[direction]} 걷기") | |
| for direction in DIRECTIONS | |
| if direction in walks and Path(walks[direction]).exists() | |
| ] | |
| def palette_swatch(colors, prefix="palette"): | |
| while len(colors) < 32: | |
| colors.append(colors[-1]) | |
| swatch = 34 | |
| preview = Image.new("RGB", (swatch * 16, swatch * 2), "white") | |
| draw = ImageDraw.Draw(preview) | |
| for index, color in enumerate(colors[:32]): | |
| x = (index % 16) * swatch | |
| y = (index // 16) * swatch | |
| draw.rectangle((x, y, x + swatch - 2, y + swatch - 2), fill=color) | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as handle: | |
| preview.save(handle.name) | |
| return persist(handle.name, prefix) | |
| def palette_preview(image_path, prefix="palette"): | |
| image = Image.open(image_path).convert("RGB").resize( | |
| (64, 64), Image.Resampling.NEAREST | |
| ) | |
| return palette_swatch(reference_palette(image), prefix) | |
| def finalize_shared_palette(paths, prefix): | |
| colors = shared_palette(paths.values()) | |
| outputs = apply_shared_palette(paths.values(), colors) | |
| finalized = { | |
| direction: persist(path, f"{prefix}_{direction}") | |
| for direction, path in zip(paths, outputs) | |
| } | |
| return finalized, palette_swatch(colors, f"{prefix}_palette") | |
| def animation_preview(sheet_path, fps=8): | |
| sheet = Image.open(sheet_path).convert("RGB") | |
| return persist( | |
| save_gif(sheet, reference_palette(sheet), fps), | |
| "walk_preview", | |
| ) | |
| def generate( | |
| server_value, | |
| api_key_value, | |
| image_path, | |
| task, | |
| direction, | |
| appearance, | |
| seed, | |
| steps, | |
| cfg, | |
| lora_strength, | |
| palette_reference_path=None, | |
| palette_mode="Lock reference palette", | |
| align_frames=True, | |
| pixel_snap=True, | |
| output_resolution="Upscaled", | |
| output_format="PNG sheet", | |
| fps=8, | |
| prefix="result", | |
| ): | |
| if not image_path: | |
| raise ValueError("입력 이미지를 선택해 주세요.") | |
| server = normalize_server(server_value) | |
| api_key = resolve_api_key(api_key_value) | |
| cloud = is_cloud(server) | |
| if cloud and not api_key: | |
| raise ValueError( | |
| "Comfy Cloud API 키가 필요합니다. 설정 탭 또는 .env에 " | |
| "COMFY_API_KEY를 입력해 주세요." | |
| ) | |
| workflow = json.loads(WORKFLOW.read_text(encoding="utf-8")) | |
| workflow["1"]["inputs"]["image"] = upload_image(server, api_key, image_path) | |
| workflow["4"]["inputs"]["strength_model"] = float(lora_strength) | |
| workflow["9"]["inputs"]["text"] = make_prompt(task, direction, appearance) | |
| workflow["14"]["inputs"]["noise_seed"] = int(seed) | |
| workflow["15"]["inputs"]["steps"] = int(steps) | |
| workflow["17"]["inputs"]["cfg"] = float(cfg) | |
| workflow["2"]["inputs"]["megapixels"] = ( | |
| 2.0 if task in {"Dress 4x2 walk sheet", "Propagate frame 1 appearance"} else 1.0 | |
| ) | |
| job_id = submit(server, api_key, workflow) | |
| result = wait_for_result(server, api_key, job_id, cloud) | |
| output = first_image(result.get("outputs", result)) | |
| if not output: | |
| raise RuntimeError("ComfyUI 작업은 끝났지만 이미지 출력이 없습니다.") | |
| raw_path = download_image(server, api_key, output) | |
| final_path = process_output( | |
| raw_path, | |
| image_path, | |
| task, | |
| palette_reference_path, | |
| palette_mode, | |
| align_frames, | |
| pixel_snap, | |
| output_resolution, | |
| output_format, | |
| fps, | |
| ) | |
| return persist(final_path, prefix), job_id | |
| def compose_first_frame(first_frame_path, direction): | |
| base = Image.open(BASE_WALKS[direction]).convert("RGB") | |
| first = Image.open(first_frame_path).convert("RGB").resize( | |
| (base.width // 4, base.height // 2), Image.Resampling.NEAREST | |
| ) | |
| base.paste(first, (0, 0)) | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as handle: | |
| base.save(handle.name) | |
| return handle.name | |
| def dress_core( | |
| source, | |
| appearance, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| ): | |
| final, job_id = generate( | |
| server, | |
| api_key, | |
| source, | |
| "Dress standing character", | |
| "south", | |
| appearance, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| palette_mode="Allow new colors (32)", | |
| align_frames=False, | |
| prefix="standing_dressed", | |
| ) | |
| state = state_copy(state) | |
| palette = palette_preview(final) | |
| state["active_standing"] = final | |
| state["active_palette"] = palette | |
| state["directions"] = {"south": final} | |
| state["walks"] = {} | |
| add_history(state, final, "Standing 외형 완료") | |
| return state, final, palette, job_id | |
| def direction_job( | |
| direction, | |
| index, | |
| source, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| ): | |
| if direction == "south": | |
| return source, "기존 Standing 사용" | |
| return generate( | |
| server, | |
| api_key, | |
| source, | |
| "Rotate standing character", | |
| direction, | |
| "", | |
| int(seed) + index, | |
| steps, | |
| cfg, | |
| strength, | |
| palette_mode="Defer shared palette", | |
| prefix=f"standing_{direction}_pending", | |
| ) | |
| def direction_results( | |
| selected, | |
| source, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| progress=None, | |
| ): | |
| state = state if state is not None else initial_state() | |
| source = source or state.get("active_standing") | |
| if not source: | |
| raise ValueError("먼저 Standing 캐릭터를 완성해 주세요.") | |
| selected = list(dict.fromkeys(selected or [])) | |
| if not selected: | |
| raise ValueError("생성할 방향을 하나 이상 선택해 주세요.") | |
| for direction in selected: | |
| state["directions"].pop(direction, None) | |
| total = len(selected) | |
| completed = 0 | |
| failures = [] | |
| workers = min(MAX_PARALLEL_REQUESTS, total) | |
| with ThreadPoolExecutor(max_workers=workers) as executor: | |
| futures = { | |
| executor.submit( | |
| direction_job, | |
| direction, | |
| index, | |
| source, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| ): direction | |
| for index, direction in enumerate(selected) | |
| } | |
| for future in as_completed(futures): | |
| direction = futures[future] | |
| try: | |
| final, job_id = future.result() | |
| except Exception as error: | |
| failures.append(f"{DIRECTION_LABELS[direction]}: {request_error(error)}") | |
| continue | |
| state["directions"][direction] = final | |
| completed += 1 | |
| add_history( | |
| state, | |
| final, | |
| f"{DIRECTION_LABELS[direction]} Standing · 팔레트 대기 · {job_id}", | |
| ) | |
| if progress: | |
| progress( | |
| (completed, total), | |
| desc=f"{DIRECTION_LABELS[direction]} 완료 · {completed}/{total}", | |
| ) | |
| yield state, f"{DIRECTION_LABELS[direction]} 완료 · {completed}/{total}" | |
| if failures: | |
| raise RuntimeError(" / ".join(failures)) | |
| finalized, palette = finalize_shared_palette( | |
| {direction: state["directions"][direction] for direction in selected}, | |
| "standing_shared", | |
| ) | |
| state["directions"].update(finalized) | |
| state["active_palette"] = palette | |
| if "south" in finalized: | |
| state["active_standing"] = finalized["south"] | |
| for direction, path in finalized.items(): | |
| add_history(state, path, f"{DIRECTION_LABELS[direction]} Standing · 공통 팔레트") | |
| if progress: | |
| progress(1, desc="전체 방향 공통 32색 팔레트 적용") | |
| yield state, "전체 방향 완료 · 공통 32색 팔레트를 적용했습니다." | |
| def walk_job( | |
| direction, | |
| index, | |
| standing, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| ): | |
| first, _ = generate( | |
| server, | |
| api_key, | |
| standing, | |
| "Standing to walk frame 1", | |
| direction, | |
| "", | |
| int(seed) + index * 2, | |
| steps, | |
| cfg, | |
| strength, | |
| palette_mode="Defer shared palette", | |
| align_frames=False, | |
| prefix=f"walk_first_{direction}_pending", | |
| ) | |
| composite = compose_first_frame(first, direction) | |
| final, job_id = generate( | |
| server, | |
| api_key, | |
| composite, | |
| "Propagate frame 1 appearance", | |
| direction, | |
| "", | |
| int(seed) + index * 2 + 1, | |
| steps, | |
| cfg, | |
| strength, | |
| palette_mode="Defer shared palette", | |
| align_frames=True, | |
| prefix=f"walk_{direction}_pending", | |
| ) | |
| return final, job_id | |
| def walk_results( | |
| selected, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| progress=None, | |
| ): | |
| state = state if state is not None else initial_state() | |
| selected = list(dict.fromkeys(selected or [])) | |
| if not selected: | |
| raise ValueError("생성할 방향을 하나 이상 선택해 주세요.") | |
| missing = [ | |
| direction | |
| for direction in selected | |
| if direction not in state.get("directions", {}) | |
| ] | |
| if missing: | |
| labels = ", ".join(DIRECTION_LABELS[direction] for direction in missing) | |
| raise ValueError(f"먼저 다음 방향의 Standing을 만들어 주세요: {labels}") | |
| for direction in selected: | |
| state["walks"].pop(direction, None) | |
| total = len(selected) | |
| completed = 0 | |
| failures = [] | |
| workers = min(MAX_PARALLEL_REQUESTS, total) | |
| with ThreadPoolExecutor(max_workers=workers) as executor: | |
| futures = { | |
| executor.submit( | |
| walk_job, | |
| direction, | |
| index, | |
| state["directions"][direction], | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| ): direction | |
| for index, direction in enumerate(selected) | |
| } | |
| for future in as_completed(futures): | |
| direction = futures[future] | |
| try: | |
| final, job_id = future.result() | |
| except Exception as error: | |
| failures.append(f"{DIRECTION_LABELS[direction]}: {request_error(error)}") | |
| continue | |
| state["walks"][direction] = final | |
| completed += 1 | |
| add_history( | |
| state, | |
| final, | |
| f"{DIRECTION_LABELS[direction]} 걷기 · 팔레트 대기 · {job_id}", | |
| ) | |
| if progress: | |
| progress( | |
| (completed, total), | |
| desc=f"{DIRECTION_LABELS[direction]} 걷기 완료 · {completed}/{total}", | |
| ) | |
| yield state, f"{DIRECTION_LABELS[direction]} 걷기 완료 · {completed}/{total}" | |
| if failures: | |
| raise RuntimeError(" / ".join(failures)) | |
| finalized, palette = finalize_shared_palette( | |
| {direction: state["walks"][direction] for direction in selected}, | |
| "walk_shared", | |
| ) | |
| state["walks"].update(finalized) | |
| state["active_palette"] = palette | |
| for direction, path in finalized.items(): | |
| add_history(state, path, f"{DIRECTION_LABELS[direction]} 걷기 · 공통 팔레트") | |
| if progress: | |
| progress(1, desc="전체 방향 공통 32색 팔레트 적용") | |
| yield state, "전체 걷기 완료 · 공통 32색 팔레트를 적용했습니다." | |
| def format_failure(error): | |
| return f"오류 · {request_error(error)}" | |
| def ui_dress( | |
| source, | |
| appearance, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| ): | |
| try: | |
| state, final, palette, job_id = dress_core( | |
| source, | |
| appearance, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| ) | |
| return ( | |
| final, | |
| palette, | |
| final, | |
| f"완료 · Standing 외형 생성 · 시드 {int(seed)} · {job_id}", | |
| state, | |
| history_value(state), | |
| ) | |
| except Exception as error: | |
| return None, None, None, format_failure(error), state, history_value(state) | |
| def ui_directions( | |
| selected, | |
| source, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| progress=gr.Progress(), | |
| ): | |
| try: | |
| for state, message in direction_results( | |
| selected, | |
| source, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| progress, | |
| ): | |
| yield ( | |
| *direction_values(state), | |
| f"{message} · 시드 {int(seed)}", | |
| state, | |
| history_value(state), | |
| ) | |
| except Exception as error: | |
| yield ( | |
| *direction_values(state), | |
| format_failure(error), | |
| state, | |
| history_value(state), | |
| ) | |
| def ui_walks( | |
| selected, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| progress=gr.Progress(), | |
| ): | |
| try: | |
| for state, message in walk_results( | |
| selected, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| progress, | |
| ): | |
| available = [ | |
| state["walks"][direction] | |
| for direction in selected or [] | |
| if direction in state["walks"] | |
| ] | |
| latest = available[-1] if available else None | |
| animation = animation_preview(latest) if latest else None | |
| yield ( | |
| walk_value(state), | |
| latest, | |
| animation, | |
| latest, | |
| f"{message} · 시드 {int(seed)}", | |
| state, | |
| history_value(state), | |
| ) | |
| except Exception as error: | |
| yield ( | |
| walk_value(state), | |
| None, | |
| None, | |
| None, | |
| format_failure(error), | |
| state, | |
| history_value(state), | |
| ) | |
| def estimate_jobs(selected): | |
| selected = selected or [] | |
| rotations = sum(direction != "south" for direction in selected) | |
| total = 1 + rotations + len(selected) * 2 | |
| return ( | |
| f"전체 실행 예상: {total}개 작업 " | |
| f"(외형 1 + 방향 {rotations} + 걷기 {len(selected) * 2}) · " | |
| "방향별 최대 4개 병렬" | |
| ) | |
| def new_seed(): | |
| return secrets.randbelow(2_147_483_647) + 1 | |
| def ui_run_all( | |
| source, | |
| appearance, | |
| selected, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| progress=gr.Progress(), | |
| ): | |
| try: | |
| if not selected: | |
| raise ValueError("생성할 방향을 하나 이상 선택해 주세요.") | |
| total = 1 + sum(direction != "south" for direction in selected) + len(selected) * 2 | |
| progress((0, total), desc="Standing 외형 생성") | |
| state, active, palette, _ = dress_core( | |
| source, | |
| appearance, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| ) | |
| yield ( | |
| active, | |
| palette, | |
| *direction_values(state), | |
| walk_value(state), | |
| None, | |
| None, | |
| active, | |
| "Standing 완료 · 방향 작업을 병렬로 시작합니다.", | |
| state, | |
| history_value(state), | |
| ) | |
| for state, message in direction_results( | |
| selected, | |
| active, | |
| server, | |
| api_key, | |
| int(seed) + 100, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| progress, | |
| ): | |
| yield ( | |
| active, | |
| state.get("active_palette") or palette, | |
| *direction_values(state), | |
| walk_value(state), | |
| None, | |
| None, | |
| active, | |
| message, | |
| state, | |
| history_value(state), | |
| ) | |
| for state, message in walk_results( | |
| selected, | |
| server, | |
| api_key, | |
| int(seed) + 200, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| progress, | |
| ): | |
| available = [ | |
| state["walks"][direction] | |
| for direction in selected | |
| if direction in state["walks"] | |
| ] | |
| latest = available[-1] if available else None | |
| yield ( | |
| active, | |
| state.get("active_palette") or palette, | |
| *direction_values(state), | |
| walk_value(state), | |
| latest, | |
| animation_preview(latest) if latest else None, | |
| latest, | |
| f"{message} · 전체 {total}개 작업 · 시드 {int(seed)}", | |
| state, | |
| history_value(state), | |
| ) | |
| except Exception as error: | |
| yield ( | |
| (state or {}).get("active_standing") or None, | |
| None, | |
| *direction_values(state), | |
| walk_value(state), | |
| None, | |
| None, | |
| None, | |
| format_failure(error), | |
| state, | |
| history_value(state), | |
| ) | |
| def use_active(state): | |
| path = (state or {}).get("active_standing") | |
| if not path: | |
| return None, "먼저 Standing 캐릭터를 완성해 주세요." | |
| return path, "활성 캐릭터를 입력으로 가져왔습니다." | |
| def walk_base(direction): | |
| return str(BASE_WALKS[direction]) | |
| def ui_single_walk( | |
| direction, | |
| source, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| progress=gr.Progress(), | |
| ): | |
| state = state_copy(state) | |
| if source: | |
| state["directions"][direction] = source | |
| if direction == "south" and not state.get("active_standing"): | |
| state["active_standing"] = source | |
| yield from ui_walks( | |
| [direction], | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| state, | |
| progress, | |
| ) | |
| def local_process( | |
| source, | |
| palette_source, | |
| palette_mode, | |
| align, | |
| resolution, | |
| output_format, | |
| fps, | |
| set_active, | |
| state, | |
| ): | |
| try: | |
| if not source: | |
| raise ValueError("처리할 이미지를 선택해 주세요.") | |
| with Image.open(source) as image: | |
| sheet = image.width >= image.height * 1.5 | |
| task = "Dress 4x2 walk sheet" if sheet else "Dress standing character" | |
| final = process_output( | |
| source, | |
| source, | |
| task, | |
| palette_source, | |
| palette_mode, | |
| align and sheet, | |
| True, | |
| resolution, | |
| output_format, | |
| fps, | |
| ) | |
| final = persist(final, "pixel_palette") | |
| palette = palette_preview(palette_source or final, "palette") | |
| state = state_copy(state) | |
| state["active_palette"] = palette | |
| if set_active and not sheet and output_format == "PNG sheet": | |
| state["active_standing"] = final | |
| state["directions"]["south"] = final | |
| add_history(state, final, "픽셀·팔레트 처리") | |
| return ( | |
| final, | |
| final, | |
| palette, | |
| palette, | |
| "완료 · Perfect Pixel 격자와 32색 팔레트를 적용했습니다.", | |
| state, | |
| history_value(state), | |
| state.get("active_standing") or None, | |
| ) | |
| except Exception as error: | |
| return ( | |
| None, | |
| None, | |
| None, | |
| None, | |
| format_failure(error), | |
| state, | |
| history_value(state), | |
| (state or {}).get("active_standing") or None, | |
| ) | |
| def check_connection(server_value, api_key_value): | |
| try: | |
| server = normalize_server(server_value) | |
| api_key = resolve_api_key(api_key_value) | |
| if is_cloud(server) and not api_key: | |
| raise ValueError("Comfy Cloud API 키가 설정되지 않았습니다.") | |
| response = requests.get( | |
| f"{server}/api/prompt", | |
| headers=headers(api_key), | |
| timeout=15, | |
| ) | |
| response.raise_for_status() | |
| return ( | |
| f"연결 확인 · {urlsplit(server).hostname} 응답 " | |
| f"(HTTP {response.status_code})" | |
| ) | |
| except Exception as error: | |
| return format_failure(error) | |
| def prompt_preview(task, direction, appearance): | |
| try: | |
| return make_prompt(task, direction, appearance) | |
| except Exception as error: | |
| return str(error) | |
| def ui_advanced( | |
| source, | |
| task, | |
| direction, | |
| appearance, | |
| server, | |
| api_key, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| palette_source, | |
| palette_mode, | |
| align, | |
| resolution, | |
| output_format, | |
| fps, | |
| state, | |
| ): | |
| try: | |
| final, job_id = generate( | |
| server, | |
| api_key, | |
| source, | |
| task, | |
| direction, | |
| appearance, | |
| seed, | |
| steps, | |
| cfg, | |
| strength, | |
| palette_source, | |
| palette_mode, | |
| align, | |
| True, | |
| resolution, | |
| output_format, | |
| fps, | |
| prefix="advanced", | |
| ) | |
| state = state_copy(state) | |
| add_history(state, final, f"고급 단일 작업 · {TASK_LABELS[task]}") | |
| return ( | |
| final, | |
| final, | |
| f"완료 · {TASK_LABELS[task]} · {job_id}", | |
| state, | |
| history_value(state), | |
| ) | |
| except Exception as error: | |
| return None, None, format_failure(error), state, history_value(state) | |
| def render_stage_strip(state): | |
| state = state or initial_state() | |
| direction_count = len(state.get("directions", {})) | |
| walk_count = len(state.get("walks", {})) | |
| if not state.get("active_standing"): | |
| current = 0 | |
| elif direction_count < 4: | |
| current = 2 | |
| elif walk_count < 4: | |
| current = 3 | |
| else: | |
| current = 4 | |
| stages = [ | |
| ("베이스", "실제 LPC 원본 선택"), | |
| ("외형", "머리·복장·장식"), | |
| ("방향", f"{direction_count}/4 완료"), | |
| ("걷기", f"{walk_count}/4 완료"), | |
| ("내보내기", "PNG·GIF"), | |
| ] | |
| items = "".join( | |
| f'<div class="stage {"complete" if index < current else "current" if index == current else ""}">' | |
| f"<b>{title}</b><span>{detail}</span></div>" | |
| for index, (title, detail) in enumerate(stages) | |
| ) | |
| return f'<div class="stage-strip" aria-label="LPC 제작 단계">{items}</div>' | |
| def project_rail(state): | |
| state = state or initial_state() | |
| return ( | |
| render_stage_strip(state), | |
| state.get("active_palette") or None, | |
| "현재 프로젝트 · " | |
| f"Standing {'완료' if state.get('active_standing') else '대기'} · " | |
| f"방향 {len(state.get('directions', {}))}/4 · " | |
| f"걷기 {len(state.get('walks', {}))}/4", | |
| ) | |
| CSS = """ | |
| :root { | |
| --paper: #f4f3ed; | |
| --proof: #ffffff; | |
| --ink: #171918; | |
| --muted: #5d625f; | |
| --line: #c9cbc5; | |
| --steel: #e2e3de; | |
| --blue: #2458d6; | |
| --blue-deep: #173b94; | |
| --red: #d13f2d; | |
| --red-deep: #b62f21; | |
| --success: #18723a; | |
| } | |
| body, .gradio-container { | |
| background: var(--paper) !important; | |
| color: var(--ink) !important; | |
| font-family: "Segoe UI", "Noto Sans KR", Arial, sans-serif !important; | |
| } | |
| .gradio-container { max-width: 1760px !important; margin: 0 auto !important; } | |
| #app-shell { border: 1px solid var(--ink); background: var(--proof); } | |
| .app-header { | |
| display: grid; | |
| grid-template-columns: minmax(260px, 1fr) auto; | |
| align-items: end; | |
| gap: 24px; | |
| padding: 22px 26px 18px; | |
| border-bottom: 4px solid var(--ink); | |
| background: var(--proof); | |
| } | |
| .app-header h1 { margin: 0; font-size: clamp(1.6rem, 3vw, 2.7rem); line-height: 1; letter-spacing: -0.03em; } | |
| .app-header p { max-width: 70ch; margin: 8px 0 0; color: var(--muted); } | |
| .connection-note { color: var(--success); font-weight: 700; white-space: nowrap; } | |
| .stage-strip { | |
| display: grid; | |
| grid-template-columns: repeat(5, 1fr); | |
| border: 1px solid var(--ink); | |
| background: var(--ink); | |
| gap: 1px; | |
| } | |
| .stage { min-height: 68px; padding: 12px 14px; background: var(--proof); } | |
| .stage b, .stage span { display: block; } | |
| .stage b { font-size: 1rem; } | |
| .stage span { margin-top: 4px; color: var(--muted); font-size: .82rem; } | |
| .stage.current { background: var(--blue); color: white; } | |
| .stage.current span { color: #eef3ff; } | |
| .stage.complete { box-shadow: inset 0 -5px 0 var(--success); } | |
| .proof, .tool-rail, .contact-sheet, .settings-panel { | |
| background: var(--proof) !important; | |
| border: 1px solid var(--ink) !important; | |
| border-radius: 2px !important; | |
| } | |
| .proof { position: relative; padding: 12px !important; } | |
| .proof::before, .proof::after { | |
| content: ""; | |
| position: absolute; | |
| width: 18px; | |
| height: 18px; | |
| border: 2px solid var(--blue); | |
| border-radius: 50%; | |
| pointer-events: none; | |
| } | |
| .proof::before { top: 10px; left: 10px; } | |
| .proof::after { right: 10px; bottom: 10px; } | |
| .pixel-preview img, .pixel-gallery img, .proof img { image-rendering: pixelated !important; } | |
| .tool-rail { padding: 14px !important; } | |
| .registration-label { | |
| font-family: Consolas, "Courier New", monospace; | |
| color: var(--blue-deep); | |
| font-size: .78rem; | |
| letter-spacing: .02em; | |
| } | |
| .status-line textarea, .status-line input { | |
| font-weight: 700 !important; | |
| color: var(--ink) !important; | |
| background: var(--steel) !important; | |
| } | |
| button.primary { | |
| background: var(--red) !important; | |
| border: 1px solid var(--red-deep) !important; | |
| color: white !important; | |
| border-radius: 2px !important; | |
| font-weight: 800 !important; | |
| } | |
| button.primary:hover { background: var(--red-deep) !important; } | |
| button.secondary { border-radius: 2px !important; border-color: var(--ink) !important; } | |
| button:focus-visible, input:focus-visible, textarea:focus-visible, [role="tab"]:focus-visible { | |
| outline: 3px solid var(--blue) !important; | |
| outline-offset: 2px !important; | |
| } | |
| [role="tablist"] { gap: 0 !important; border-bottom: 1px solid var(--ink); } | |
| [role="tab"] { border-radius: 0 !important; font-weight: 750 !important; min-height: 48px; } | |
| [role="tab"][aria-selected="true"] { background: var(--blue) !important; color: white !important; } | |
| .direction-board { background: var(--proof); border: 1px solid var(--ink); padding: 10px !important; } | |
| .direction-board .gr-image { border-color: var(--blue) !important; } | |
| .palette-strip img { image-rendering: pixelated !important; min-height: 68px; object-fit: contain; } | |
| .proof-log { border-top: 4px solid var(--ink) !important; } | |
| .advanced-panel { border-top: 1px dashed var(--muted) !important; } | |
| footer { display: none !important; } | |
| @media (max-width: 900px) { | |
| .app-header { grid-template-columns: 1fr; } | |
| .connection-note { white-space: normal; } | |
| .stage-strip { grid-template-columns: 1fr; } | |
| .stage { min-height: 50px; } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } | |
| } | |
| """ | |
| def build_ui(): | |
| env_note = ( | |
| "로컬 환경 설정 감지" | |
| if ENV_PATH and resolve_api_key("") | |
| else "설정 탭에서 Comfy 연결 필요" | |
| ) | |
| default_server = os.getenv("COMFY_URL", "https://cloud.comfy.org") | |
| base_gallery_value = [(str(BASE_STANDING), "여성 기본 Standing · 남쪽")] | |
| with gr.Blocks( | |
| title="LPC 사방향 제작 작업대", | |
| fill_width=True, | |
| delete_cache=(86400, 86400), | |
| ) as demo: | |
| project_state = gr.State(initial_state()) | |
| gr.HTML( | |
| f""" | |
| <header class="app-header" id="app-shell"> | |
| <div> | |
| <div class="registration-label">LPC / FOUR-DIRECTION / PROOF WORKBENCH</div> | |
| <h1>LPC 사방향 제작 작업대</h1> | |
| <p>베이스 캐릭터에서 외형, 방향, 8프레임 걷기, 32색 내보내기까지 한 흐름으로 만듭니다.</p> | |
| </div> | |
| <div class="connection-note">{env_note}</div> | |
| </header> | |
| """ | |
| ) | |
| with gr.Row(elem_classes="settings-panel"): | |
| gr.Markdown( | |
| "**공통 LoRA 강도** · 모든 생성 작업에 적용됩니다. " | |
| "`0`은 LoRA 비활성화, `1.0`은 기본 강도입니다." | |
| ) | |
| strength = gr.Slider( | |
| 0, | |
| 1.5, | |
| value=1, | |
| step=0.05, | |
| label="LoRA 강도", | |
| scale=2, | |
| ) | |
| with gr.Tabs(): | |
| with gr.Tab("빠른 제작", id="quick"): | |
| quick_stage_strip = gr.HTML(render_stage_strip(initial_state())) | |
| with gr.Row(equal_height=True): | |
| with gr.Column(scale=3, elem_classes="tool-rail"): | |
| gr.Markdown("### 실제 LPC 베이스") | |
| quick_base_gallery = gr.Gallery( | |
| base_gallery_value, | |
| columns=1, | |
| rows=1, | |
| height=250, | |
| label="내장 베이스", | |
| show_label=False, | |
| elem_classes="pixel-gallery", | |
| ) | |
| quick_source = gr.Image( | |
| value=str(BASE_STANDING), | |
| type="filepath", | |
| label="선택된 베이스", | |
| elem_classes="pixel-preview", | |
| ) | |
| gr.Markdown( | |
| "학습에 사용한 여성 기본 체형과 헤드입니다. " | |
| "다른 체형은 현재 모델의 보장 범위가 아닙니다." | |
| ) | |
| with gr.Column(scale=5, elem_classes="proof"): | |
| gr.Markdown("### 활성 캐릭터 교정쇄") | |
| active_proof = gr.Image( | |
| type="filepath", | |
| label="현재 Standing 캐릭터", | |
| height=560, | |
| elem_classes="pixel-preview", | |
| ) | |
| quick_palette = gr.Image( | |
| type="filepath", | |
| label="활성 32색 팔레트", | |
| height=90, | |
| elem_classes="palette-strip", | |
| ) | |
| with gr.Column(scale=4, elem_classes="tool-rail"): | |
| gr.Markdown("### 다음 작업 · 외형 만들기") | |
| quick_appearance = gr.Textbox( | |
| label="원하는 외형", | |
| lines=5, | |
| placeholder=( | |
| "예: 파란 단발머리, 붉은 긴팔 상의, " | |
| "남색 바지, 갈색 부츠, 금색 안경" | |
| ), | |
| ) | |
| quick_directions = gr.CheckboxGroup( | |
| choices=[ | |
| (DIRECTION_LABELS[direction], direction) | |
| for direction in DIRECTIONS | |
| ], | |
| value=list(DIRECTIONS), | |
| label="만들 방향", | |
| ) | |
| quick_estimate = gr.Textbox( | |
| value=estimate_jobs(list(DIRECTIONS)), | |
| label="전체 실행 작업 수", | |
| interactive=False, | |
| ) | |
| quick_dress_button = gr.Button( | |
| "1단계 · Standing 외형 만들기", | |
| variant="primary", | |
| elem_classes="primary", | |
| ) | |
| quick_dress_reroll = gr.Button( | |
| "새 시드로 1단계 다시 만들기", | |
| elem_classes="secondary", | |
| ) | |
| quick_direction_button = gr.Button( | |
| "2단계 · 선택 방향 만들기", | |
| elem_classes="secondary", | |
| ) | |
| quick_direction_reroll = gr.Button( | |
| "새 시드로 2단계 다시 만들기", | |
| elem_classes="secondary", | |
| ) | |
| quick_walk_button = gr.Button( | |
| "3단계 · 걷기 만들기", | |
| elem_classes="secondary", | |
| ) | |
| quick_walk_reroll = gr.Button( | |
| "새 시드로 3단계 다시 만들기", | |
| elem_classes="secondary", | |
| ) | |
| with gr.Accordion("전체 자동 실행", open=False): | |
| gr.Markdown( | |
| "단계 순서는 유지하고, 같은 단계의 방향 작업은 " | |
| "최대 4개까지 병렬 실행합니다." | |
| ) | |
| quick_all_button = gr.Button( | |
| "전체 실행", | |
| variant="stop", | |
| elem_classes="primary", | |
| ) | |
| quick_file = gr.File(label="최근 결과 다운로드") | |
| gr.Markdown("### 방향 교정쇄") | |
| with gr.Column(elem_classes="direction-board"): | |
| with gr.Row(): | |
| quick_north = gr.Image( | |
| label="북쪽", type="filepath", elem_classes="pixel-preview" | |
| ) | |
| with gr.Row(): | |
| quick_west = gr.Image( | |
| label="서쪽", type="filepath", elem_classes="pixel-preview" | |
| ) | |
| quick_south = gr.Image( | |
| label="남쪽", type="filepath", elem_classes="pixel-preview" | |
| ) | |
| quick_east = gr.Image( | |
| label="동쪽", type="filepath", elem_classes="pixel-preview" | |
| ) | |
| quick_walks = gr.Gallery( | |
| label="방향별 4×2 걷기 시트", | |
| columns=2, | |
| type="filepath", | |
| elem_classes="pixel-gallery contact-sheet", | |
| ) | |
| quick_animation = gr.Image( | |
| type="filepath", | |
| label="최근 완성 시트 애니메이션", | |
| height=300, | |
| elem_classes="pixel-preview", | |
| ) | |
| quick_status = gr.Textbox( | |
| value="준비 · 실제 LPC 베이스를 선택하고 외형을 설명해 주세요.", | |
| label="작업 상태", | |
| interactive=False, | |
| elem_classes="status-line", | |
| ) | |
| with gr.Tab("Standing 외형", id="standing"): | |
| with gr.Row(equal_height=True): | |
| with gr.Column(scale=4, elem_classes="tool-rail"): | |
| gr.Markdown("### 원본 교정쇄") | |
| standing_source = gr.Image( | |
| value=str(BASE_STANDING), | |
| type="filepath", | |
| label="Standing 입력", | |
| sources=["upload", "clipboard"], | |
| elem_classes="pixel-preview", | |
| ) | |
| with gr.Column(scale=4, elem_classes="tool-rail"): | |
| gr.Markdown("### 외형 지시") | |
| standing_appearance = gr.Textbox( | |
| label="머리·상의·하의·신발·장식", | |
| lines=8, | |
| placeholder=( | |
| "예: 짙은 보라색 웨이브 머리, 흰 블라우스, " | |
| "검은 치마, 검은 부츠, 은색 머리핀" | |
| ), | |
| ) | |
| standing_button = gr.Button( | |
| "Standing 외형 생성", | |
| variant="primary", | |
| elem_classes="primary", | |
| ) | |
| standing_reroll = gr.Button( | |
| "새 시드로 다시 생성", | |
| elem_classes="secondary", | |
| ) | |
| standing_status = gr.Textbox( | |
| label="상태", | |
| interactive=False, | |
| elem_classes="status-line", | |
| ) | |
| with gr.Column(scale=5, elem_classes="proof"): | |
| gr.Markdown("### 완성 교정쇄") | |
| standing_output = gr.Image( | |
| type="filepath", | |
| label="완성 Standing", | |
| height=520, | |
| elem_classes="pixel-preview", | |
| ) | |
| standing_palette = gr.Image( | |
| type="filepath", | |
| label="생성된 32색 팔레트", | |
| height=90, | |
| elem_classes="palette-strip", | |
| ) | |
| standing_file = gr.File(label="Standing 다운로드") | |
| with gr.Tab("방향 만들기", id="directions"): | |
| with gr.Row(): | |
| with gr.Column(scale=3, elem_classes="tool-rail"): | |
| direction_source = gr.Image( | |
| type="filepath", | |
| label="기준 Standing", | |
| sources=["upload", "clipboard"], | |
| elem_classes="pixel-preview", | |
| ) | |
| use_active_direction = gr.Button( | |
| "활성 캐릭터 가져오기", | |
| elem_classes="secondary", | |
| ) | |
| direction_choices = gr.CheckboxGroup( | |
| choices=[ | |
| (DIRECTION_LABELS[direction], direction) | |
| for direction in DIRECTIONS | |
| ], | |
| value=list(DIRECTIONS), | |
| label="생성 방향", | |
| ) | |
| direction_button = gr.Button( | |
| "선택 방향 단계별 생성", | |
| variant="primary", | |
| elem_classes="primary", | |
| ) | |
| direction_reroll = gr.Button( | |
| "새 시드로 선택 방향 다시 생성", | |
| elem_classes="secondary", | |
| ) | |
| direction_status = gr.Textbox( | |
| label="상태", | |
| interactive=False, | |
| elem_classes="status-line", | |
| ) | |
| with gr.Column(scale=9, elem_classes="direction-board"): | |
| gr.Markdown("### 사방향 턴어라운드 교정판") | |
| with gr.Row(): | |
| direction_north = gr.Image( | |
| label="북쪽", | |
| type="filepath", | |
| elem_classes="pixel-preview", | |
| ) | |
| with gr.Row(): | |
| direction_west = gr.Image( | |
| label="서쪽", | |
| type="filepath", | |
| elem_classes="pixel-preview", | |
| ) | |
| direction_south = gr.Image( | |
| label="남쪽", | |
| type="filepath", | |
| elem_classes="pixel-preview", | |
| scale=2, | |
| ) | |
| direction_east = gr.Image( | |
| label="동쪽", | |
| type="filepath", | |
| elem_classes="pixel-preview", | |
| ) | |
| with gr.Tab("걷기 만들기", id="walk"): | |
| with gr.Row(equal_height=True): | |
| with gr.Column(scale=3, elem_classes="tool-rail"): | |
| walk_direction = gr.Dropdown( | |
| choices=[ | |
| (DIRECTION_LABELS[direction], direction) | |
| for direction in DIRECTIONS | |
| ], | |
| value="south", | |
| label="방향", | |
| ) | |
| walk_source = gr.Image( | |
| type="filepath", | |
| label="해당 방향 Standing", | |
| sources=["upload", "clipboard"], | |
| elem_classes="pixel-preview", | |
| ) | |
| use_active_walk = gr.Button( | |
| "활성 남쪽 캐릭터 가져오기", | |
| elem_classes="secondary", | |
| ) | |
| walk_button = gr.Button( | |
| "첫 프레임 → 8프레임 생성", | |
| variant="primary", | |
| elem_classes="primary", | |
| ) | |
| walk_reroll = gr.Button( | |
| "새 시드로 다시 생성", | |
| elem_classes="secondary", | |
| ) | |
| walk_status = gr.Textbox( | |
| label="상태", | |
| interactive=False, | |
| elem_classes="status-line", | |
| ) | |
| with gr.Column(scale=4, elem_classes="proof"): | |
| gr.Markdown("### 내장 4×2 자세 베이스") | |
| walk_base_preview = gr.Image( | |
| value=str(BASE_WALKS["south"]), | |
| type="filepath", | |
| label="방향별 베이스", | |
| elem_classes="pixel-preview", | |
| ) | |
| with gr.Column(scale=5, elem_classes="proof"): | |
| gr.Markdown("### 완성 4×2 걷기 교정쇄") | |
| walk_output = gr.Image( | |
| type="filepath", | |
| label="완성 걷기 시트", | |
| elem_classes="pixel-preview", | |
| ) | |
| walk_animation = gr.Image( | |
| type="filepath", | |
| label="현재 시트 애니메이션", | |
| height=300, | |
| elem_classes="pixel-preview", | |
| ) | |
| walk_file = gr.File(label="걷기 시트 다운로드") | |
| walk_gallery = gr.Gallery( | |
| label="현재 프로젝트의 방향별 걷기", | |
| columns=2, | |
| type="filepath", | |
| elem_classes="pixel-gallery contact-sheet", | |
| ) | |
| with gr.Tab("픽셀·팔레트", id="pixel"): | |
| with gr.Row(equal_height=True): | |
| with gr.Column(scale=4, elem_classes="tool-rail"): | |
| pixel_source = gr.Image( | |
| type="filepath", | |
| label="처리할 Standing 또는 4×2 시트", | |
| sources=["upload", "clipboard"], | |
| elem_classes="pixel-preview", | |
| ) | |
| use_active_pixel = gr.Button( | |
| "활성 캐릭터 가져오기", | |
| elem_classes="secondary", | |
| ) | |
| palette_source = gr.Image( | |
| type="filepath", | |
| label="고정할 팔레트 기준 이미지", | |
| sources=["upload", "clipboard"], | |
| elem_classes="pixel-preview", | |
| ) | |
| palette_mode = gr.Radio( | |
| PALETTE_CHOICES, | |
| value="Allow new colors (32)", | |
| label="팔레트 방식", | |
| ) | |
| with gr.Column(scale=3, elem_classes="tool-rail"): | |
| pixel_align = gr.Checkbox( | |
| value=True, | |
| label="4×2 프레임 발 위치 정렬", | |
| ) | |
| pixel_resolution = gr.Radio( | |
| RESOLUTION_CHOICES, | |
| value="Native LPC", | |
| label="해상도", | |
| ) | |
| pixel_format = gr.Radio( | |
| FORMAT_CHOICES, | |
| value="PNG sheet", | |
| label="출력 형식", | |
| ) | |
| pixel_fps = gr.Slider( | |
| 1, 20, value=8, step=1, label="GIF FPS" | |
| ) | |
| set_active = gr.Checkbox( | |
| value=True, | |
| label="Standing 결과를 활성 캐릭터로 지정", | |
| ) | |
| pixel_button = gr.Button( | |
| "Perfect Pixel 격자·32색 적용", | |
| variant="primary", | |
| elem_classes="primary", | |
| ) | |
| pixel_status = gr.Textbox( | |
| label="상태", | |
| interactive=False, | |
| elem_classes="status-line", | |
| ) | |
| with gr.Column(scale=5, elem_classes="proof"): | |
| pixel_output = gr.Image( | |
| type="filepath", | |
| label="처리 결과", | |
| elem_classes="pixel-preview", | |
| ) | |
| pixel_palette = gr.Image( | |
| type="filepath", | |
| label="32색 팔레트", | |
| height=90, | |
| elem_classes="palette-strip", | |
| ) | |
| with gr.Row(): | |
| pixel_file = gr.File(label="이미지 다운로드") | |
| palette_file = gr.File(label="팔레트 PNG") | |
| with gr.Tab("결과·설정", id="settings"): | |
| with gr.Row(): | |
| with gr.Column(scale=7, elem_classes="proof-log"): | |
| gr.Markdown("### 최근 결과") | |
| history_gallery = gr.Gallery( | |
| label="이 세션에서 생성한 파일", | |
| columns=4, | |
| type="filepath", | |
| elem_classes="pixel-gallery", | |
| ) | |
| with gr.Column(scale=5, elem_classes="settings-panel"): | |
| gr.Markdown("### Comfy 연결") | |
| server = gr.Textbox( | |
| value=default_server, | |
| label="ComfyUI 서버", | |
| placeholder="https://cloud.comfy.org", | |
| ) | |
| api_key = gr.Textbox( | |
| type="password", | |
| label="Comfy Cloud API 키", | |
| placeholder=( | |
| "비워두면 .env의 COMFY_API_KEY를 사용합니다." | |
| ), | |
| ) | |
| connection_button = gr.Button( | |
| "연결 확인", | |
| elem_classes="secondary", | |
| ) | |
| connection_status = gr.Textbox( | |
| value=( | |
| "환경 설정 준비됨" | |
| if ENV_PATH | |
| else "로컬 .env를 찾지 못했습니다." | |
| ), | |
| label="연결 상태", | |
| interactive=False, | |
| elem_classes="status-line", | |
| ) | |
| with gr.Accordion("생성 품질 설정", open=False): | |
| seed = gr.Number( | |
| value=710001, precision=0, label="Seed" | |
| ) | |
| steps = gr.Slider( | |
| 12, 40, value=28, step=1, label="Steps" | |
| ) | |
| cfg = gr.Slider( | |
| 1, 8, value=5, step=0.1, label="CFG" | |
| ) | |
| with gr.Accordion( | |
| "고급 단일 작업 · 영어 Task 프롬프트", | |
| open=False, | |
| elem_classes="advanced-panel", | |
| ): | |
| with gr.Row(): | |
| advanced_source = gr.Image( | |
| type="filepath", | |
| label="입력 이미지", | |
| sources=["upload", "clipboard"], | |
| elem_classes="pixel-preview", | |
| ) | |
| advanced_output = gr.Image( | |
| type="filepath", | |
| label="결과", | |
| elem_classes="pixel-preview", | |
| ) | |
| with gr.Row(): | |
| advanced_task = gr.Dropdown( | |
| choices=[ | |
| (label, task) for task, label in TASK_LABELS.items() | |
| ], | |
| value="Rotate standing character", | |
| label="작업", | |
| ) | |
| advanced_direction = gr.Dropdown( | |
| choices=[ | |
| (DIRECTION_LABELS[direction], direction) | |
| for direction in DIRECTIONS | |
| ], | |
| value="north", | |
| label="방향", | |
| ) | |
| advanced_appearance = gr.Textbox( | |
| label="외형 설명", | |
| lines=3, | |
| ) | |
| preview_button = gr.Button( | |
| "영어 프롬프트 확인", | |
| elem_classes="secondary", | |
| ) | |
| advanced_prompt = gr.Textbox( | |
| label="실제 영어 Task 프롬프트", | |
| lines=6, | |
| interactive=False, | |
| ) | |
| with gr.Row(): | |
| advanced_palette_source = gr.Image( | |
| type="filepath", | |
| label="팔레트 기준", | |
| elem_classes="pixel-preview", | |
| ) | |
| advanced_palette_mode = gr.Radio( | |
| PALETTE_CHOICES, | |
| value="Lock reference palette", | |
| label="팔레트", | |
| ) | |
| with gr.Row(): | |
| advanced_align = gr.Checkbox( | |
| value=True, | |
| label="4×2 발 위치 정렬", | |
| ) | |
| advanced_resolution = gr.Radio( | |
| RESOLUTION_CHOICES, | |
| value="Upscaled", | |
| label="해상도", | |
| ) | |
| advanced_format = gr.Radio( | |
| FORMAT_CHOICES, | |
| value="PNG sheet", | |
| label="출력", | |
| ) | |
| advanced_fps = gr.Slider( | |
| 1, 20, value=8, step=1, label="GIF FPS" | |
| ) | |
| advanced_button = gr.Button( | |
| "고급 단일 작업 실행", | |
| variant="primary", | |
| elem_classes="primary", | |
| ) | |
| advanced_file = gr.File(label="결과 다운로드") | |
| advanced_status = gr.Textbox( | |
| label="상태", | |
| interactive=False, | |
| elem_classes="status-line", | |
| ) | |
| with gr.Row(elem_classes="proof-log"): | |
| project_palette_bar = gr.Image( | |
| type="filepath", | |
| label="프로젝트 32색 팔레트", | |
| height=90, | |
| elem_classes="palette-strip", | |
| scale=3, | |
| ) | |
| project_summary = gr.Textbox( | |
| value="현재 프로젝트 · Standing 대기 · 방향 0/4 · 걷기 0/4", | |
| label="프로젝트 상태", | |
| interactive=False, | |
| elem_classes="status-line", | |
| scale=2, | |
| ) | |
| quick_base_gallery.select( | |
| lambda: ( | |
| str(BASE_STANDING), | |
| str(BASE_STANDING), | |
| str(BASE_STANDING), | |
| ), | |
| outputs=[quick_source, standing_source, pixel_source], | |
| ) | |
| quick_directions.change( | |
| estimate_jobs, | |
| inputs=quick_directions, | |
| outputs=quick_estimate, | |
| ) | |
| common_generation = [server, api_key, seed, steps, cfg, strength] | |
| def bind_generation(button, reroll, fn, inputs, outputs): | |
| normal = button.click(fn, inputs=inputs, outputs=outputs) | |
| repeated = reroll.click(new_seed, outputs=seed).then( | |
| fn, | |
| inputs=inputs, | |
| outputs=outputs, | |
| ) | |
| return normal, repeated | |
| def forward_active(state): | |
| active = state.get("active_standing") or None | |
| return active, active, active | |
| bind_generation( | |
| quick_dress_button, | |
| quick_dress_reroll, | |
| ui_dress, | |
| [ | |
| quick_source, | |
| quick_appearance, | |
| *common_generation, | |
| project_state, | |
| ], | |
| [ | |
| active_proof, | |
| quick_palette, | |
| quick_file, | |
| quick_status, | |
| project_state, | |
| history_gallery, | |
| ], | |
| ) | |
| bind_generation( | |
| quick_direction_button, | |
| quick_direction_reroll, | |
| ui_directions, | |
| [ | |
| quick_directions, | |
| active_proof, | |
| *common_generation, | |
| project_state, | |
| ], | |
| [ | |
| quick_north, | |
| quick_west, | |
| quick_south, | |
| quick_east, | |
| quick_status, | |
| project_state, | |
| history_gallery, | |
| ], | |
| ) | |
| bind_generation( | |
| quick_walk_button, | |
| quick_walk_reroll, | |
| ui_walks, | |
| [ | |
| quick_directions, | |
| *common_generation, | |
| project_state, | |
| ], | |
| [ | |
| quick_walks, | |
| walk_output, | |
| quick_animation, | |
| quick_file, | |
| quick_status, | |
| project_state, | |
| history_gallery, | |
| ], | |
| ) | |
| quick_all_button.click( | |
| ui_run_all, | |
| inputs=[ | |
| quick_source, | |
| quick_appearance, | |
| quick_directions, | |
| *common_generation, | |
| project_state, | |
| ], | |
| outputs=[ | |
| active_proof, | |
| quick_palette, | |
| quick_north, | |
| quick_west, | |
| quick_south, | |
| quick_east, | |
| quick_walks, | |
| walk_output, | |
| quick_animation, | |
| quick_file, | |
| quick_status, | |
| project_state, | |
| history_gallery, | |
| ], | |
| ) | |
| standing_events = bind_generation( | |
| standing_button, | |
| standing_reroll, | |
| ui_dress, | |
| [ | |
| standing_source, | |
| standing_appearance, | |
| *common_generation, | |
| project_state, | |
| ], | |
| [ | |
| standing_output, | |
| standing_palette, | |
| standing_file, | |
| standing_status, | |
| project_state, | |
| history_gallery, | |
| ], | |
| ) | |
| for event in standing_events: | |
| event.then( | |
| forward_active, | |
| inputs=project_state, | |
| outputs=[active_proof, direction_source, pixel_source], | |
| ) | |
| use_active_direction.click( | |
| use_active, | |
| inputs=project_state, | |
| outputs=[direction_source, direction_status], | |
| ) | |
| bind_generation( | |
| direction_button, | |
| direction_reroll, | |
| ui_directions, | |
| [ | |
| direction_choices, | |
| direction_source, | |
| *common_generation, | |
| project_state, | |
| ], | |
| [ | |
| direction_north, | |
| direction_west, | |
| direction_south, | |
| direction_east, | |
| direction_status, | |
| project_state, | |
| history_gallery, | |
| ], | |
| ) | |
| use_active_walk.click( | |
| use_active, | |
| inputs=project_state, | |
| outputs=[walk_source, walk_status], | |
| ) | |
| walk_direction.change( | |
| walk_base, | |
| inputs=walk_direction, | |
| outputs=walk_base_preview, | |
| ) | |
| bind_generation( | |
| walk_button, | |
| walk_reroll, | |
| ui_single_walk, | |
| [ | |
| walk_direction, | |
| walk_source, | |
| *common_generation, | |
| project_state, | |
| ], | |
| [ | |
| walk_gallery, | |
| walk_output, | |
| walk_animation, | |
| walk_file, | |
| walk_status, | |
| project_state, | |
| history_gallery, | |
| ], | |
| ) | |
| use_active_pixel.click( | |
| use_active, | |
| inputs=project_state, | |
| outputs=[pixel_source, pixel_status], | |
| ) | |
| pixel_button.click( | |
| local_process, | |
| inputs=[ | |
| pixel_source, | |
| palette_source, | |
| palette_mode, | |
| pixel_align, | |
| pixel_resolution, | |
| pixel_format, | |
| pixel_fps, | |
| set_active, | |
| project_state, | |
| ], | |
| outputs=[ | |
| pixel_output, | |
| pixel_file, | |
| pixel_palette, | |
| palette_file, | |
| pixel_status, | |
| project_state, | |
| history_gallery, | |
| active_proof, | |
| ], | |
| ) | |
| connection_button.click( | |
| check_connection, | |
| inputs=[server, api_key], | |
| outputs=connection_status, | |
| ) | |
| preview_button.click( | |
| prompt_preview, | |
| inputs=[ | |
| advanced_task, | |
| advanced_direction, | |
| advanced_appearance, | |
| ], | |
| outputs=advanced_prompt, | |
| ) | |
| advanced_button.click( | |
| ui_advanced, | |
| inputs=[ | |
| advanced_source, | |
| advanced_task, | |
| advanced_direction, | |
| advanced_appearance, | |
| *common_generation, | |
| advanced_palette_source, | |
| advanced_palette_mode, | |
| advanced_align, | |
| advanced_resolution, | |
| advanced_format, | |
| advanced_fps, | |
| project_state, | |
| ], | |
| outputs=[ | |
| advanced_output, | |
| advanced_file, | |
| advanced_status, | |
| project_state, | |
| history_gallery, | |
| ], | |
| ) | |
| project_state.change( | |
| project_rail, | |
| inputs=project_state, | |
| outputs=[quick_stage_strip, project_palette_bar, project_summary], | |
| ) | |
| return demo | |
| def self_check(): | |
| assert normalize_server("https://cloud.comfy.org/#project") == ( | |
| "https://cloud.comfy.org" | |
| ) | |
| try: | |
| normalize_server("https://cloud.comfy.orghttps://cloud.comfy.org") | |
| except ValueError: | |
| pass | |
| else: | |
| raise AssertionError("Repeated server URLs must be rejected.") | |
| assert make_prompt("Rotate standing character", "west", "").startswith( | |
| "TASK_ROTATE_STANDING:" | |
| ) | |
| assert "4 by 2" in make_prompt( | |
| "Dress 4x2 walk sheet", "east", "blue hair and black clothes" | |
| ) | |
| assert estimate_jobs(list(DIRECTIONS)).startswith("전체 실행 예상: 12개") | |
| assert MAX_PARALLEL_REQUESTS == 4 | |
| assert 1 <= new_seed() <= 2_147_483_647 | |
| assert 'class="stage current"' in render_stage_strip(initial_state()) | |
| assert BASE_STANDING.exists() and all(path.exists() for path in BASE_WALKS.values()) | |
| workflow = json.loads(WORKFLOW.read_text(encoding="utf-8")) | |
| assert workflow["4"]["class_type"] == "LoraLoaderModelOnly" | |
| assert workflow["4"]["inputs"]["model"] == ["3", 0] | |
| first = Image.new("RGB", (64, 64), "red") | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as handle: | |
| first.save(handle.name) | |
| composed = compose_first_frame(handle.name, "south") | |
| with Image.open(composed) as sheet: | |
| assert sheet.size == (2048, 1024) | |
| assert sheet.getpixel((10, 10)) == (255, 0, 0) | |
| Path(handle.name).unlink(missing_ok=True) | |
| Path(composed).unlink(missing_ok=True) | |
| print("self-check passed") | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--check", action="store_true") | |
| args = parser.parse_args() | |
| if args.check: | |
| self_check() | |
| else: | |
| OUTPUT_DIR.mkdir(exist_ok=True) | |
| build_ui().queue(default_concurrency_limit=1).launch( | |
| css=CSS, | |
| allowed_paths=[str(BASE_DIR), str(OUTPUT_DIR)], | |
| ssr_mode=False, | |
| ) | |