ij's picture
Current public release
4c3e3fc
Raw
History Blame Contribute Delete
74.6 kB
# 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,
)