145 lines
6.7 KiB
Python
145 lines
6.7 KiB
Python
"""스타일링 ④ — 포스터를 레퍼런스 화풍으로 다시 그린다
|
|
|
|
내부 검토용이다. 번들 레퍼런스의 권리가 남아 있어 결과물을 그대로 밖에 내지 않는다.
|
|
"""
|
|
import base64
|
|
import io
|
|
|
|
import httpx
|
|
from PIL import Image
|
|
|
|
from answers.poster_text_answer import PosterTextAnswer
|
|
from models.styling import OutputFormat, StyleTemplate, TransferResult
|
|
from services.styling.info_band import compose_info_band
|
|
from services.styling.poster_text import read_poster_text
|
|
from settings import settings
|
|
|
|
IMAGES_EDIT_API = "https://api.openai.com/v1/images/edits"
|
|
IMAGE_MODEL = "gpt-image-2" # gpt-image-1은 한글 제목 끝자를 흘렸다
|
|
IMAGE_QUALITY = "high"
|
|
REQUEST_TIMEOUT = httpx.Timeout(300.0, connect=10.0)
|
|
|
|
# gpt-image가 내는 크기로 생성한 뒤 필요하면 센터 크롭한다
|
|
OUTPUT_FORMATS = (
|
|
OutputFormat(id="poster", label="포스터 2:3", generated_size="1024x1536"),
|
|
OutputFormat(id="story", label="스토리 9:16", generated_size="1024x1536",
|
|
crop=(864, 1536)),
|
|
OutputFormat(id="feed", label="피드 4:5", generated_size="1024x1536",
|
|
crop=(1024, 1280)),
|
|
OutputFormat(id="square", label="정방형 1:1", generated_size="1024x1024"),
|
|
)
|
|
FORMATS_BY_ID = {output.id: output for output in OUTPUT_FORMATS}
|
|
|
|
SQUARE_SIZE = "1024x1024"
|
|
RESERVED_BOTTOM = "22%" # 정보 밴드가 들어갈 자리
|
|
|
|
# 성공한 프롬프트만 "그릴 수 있는 말"로 돼 있었다. 무드 형용사는 픽셀로 번역되지 않는다.
|
|
# 또 하나 — 성공 사례는 스타일만 얹은 게 아니라 그 화법으로 장면을 다시 그렸다.
|
|
TRANSFER_PROMPT = (
|
|
"Redraw the first image as a brand-new movie-style poster, fully committing to the "
|
|
"visual language described below. Do not merely apply a color filter — rebuild the "
|
|
"illustration, lighting, texture and title lettering from scratch in that style.\n\n"
|
|
"STYLE TO ADOPT: {style_prompt}\n\n"
|
|
"KEEP FROM THE ORIGINAL: the event name and the subject matter of the artwork (what the "
|
|
"event is about). The people, objects and scenery may be re-staged and re-drawn in the "
|
|
"new style.\n\n"
|
|
"{text_manifest}"
|
|
"TEXT RULE: render Korean text sharp and legible. Do not swap visually similar jamo "
|
|
"(ㅁ/ㅂ, ㅈ/ㅊ), do not drop trailing characters, and do not invent words. {orientation}"
|
|
)
|
|
|
|
TITLE_MANIFEST = (
|
|
"TEXT TO RENDER — the poster must contain exactly ONE piece of text, the main "
|
|
"title, rendered character-for-character as written here. This is the correct "
|
|
"reading; if the original image looks different, trust this line:\n"
|
|
" Main title: 「{title}」\n"
|
|
# 위치를 안 박으면 제목을 하단 예약 영역에 놓아 정보 밴드와 겹친다
|
|
" Placement: put this title in the UPPER THIRD of the poster, near the top "
|
|
"edge. It must not appear in the lower half.\n\n"
|
|
)
|
|
|
|
# 작은 글자는 모델이 자모를 흘린다(주류 → 중류). 제목만 그리게 하고 나머지는 밴드로 얹는다.
|
|
NO_OTHER_TEXT = (
|
|
"DO NOT RENDER any other text. No tagline or slogan, no date, no time, no venue "
|
|
"name, no programme or zone labels, no admission info, no phone numbers, no URLs, "
|
|
"no organiser or sponsor credits, no logos with lettering. Apart from the single "
|
|
f"title line the poster must be free of characters. Leave the bottom {RESERVED_BOTTOM} "
|
|
"as artwork or background only — that area is reserved and will be filled in afterwards.\n\n"
|
|
)
|
|
|
|
|
|
def text_manifest(text: PosterTextAnswer) -> str:
|
|
title = TITLE_MANIFEST.format(title=text.title) if text.title.strip() else ""
|
|
return title + NO_OTHER_TEXT
|
|
|
|
|
|
def build_prompt(template: StyleTemplate, text: PosterTextAnswer,
|
|
output: OutputFormat) -> str:
|
|
orientation = ("Square 1:1 poster." if output.generated_size == SQUARE_SIZE
|
|
else "Vertical 2:3 poster.")
|
|
return TRANSFER_PROMPT.format(style_prompt=template.style_prompt,
|
|
text_manifest=text_manifest(text),
|
|
orientation=orientation)
|
|
|
|
|
|
def center_crop(image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
|
target_width, target_height = size
|
|
scale = max(target_width / image.width, target_height / image.height)
|
|
scaled = image.resize((round(image.width * scale), round(image.height * scale)),
|
|
Image.LANCZOS)
|
|
left = (scaled.width - target_width) // 2
|
|
top = (scaled.height - target_height) // 2
|
|
return scaled.crop((left, top, left + target_width, top + target_height))
|
|
|
|
|
|
def encode_png(image: Image.Image) -> bytes:
|
|
buffer = io.BytesIO()
|
|
image.save(buffer, "PNG")
|
|
return buffer.getvalue()
|
|
|
|
|
|
async def request_edit(poster: bytes, reference: bytes, prompt: str,
|
|
generated_size: str) -> bytes:
|
|
files = [
|
|
("image[]", ("poster.jpg", poster, "image/jpeg")),
|
|
("image[]", ("reference.jpg", reference, "image/jpeg")),
|
|
]
|
|
data = {"model": IMAGE_MODEL, "prompt": prompt, "size": generated_size,
|
|
"quality": IMAGE_QUALITY, "n": "1"}
|
|
|
|
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
|
response = await client.post(
|
|
IMAGES_EDIT_API, files=files, data=data,
|
|
headers={"Authorization": f"Bearer {settings.chatgpt_api_key}"})
|
|
if response.status_code != 200:
|
|
raise RuntimeError(f"{IMAGE_MODEL} 호출 실패 {response.status_code}: "
|
|
f"{response.text[:400]}")
|
|
|
|
items = response.json().get("data") or []
|
|
if not items or not items[0].get("b64_json"):
|
|
raise RuntimeError(f"{IMAGE_MODEL} 빈 응답 — 거절되었을 수 있다")
|
|
return base64.b64decode(items[0]["b64_json"])
|
|
|
|
|
|
async def transfer_style(poster: bytes, template: StyleTemplate, reference: bytes, *,
|
|
format_id: str = "poster",
|
|
text: PosterTextAnswer | None = None) -> TransferResult:
|
|
output = FORMATS_BY_ID.get(format_id)
|
|
if output is None:
|
|
raise ValueError(f"미지의 format: {format_id!r} (허용: {list(FORMATS_BY_ID)})")
|
|
|
|
text = text or await read_poster_text(Image.open(io.BytesIO(poster)))
|
|
prompt = build_prompt(template, text, output)
|
|
generated = Image.open(io.BytesIO(
|
|
await request_edit(poster, reference, prompt, output.generated_size)))
|
|
|
|
if output.crop:
|
|
generated = center_crop(generated, output.crop)
|
|
# 날짜·시간·장소는 모델이 안 그렸다. 실제 폰트로 얹는다
|
|
with_band = compose_info_band(generated, text)
|
|
|
|
return TransferResult(image=encode_png(with_band or generated),
|
|
format_id=output.id,
|
|
style_prompt=template.style_prompt,
|
|
has_info_band=with_band is not None)
|