# 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'
' f"{title}{detail}
" for index, (title, detail) in enumerate(stages) ) return f'
{items}
' 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"""
LPC / FOUR-DIRECTION / PROOF WORKBENCH

LPC 사방향 제작 작업대

베이스 캐릭터에서 외형, 방향, 8프레임 걷기, 32색 내보내기까지 한 흐름으로 만듭니다.

{env_note}
""" ) 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, )