"""스타일링 ④ — 포스터를 레퍼런스 화풍으로 다시 그린다 내부 검토용이다. 번들 레퍼런스의 권리가 남아 있어 결과물을 그대로 밖에 내지 않는다. """ 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)