음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -0,0 +1,479 @@
#!/usr/bin/env python3
"""Generate persona-specific raster parts with the project imagegen wrapper.
The renderer consumes 900x1125 transparent PNGs. gpt-image-2 generation sizes
need 16px multiples, so this runner generates a 1024x1280 chroma-key draft and
normalizes the result back to the app canvas.
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import shlex
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parents[3]
SOURCE_PARTS = ROOT / "apps/web/public/avatar/seoyeon-live2d-psd-v2/parts"
SOURCE_CROPS = ROOT / "docs/avatar-art/personas/source-part-crops"
TARGET_CANVAS = (900, 1125)
GEN_SIZE = "1024x1280"
KEY = (0, 255, 0)
WRAPPER = Path.home() / ".codex/imagegen-headless/codex_imagegen.sh"
WSL_WRAPPER = "/mnt/c/Users/encep/.codex/imagegen-headless/codex_imagegen.sh"
WSL_PYTHON = "/mnt/c/Users/encep/AppData/Local/Python/pythoncore-3.14-64/python.exe"
GIT_BASH = Path("C:/Program Files/Git/bin/bash.exe")
PERSONAS = {
"P4": {
"name": "하늘",
"summary": "고2 여학생, 학업/시험 불안, 완벽주의, 긴장된 눈매와 깔끔한 교복 느낌",
"palette": "soft black hair with cool brown highlights, pale warm skin, muted navy school uniform",
},
"P5": {
"name": "도윤",
"summary": "중3 남학생, 또래관계 갈등과 소외감, 경계심 있고 말수가 적은 표정",
"palette": "dark brown short hair, neutral warm skin, subdued gray-blue school jacket",
},
"P6": {
"name": "하린",
"summary": "고3 여학생, 진로갈등과 부모 기대 압박, 차분하지만 흔들리는 표정",
"palette": "deep ash brown medium hair, soft warm skin, neat cream and charcoal school styling",
},
"P7": {
"name": "도현",
"summary": "고3 남학생, 입시 번아웃과 무기력, 지친 눈매와 낮은 에너지",
"palette": "black slightly messy short hair, low-saturation warm skin, dark school cardigan",
},
}
CORE_PARTS = [
"face-base",
"hair-front",
"hair-side-left-1",
"hair-side-right-1",
"brow-left",
"brow-right",
"brow-sad-left",
"brow-sad-right",
"eye-white-left",
"eye-white-right",
"iris-left",
"iris-right",
"pupil-left",
"pupil-right",
"lash-left",
"lash-right",
"mouth-neutral",
"mouth-sad",
"tear-left",
"tear-right",
]
BODY_PARTS = [
"hair-back-left",
"hair-back-right",
"hair-back-base",
"body",
"neck",
"clavicle",
"outfit",
"outfit-inner",
"outfit-outer",
"ribbon",
"face",
"ear-left",
"ear-right",
"blush-left",
"blush-right",
"nose",
"eye-left",
"eye-right",
"highlight-left",
"highlight-right",
"eyelid-upper-left",
"eyelid-upper-right",
"eyelid-lower-left",
"eyelid-lower-right",
"hair-bangs",
"hair-side-left-2",
"hair-side-right-2",
"mouth-open",
"mouth-open-small",
"mouth-warm",
"mouth-tired",
"mouth-anxious",
"mouth-startled",
]
def read_manifest() -> dict:
path = ROOT / "docs/avatar-art/seoyeon/live2d-psd-v2/detailed-parts/detailed-parts-manifest.json"
return json.loads(path.read_text(encoding="utf-8"))
def resolved_alpha_box(part: dict) -> list[int] | None:
alpha = part.get("alphaBox")
if alpha:
return [int(v) for v in alpha]
ref = SOURCE_PARTS / f"{part['id']}.png"
if not ref.exists():
return None
im = Image.open(ref).convert("RGBA")
box = alpha_bbox(im)
return [int(v) for v in box] if box else None
def prompt_for(persona_code: str, part: dict) -> str:
persona = PERSONAS[persona_code]
alpha = resolved_alpha_box(part)
origin = part.get("originPercent")
return f"""Use case: stylized-concept
Asset type: Live2D-style raster avatar part for Vignette persona {persona_code} {persona["name"]}
Primary request: Generate exactly one isolated avatar part, matching the reference part's type and placement.
Reference image role: the input image is a tight crop of the source Seoyeon PSD v2 part. Use it for the exact part category, silhouette, edge quality, and local proportions.
Subject: {persona["summary"]}
Part id: {part["id"]}
Final canvas requirement: the delivered project asset must be a 900x1125 transparent PNG. This generation draft is a cropped part source; post-processing will resize the detected part to source alphaBox {alpha} and paste it at originPercent {origin}.
Crop constraint: fill the draft with only this one part, with a small clean margin. Do not draw surrounding face, hair, body, sheet cells, labels, or other avatar parts.
Style/medium: polished anime Live2D PSD part, clean painted edges, matching Vignette avatar renderer style.
Color palette: {persona["palette"]}.
Background: perfectly flat solid #00ff00 chroma-key background. Do not use #00ff00 in the part.
Constraints: output one part only, not a character sheet, not multiple variants, no full body unless the part id requires it, no text, no watermark, no shadow, no floor, no frame. Preserve the reference part's transparent silhouette logic and visual scale.
Avoid: collage, sprite sheet, labels, full character, extra facial features, extra parts outside this one part, gradients in the background.
"""
def ensure_dirs(persona_code: str) -> tuple[Path, Path, Path]:
root = ROOT / f"docs/avatar-art/personas/{persona_code}"
raw = root / "generated-raw"
final = ROOT / f"apps/web/public/avatar/{persona_code.lower()}-live2d-generated/parts"
prompts = root / "prompts"
raw.mkdir(parents=True, exist_ok=True)
final.mkdir(parents=True, exist_ok=True)
prompts.mkdir(parents=True, exist_ok=True)
return raw, final, prompts
def make_reference_crop(part: dict) -> Path:
part_id = part["id"]
ref = SOURCE_PARTS / f"{part_id}.png"
if not ref.exists():
raise FileNotFoundError(ref)
SOURCE_CROPS.mkdir(parents=True, exist_ok=True)
out = SOURCE_CROPS / f"{part_id}.png"
if out.exists():
return out
im = Image.open(ref).convert("RGBA")
box = resolved_alpha_box(part) or alpha_bbox(im)
if not box:
im.save(out, optimize=True)
return out
x0, y0, x1, y1 = [int(v) for v in box]
pad_x = max(8, int((x1 - x0) * 0.28))
pad_y = max(8, int((y1 - y0) * 0.28))
crop_box = (
max(0, x0 - pad_x),
max(0, y0 - pad_y),
min(im.width, x1 + pad_x),
min(im.height, y1 + pad_y),
)
crop = im.crop(crop_box)
crop.save(out, optimize=True)
return out
def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None:
arr = np.array(im.convert("RGBA"))
alpha = arr[:, :, 3]
ys, xs = np.where(alpha > threshold)
if len(xs) == 0:
return None
return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1)
def procedural_blush(target_box: list[int] | tuple[int, int, int, int]) -> Image.Image:
x0, y0, x1, y1 = [int(v) for v in target_box]
layer = Image.new("RGBA", TARGET_CANVAS, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
w = x1 - x0
h = y1 - y0
inset_x = max(2, int(w * 0.12))
inset_y = max(2, int(h * 0.18))
draw.ellipse(
(x0 + inset_x, y0 + inset_y, x1 - inset_x, y1 - inset_y),
fill=(238, 142, 158, 74),
)
return layer.filter(ImageFilter.GaussianBlur(radius=max(7, int(min(w, h) * 0.15))))
def remove_key_and_resize(
src: Path,
dst: Path,
target_box: list[int] | tuple[int, int, int, int] | None,
part_id: str = "",
) -> None:
if part_id.startswith("blush-") and target_box:
dst.parent.mkdir(parents=True, exist_ok=True)
procedural_blush(target_box).save(dst, optimize=True)
return
im = Image.open(src).convert("RGBA")
if im.size != TARGET_CANVAS:
im = im.resize(TARGET_CANVAS, Image.Resampling.LANCZOS)
arr = np.array(im, dtype=np.uint8)
rgb = arr[:, :, :3].astype(np.int16)
alpha = arr[:, :, 3].astype(np.int16)
r = rgb[:, :, 0]
g = rgb[:, :, 1]
b = rgb[:, :, 2]
max_rb = np.maximum(r, b)
green_delta = g - max_rb
hard_key = ((g > 80) & (green_delta > 18)) | ((g > 105) & (g > r * 1.16) & (g > b * 1.16))
soft_key = (g > 64) & (green_delta > 5) & ~hard_key
arr[hard_key, 3] = 0
if np.any(soft_key):
arr[soft_key, 1] = np.clip(max_rb[soft_key] + 3, 0, 255).astype(np.uint8)
arr[soft_key, 3] = np.clip(alpha[soft_key] * 0.72, 0, 255).astype(np.uint8)
arr[(arr[:, :, 3] > 0) & (arr[:, :, 3] < 18), 3] = 0
im = Image.fromarray(arr, "RGBA")
if target_box:
src_box = alpha_bbox(im, threshold=12)
if src_box:
x0, y0, x1, y1 = src_box
tx0, ty0, tx1, ty1 = [int(v) for v in target_box]
target_w = max(1, tx1 - tx0)
target_h = max(1, ty1 - ty0)
crop = im.crop((x0, y0, x1, y1)).resize((target_w, target_h), Image.Resampling.LANCZOS)
placed = Image.new("RGBA", TARGET_CANVAS, (0, 0, 0, 0))
placed.alpha_composite(crop, (tx0, ty0))
im = placed
dst.parent.mkdir(parents=True, exist_ok=True)
im.save(dst, optimize=True)
def validate_box(path: Path, expected_box: list[int] | tuple[int, int, int, int] | None) -> dict:
im = Image.open(path).convert("RGBA")
box = alpha_bbox(im, threshold=8)
result: dict[str, object] = {"box": list(box) if box else None, "ok": False}
if not expected_box or not box:
return result
expected = [int(v) for v in expected_box]
actual = [int(v) for v in box]
drift = [actual[i] - expected[i] for i in range(4)]
result["expectedBox"] = expected
result["drift"] = drift
result["ok"] = max(abs(v) for v in drift) <= 2
return result
def to_wsl_path(path: Path) -> str:
resolved = path.resolve()
drive = resolved.drive.rstrip(":").lower()
rest = resolved.as_posix()[2:]
return f"/mnt/{drive}{rest}"
def to_msys_path(path: Path) -> str:
resolved = path.resolve()
drive = resolved.drive.rstrip(":").lower()
rest = resolved.as_posix()[2:]
return f"/{drive}{rest}"
def run_wrapper(raw: Path, ref: Path, prompt: str, quality: str) -> subprocess.CompletedProcess[str]:
if platform.system().lower().startswith("windows"):
bash_exe = str(GIT_BASH) if GIT_BASH.exists() else "bash"
wrapper = to_msys_path(WRAPPER) if GIT_BASH.exists() else WSL_WRAPPER
raw_path = to_msys_path(raw) if GIT_BASH.exists() else to_wsl_path(raw)
ref_path = to_msys_path(ref) if GIT_BASH.exists() else to_wsl_path(ref)
py_prefix = "" if GIT_BASH.exists() else f"PYTHON={shlex.quote(WSL_PYTHON)} "
command = " ".join(
[
py_prefix + shlex.quote(wrapper),
"--out",
shlex.quote(raw_path),
"--size",
shlex.quote(GEN_SIZE),
"--quality",
shlex.quote(quality),
"-i",
shlex.quote(ref_path),
"--prompt",
shlex.quote(prompt),
]
)
return subprocess.run([bash_exe, "-lc", command], cwd=ROOT, text=True, capture_output=True, check=False)
args = [
"bash",
str(WRAPPER),
"--out",
str(raw),
"--size",
GEN_SIZE,
"--quality",
quality,
"-i",
str(ref),
"--prompt",
prompt,
]
return subprocess.run(args, cwd=ROOT, text=True, capture_output=True, check=False)
def run_one(persona_code: str, part: dict, quality: str, overwrite: bool, reprocess_existing: bool) -> dict:
raw_dir, final_dir, prompts_dir = ensure_dirs(persona_code)
part_id = part["id"]
prompt = prompt_for(persona_code, part)
prompt_path = prompts_dir / f"{part_id}.txt"
prompt_path.write_text(prompt, encoding="utf-8")
ref = make_reference_crop(part)
raw = raw_dir / f"{part_id}.png"
final = final_dir / f"{part_id}.png"
if not ref.exists():
raise FileNotFoundError(ref)
target_box = resolved_alpha_box(part)
if target_box is None:
final.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGBA", TARGET_CANVAS, (0, 0, 0, 0)).save(final, optimize=True)
return {
"persona": persona_code,
"part": part_id,
"status": "empty-source",
"final": str(final),
"boxValidation": {"box": None, "ok": True, "emptySource": True},
}
if final.exists() and reprocess_existing and raw.exists():
remove_key_and_resize(raw, final, target_box, part_id)
return {
"persona": persona_code,
"part": part_id,
"status": "reprocessed",
"raw": str(raw),
"final": str(final),
"boxValidation": validate_box(final, target_box),
}
if final.exists() and not overwrite:
return {
"persona": persona_code,
"part": part_id,
"status": "exists",
"final": str(final),
"boxValidation": validate_box(final, target_box),
}
os.environ["PYTHONUTF8"] = "1"
result = run_wrapper(raw, ref, prompt, quality)
if result.returncode != 0:
return {
"persona": persona_code,
"part": part_id,
"status": "failed",
"stdout": result.stdout[-2000:],
"stderr": result.stderr[-4000:],
}
remove_key_and_resize(raw, final, target_box, part_id)
return {
"persona": persona_code,
"part": part_id,
"status": "generated",
"raw": str(raw),
"final": str(final),
"boxValidation": validate_box(final, target_box),
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--personas", nargs="+", default=["P4", "P5", "P6", "P7"])
parser.add_argument("--parts", nargs="*", default=[])
parser.add_argument("--tier", choices=["core", "body", "all"], default="core")
parser.add_argument("--quality", choices=["low", "medium", "high", "auto"], default="low")
parser.add_argument("--concurrency", type=int, default=4)
parser.add_argument("--overwrite", action="store_true")
parser.add_argument("--reprocess-existing", action="store_true")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
manifest = read_manifest()
all_parts = {part["id"]: part for part in manifest["parts"]}
part_ids = args.parts
if not part_ids:
if args.tier == "core":
part_ids = CORE_PARTS
elif args.tier == "body":
part_ids = BODY_PARTS
else:
part_ids = list(all_parts.keys())
jobs = []
for persona_code in args.personas:
if persona_code not in PERSONAS:
raise ValueError(f"unknown persona {persona_code}")
_, _, prompts_dir = ensure_dirs(persona_code)
plan_parts = []
for pid in part_ids:
if pid not in all_parts:
continue
part = dict(all_parts[pid])
part["sourceCrop"] = str(make_reference_crop(all_parts[pid]).relative_to(ROOT))
plan_parts.append(part)
plan = {
"persona": persona_code,
"name": PERSONAS[persona_code]["name"],
"sourceArtSet": "seoyeon-live2d-psd-v2",
"targetArtSet": f"{persona_code.lower()}-live2d-generated",
"canvas": TARGET_CANVAS,
"generationSize": GEN_SIZE,
"parts": plan_parts,
}
plan_path = prompts_dir.parent / ("generation-plan-last-run.json" if args.parts else "generation-plan.json")
plan_path.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
for pid in part_ids:
if pid not in all_parts:
raise KeyError(pid)
prompt = prompt_for(persona_code, all_parts[pid])
(prompts_dir / f"{pid}.txt").write_text(prompt, encoding="utf-8")
jobs.append((persona_code, all_parts[pid]))
queue_path = ROOT / "docs/avatar-art/personas/generation-queue.jsonl"
queue_path.parent.mkdir(parents=True, exist_ok=True)
queue_path.write_text(
"".join(json.dumps({"persona": p, "part": part["id"]}, ensure_ascii=False) + "\n" for p, part in jobs),
encoding="utf-8",
)
if args.dry_run:
print(f"dry-run jobs={len(jobs)} queue={queue_path}")
return 0
results = []
with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as pool:
futures = [
pool.submit(run_one, persona, part, args.quality, args.overwrite, args.reprocess_existing)
for persona, part in jobs
]
for future in as_completed(futures):
result = future.result()
results.append(result)
print(json.dumps(result, ensure_ascii=False), flush=True)
summary_path = ROOT / "docs/avatar-art/personas/generation-results.json"
summary_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
failed = [item for item in results if item["status"] == "failed"]
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())