playreel/backend/services/styling/transfer.py

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)