From 2140f7d5c8d5101ec5dda4f6fa095b5074d3d38a Mon Sep 17 00:00:00 2001 From: jaehwang Date: Wed, 9 Sep 2026 10:34:58 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=EB=A7=81=201?= =?UTF-8?q?=EC=B0=A8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/answers/poster_text_answer.py | 17 +++ backend/answers/style_prompt_answer.py | 21 +++ backend/models/styling.py | 60 +++++++++ backend/raw_prompt/poster_text.txt | 17 +++ backend/raw_prompt/style_prompt.txt | 30 +++++ backend/routers/playreel.py | 2 + backend/services/styling/info_band.py | 126 ++++++++++++++++++ backend/services/styling/poster_text.py | 37 ++++++ backend/services/styling/style_analysis.py | 57 ++++++++ backend/services/styling/transfer.py | 144 +++++++++++++++++++++ frontend/lib/playreel.ts | 2 + known_issue.md | 18 +++ 12 files changed, 531 insertions(+) create mode 100644 backend/answers/poster_text_answer.py create mode 100644 backend/answers/style_prompt_answer.py create mode 100644 backend/models/styling.py create mode 100644 backend/raw_prompt/poster_text.txt create mode 100644 backend/raw_prompt/style_prompt.txt create mode 100644 backend/services/styling/info_band.py create mode 100644 backend/services/styling/poster_text.py create mode 100644 backend/services/styling/style_analysis.py create mode 100644 backend/services/styling/transfer.py diff --git a/backend/answers/poster_text_answer.py b/backend/answers/poster_text_answer.py new file mode 100644 index 0000000..0be30a7 --- /dev/null +++ b/backend/answers/poster_text_answer.py @@ -0,0 +1,17 @@ +"""스타일링 ① — 포스터에 인쇄된 글자 판독 응답 스키마 + +이미지 생성 모델이 한글을 다시 그리며 자모를 바꾸므로(맥주축제 → 백주축제) +읽기 잘하는 모델에 먼저 읽히고 그 문자열을 생성 프롬프트에 못 박는다. +""" +from pydantic import BaseModel + +MAX_EXTRA_LINES = 3 + + +class PosterTextAnswer(BaseModel): + title: str + slogan: str + date: str + time: str + place: str + extra: list[str] diff --git a/backend/answers/style_prompt_answer.py b/backend/answers/style_prompt_answer.py new file mode 100644 index 0000000..d9f50df --- /dev/null +++ b/backend/answers/style_prompt_answer.py @@ -0,0 +1,21 @@ +"""스타일링 ② — 레퍼런스 화풍 분석 응답 스키마 + +무드 형용사는 픽셀로 번역되지 않아 팔레트·조명·질감·타이포·구도 다섯을 요구한다. +짧게 나오면 다섯 중 빠진 것이 있다는 뜻이라 통과시키지 않는다. +""" +from pydantic import BaseModel, field_validator + +MIN_STYLE_PROMPT_CHARS = 200 + + +class StylePromptAnswer(BaseModel): + style_prompt: str + + @field_validator("style_prompt") + @classmethod + def must_cover_five_elements(cls, style_prompt: str) -> str: + text = style_prompt.strip() + if len(text) < MIN_STYLE_PROMPT_CHARS: + raise ValueError(f"style_prompt가 {len(text)}자로 너무 짧음 " + f"({MIN_STYLE_PROMPT_CHARS}자 이상) — 5요소가 다 안 나옴") + return text diff --git a/backend/models/styling.py b/backend/models/styling.py new file mode 100644 index 0000000..84cb963 --- /dev/null +++ b/backend/models/styling.py @@ -0,0 +1,60 @@ +"""포스터 스타일링 — 화풍 레퍼런스로 포스터를 다시 그린다""" +from dataclasses import dataclass +from enum import StrEnum + +from pydantic import BaseModel + + +class TemplateLicense(StrEnum): + """끄는 조건이 달라 등급을 나눈다 + PUBLIC은 보호기간이 끝나 외부 공개가 되고 + INTERNAL은 번들 레퍼런스의 권리가 남아 배포 시 감추며 + USER는 올린 사람 책임이라 막지 않고 고지만 한다 + """ + PUBLIC = "public-domain" + INTERNAL = "internal-only" + USER = "user-uploaded" + + +class TemplateCategory(StrEnum): + FINE_ART = "fine_art" + ORIENTAL = "oriental" + FILM = "film" + USER = "user" + + +class StyleTemplate(BaseModel): + """번들과 사용자 레퍼런스가 같은 모양을 쓴다""" + id: str + name_ko: str + category: TemplateCategory + license: TemplateLicense + style_prompt: str + reference_url: str # 전이에 넣는 고해상본 + thumbnail_url: str + attribution: str = "" + license_note: str = "" + small_reference: bool = False # 레퍼런스가 작아 결과가 나빠질 수 있음 + + +class OutputFormat(BaseModel): + """gpt-image가 내는 크기로 만든 뒤 필요하면 센터 크롭한다""" + id: str + label: str + generated_size: str + crop: tuple[int, int] | None = None + + +@dataclass +class ReferenceVariants: + reference: bytes # 긴 변 REFERENCE_LONG_EDGE + thumbnail: bytes + size: tuple[int, int] + + +@dataclass +class TransferResult: + image: bytes + format_id: str + style_prompt: str + has_info_band: bool # 날짜·장소를 밴드로 얹었는가 diff --git a/backend/raw_prompt/poster_text.txt b/backend/raw_prompt/poster_text.txt new file mode 100644 index 0000000..9b1ac16 --- /dev/null +++ b/backend/raw_prompt/poster_text.txt @@ -0,0 +1,17 @@ +포스터에 인쇄된 글자를 정확히 읽어라. + +주의: +- 장식 서체는 획이 붙거나 끊겨 다른 글자로 보인다. `맥주축제`를 `백주축제`로 읽는 실수가 실제로 났다. + ㅁ/ㅂ, ㅈ/ㅊ, ㅌ/ㄷ을 한 자씩 확인하라. +- 영문·로마자 병기가 있으면 대조해 검산하라. +- 읽어낸 말이 한국어로 뜻이 통하지 않으면 오독이다. 이미지 내용과도 맞는지 확인하라 + (맥주잔이 그려져 있으면 `백주축제`가 아니라 `맥주축제`다). +- 없는 항목은 빈 문자열로 둔다. 지어내지 마라. + +각 항목: +- title — 행사명. 가장 크게 인쇄된 제목 글자 그대로 +- slogan — 따옴표 안 슬로건 등 부제 +- date — 날짜 표기 그대로 +- time — 시간 표기 그대로 +- place — 장소 표기 그대로 +- extra — 그 밖에 포스터에 크게 박힌 문구 최대 3개 diff --git a/backend/raw_prompt/style_prompt.txt b/backend/raw_prompt/style_prompt.txt new file mode 100644 index 0000000..88966a0 --- /dev/null +++ b/backend/raw_prompt/style_prompt.txt @@ -0,0 +1,30 @@ +You write style specifications for an image-generation model. + +You will see one reference artwork. Produce an English spec that another model can follow to +REDRAW an unrelated poster in this exact visual language. The spec must be executable — every +clause has to translate into pixels. + +Cover all five elements, in this order, in one flowing paragraph of 5-7 sentences: + +1. PALETTE — name 3-5 actual colours as a painter would ("cobalt blue, chrome yellow, deep + viridian"), plus how they are distributed (dominant / accent / shadow). +2. LIGHTING — direction, hardness and contrast of the light. If the work has no single light + source, say so and say what carries the luminance instead. +3. TEXTURE / MEDIUM — the physical medium and mark-making: brush size and direction, impasto, + woodblock keyline, glaze, film grain, canvas or paper weave. +4. TYPOGRAPHY TREATMENT — how a title would be lettered IN THIS IDIOM **while remaining fully + legible in Korean Hangul**. Derive the colour, the surface it sits on and its placement from + the medium (the reference may contain no lettering at all — derive it anyway; never omit this). + But the letterforms themselves are constrained, because Hangul stacks two to four jamo inside + one square and a lost stroke turns it into a different word: demand EVEN, UNBROKEN stroke + weight, open counters, and generous spacing between characters. Explicitly forbid tapering or + dry-brush stroke ends, hand-brushed irregularity, hairline serifs, distressed or eroded edges, + and lettering that blends into the artwork. Say the title must read as clean, solid shapes + even though the rest of the image is in this medium. +5. COMPOSITION MOTIF — horizon placement, dominant shape and its direction of movement, figure + scale relative to frame, use of empty space, any repeating pattern. + +BANNED: mood adjectives standing alone ("epic", "dreamy", "haunting", "vibrant", "unsettling", +"atmospheric"). A mood word is only allowed if the same sentence names the colour, direction, +medium or shape that produces it. Do not name the artwork, the artist or the art movement — +describe what is on the surface, because the generating model may not know the reference. diff --git a/backend/routers/playreel.py b/backend/routers/playreel.py index 336dc18..222f4ae 100644 --- a/backend/routers/playreel.py +++ b/backend/routers/playreel.py @@ -19,6 +19,8 @@ router = APIRouter(prefix="/api/playreel", tags=["playreel"]) # 프론트 parseGoodsId와 같은 규칙. 프론트 검사는 입력 도중의 안내이고 판정은 여기서 한다 GOODS_ID_PATTERNS = ( + # 인터파크 주소는 야놀자로 리다이렉트된다. 상품 번호는 양쪽이 같다 + r"nol\.yanolja\.com/ticket/products/(\d{5,})", r"tickets\.interpark\.com/goods/(\d{5,})", r"nol\.interpark\.com/[^?]*?(\d{8,})", r"[?&]goodsCode=(\d{5,})", diff --git a/backend/services/styling/info_band.py b/backend/services/styling/info_band.py new file mode 100644 index 0000000..7b95854 --- /dev/null +++ b/backend/services/styling/info_band.py @@ -0,0 +1,126 @@ +"""스타일링 ③ — 생성된 포스터 하단에 정보 밴드를 그린다 + +큰 제목은 생성 모델이 대체로 맞추지만 작은 글자에서 자모를 흘린다 +(동대문구 → 몽대문구, 체험존 → 채헐존). 프롬프트로는 더 못 밀어낸다. +그래서 날짜·시간·장소는 모델에 맡기지 않고 실제 폰트로 직접 그린다. + +밴드 색은 이미지 하단에서 뽑아 얹은 티를 줄인다. +""" +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +from answers.poster_text_answer import PosterTextAnswer +from utils.font import font + +PAD_RATIO = 0.055 +BAND_RATIO_WITH_SLOGAN = 0.165 +BAND_RATIO = 0.13 +FEATHER_RATIO = 0.42 +FEATHER_CURVE = 1.4 +FEATHER_BLUR = 3 + +DARK_LUMINANCE = 118 # 이보다 어두우면 밝은 글자를 얹는다 +DARK_BACKGROUND_GAIN = 0.42 +LIGHT_BACKGROUND_GAIN, LIGHT_BACKGROUND_LIFT = 1.06, 26 +LIGHT_INK = (245, 243, 238) +DARK_INK = (26, 24, 22) + +MIN_FONT_SIZE = 12 +FONT_STEP = 2 +ROW_GAP_RATIO = 0.085 + +QUOTE_MARKS = '"“”' + + +def band_palette(image: Image.Image, band_top: int) -> tuple[tuple, tuple]: + """밴드 배경색과 글자색을 이미지 하단 평균색에서 뽑는다""" + width, height = image.size + strip = image.crop((0, band_top, width, height)).resize((1, 1), Image.LANCZOS) + red, green, blue = strip.getpixel((0, 0))[:3] + luminance = 0.299 * red + 0.587 * green + 0.114 * blue + + if luminance < DARK_LUMINANCE: + background = tuple(int(channel * DARK_BACKGROUND_GAIN) + for channel in (red, green, blue)) + return background, LIGHT_INK + background = tuple(min(255, int(channel * LIGHT_BACKGROUND_GAIN + LIGHT_BACKGROUND_LIFT)) + for channel in (red, green, blue)) + return background, DARK_INK + + +def fit_font(draw: ImageDraw.ImageDraw, text: str, weight: str, max_width: int, + start_size: int) -> ImageFont.FreeTypeFont: + """max_width 안에 들어가는 가장 큰 크기를 찾는다""" + size = start_size + while size > MIN_FONT_SIZE: + candidate = font(weight, size) + if draw.textlength(text, font=candidate) <= max_width: + return candidate + size -= FONT_STEP + return font(weight, MIN_FONT_SIZE) + + +def band_rows(text: PosterTextAnswer, band_height: int) -> list[tuple[str, str, int, float]]: + """슬로건 → 날짜+시간 → 장소 순. 날짜를 가장 크게 잡는다 + + 슬로건은 원래 제목 근처에 놓이는 문구지만 생성 결과의 제목 위치가 매번 달라 + 상단에 얹으면 아트워크와 부딪힌다. 밴드 첫 줄로 내려 항상 읽히게 한다. + """ + slogan = text.slogan.strip().strip(QUOTE_MARKS) + datetime_line = text.date.strip() + (f" {text.time.strip()}" if text.time.strip() else "") + + rows = [] + if slogan: + rows.append((f"“{slogan}”", "SemiBold", round(band_height * 0.20), 0.72)) + if datetime_line: + rows.append((datetime_line, "Bold", round(band_height * 0.33), 1.0)) + if text.place.strip(): + rows.append((text.place.strip(), "SemiBold", round(band_height * 0.21), 0.92)) + return rows + + +def paint_scrim(image: Image.Image, band_top: int, band_height: int, + background: tuple) -> None: + """위쪽 경계가 칼같이 잘리면 얹은 티가 난다. 그라데이션으로 이어 붙인다""" + width = image.width + scrim = Image.new("RGB", (width, band_height), background) + mask = Image.new("L", (width, band_height), 255) + mask_draw = ImageDraw.Draw(mask) + feather = round(band_height * FEATHER_RATIO) + for row in range(feather): + mask_draw.line([(0, row), (width, row)], + fill=int(255 * (row / feather) ** FEATHER_CURVE)) + image.paste(scrim, (0, band_top), mask.filter(ImageFilter.GaussianBlur(FEATHER_BLUR))) + + +def compose_info_band(poster: Image.Image, text: PosterTextAnswer) -> Image.Image | None: + """밴드를 얹은 이미지를 돌려준다. 그릴 내용이 없으면 None""" + if not (text.date.strip() or text.place.strip()): + return None + + image = poster.convert("RGB") + width, height = image.size + band_height = round(height * (BAND_RATIO_WITH_SLOGAN if text.slogan.strip() + else BAND_RATIO)) + band_top = height - band_height + background, ink = band_palette(image, band_top) + paint_scrim(image, band_top, band_height, background) + + draw = ImageDraw.Draw(image) + inner_width = width - round(width * PAD_RATIO) * 2 + rows = band_rows(text, band_height) + fitted = [(line, fit_font(draw, line, weight, inner_width, size), opacity) + for line, weight, size, opacity in rows] + + gap = round(band_height * ROW_GAP_RATIO) + heights = [chosen.getbbox(line)[3] - chosen.getbbox(line)[1] + for line, chosen, _ in fitted] + top = band_top + (band_height - (sum(heights) + gap * (len(fitted) - 1))) // 2 \ + - round(band_height * 0.03) + + for (line, chosen, opacity), line_height in zip(fitted, heights, strict=True): + colour = ink if opacity >= 1.0 else tuple( + round(channel * opacity + background[index] * (1 - opacity)) + for index, channel in enumerate(ink)) + draw.text((width // 2, top), line, font=chosen, fill=colour, anchor="ma") + top += line_height + gap + return image diff --git a/backend/services/styling/poster_text.py b/backend/services/styling/poster_text.py new file mode 100644 index 0000000..410dd4e --- /dev/null +++ b/backend/services/styling/poster_text.py @@ -0,0 +1,37 @@ +"""스타일링 ① — 포스터에 인쇄된 글자를 읽는다 + +이미지 생성 모델은 한글을 다시 그리며 자모를 바꾼다(맥주축제 → 백주축제). +프롬프트로는 못 막으므로 읽기를 먼저 하고 그 문자열을 생성 프롬프트에 못 박는다. +""" +from PIL import Image + +from answers.poster_text_answer import PosterTextAnswer +from settings import settings +from utils.common_llm import StructuredLLM +from utils.image import to_data_uri +from utils.prompt import load_prompt + +# 자모가 보일 만큼은 키워서 넣는다 +MIN_LONG_EDGE = 1400 +MAX_SIZE = (1536, 1536) + +POSTER_TEXT_PROMPT = load_prompt("poster_text") + +poster_text_llm = StructuredLLM("gpt-4o", settings.chatgpt_api_key) + + +def readable_copy(poster: Image.Image) -> Image.Image: + source = poster.convert("RGB") + if max(source.size) < MIN_LONG_EDGE: + scale = MIN_LONG_EDGE / max(source.size) + source = source.resize((round(source.width * scale), round(source.height * scale)), + Image.LANCZOS) + source.thumbnail(MAX_SIZE, Image.LANCZOS) + return source + + +async def read_poster_text(poster: Image.Image) -> PosterTextAnswer: + image = readable_copy(poster) + return await poster_text_llm.ask_with_images( + PosterTextAnswer, POSTER_TEXT_PROMPT, [("포스터", to_data_uri(image))], + temperature=0.1) diff --git a/backend/services/styling/style_analysis.py b/backend/services/styling/style_analysis.py new file mode 100644 index 0000000..d09e773 --- /dev/null +++ b/backend/services/styling/style_analysis.py @@ -0,0 +1,57 @@ +"""스타일링 ② — 레퍼런스 이미지에서 화풍 명세를 뽑는다 + +번들 템플릿 빌드와 사용자 업로드가 같은 경로를 탄다. +둘이 갈라지면 사용자 템플릿만 품질이 떨어지고 원인을 찾기 어려워진다. +""" +import io + +from PIL import Image + +from answers.style_prompt_answer import StylePromptAnswer +from models.styling import ReferenceVariants +from settings import settings +from utils.common_llm import StructuredLLM +from utils.image import to_data_uri +from utils.prompt import load_prompt + +REFERENCE_LONG_EDGE = 2400 # 붓질이 보이는 최소선 +THUMBNAIL_LONG_EDGE = 600 # 화면 카드 +VISION_SIZE = (1536, 1536) +SMALL_REFERENCE_WARN = 600 # 이보다 작으면 결과가 나빠진다. 막지는 않는다 + +REFERENCE_QUALITY = 92 +THUMBNAIL_QUALITY = 88 + +STYLE_PROMPT_PROMPT = load_prompt("style_prompt") + +style_llm = StructuredLLM("gpt-4o", settings.chatgpt_api_key) + + +def encode(image: Image.Image, quality: int) -> bytes: + buffer = io.BytesIO() + image.save(buffer, "JPEG", quality=quality, optimize=True) + return buffer.getvalue() + + +def build_variants(raw: bytes) -> ReferenceVariants: + """올린 바이트에서 전이용 고해상본과 카드용 썸네일을 만든다""" + Image.MAX_IMAGE_PIXELS = None + source = Image.open(io.BytesIO(raw)).convert("RGB") + source.thumbnail((REFERENCE_LONG_EDGE, REFERENCE_LONG_EDGE), Image.LANCZOS) + + thumbnail = source.copy() + thumbnail.thumbnail((THUMBNAIL_LONG_EDGE, THUMBNAIL_LONG_EDGE), Image.LANCZOS) + + return ReferenceVariants(reference=encode(source, REFERENCE_QUALITY), + thumbnail=encode(thumbnail, THUMBNAIL_QUALITY), + size=source.size) + + +async def analyze_style(reference: bytes) -> str: + Image.MAX_IMAGE_PIXELS = None + image = Image.open(io.BytesIO(reference)).convert("RGB") + + answer = await style_llm.ask_with_images( + StylePromptAnswer, STYLE_PROMPT_PROMPT, + [("레퍼런스", to_data_uri(image, VISION_SIZE))], temperature=0.2) + return answer.style_prompt diff --git a/backend/services/styling/transfer.py b/backend/services/styling/transfer.py new file mode 100644 index 0000000..365deea --- /dev/null +++ b/backend/services/styling/transfer.py @@ -0,0 +1,144 @@ +"""스타일링 ④ — 포스터를 레퍼런스 화풍으로 다시 그린다 + +내부 검토용이다. 번들 레퍼런스의 권리가 남아 있어 결과물을 그대로 밖에 내지 않는다. +""" +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) diff --git a/frontend/lib/playreel.ts b/frontend/lib/playreel.ts index 7fcdb20..2c18834 100644 --- a/frontend/lib/playreel.ts +++ b/frontend/lib/playreel.ts @@ -101,6 +101,8 @@ export function parseGoodsId(url: string): string | null { const s = url.trim(); if (!s) return null; const m = + // 인터파크 주소는 야놀자로 리다이렉트된다. 상품 번호는 양쪽이 같다 + s.match(/nol\.yanolja\.com\/ticket\/products\/(\d{5,})/i) ?? s.match(/tickets\.interpark\.com\/goods\/(\d{5,})/i) ?? s.match(/nol\.interpark\.com\/[^?]*?(\d{8,})/i) ?? s.match(/[?&]goodsCode=(\d{5,})/i) ?? diff --git a/known_issue.md b/known_issue.md index d53f0c5..7185ecb 100644 --- a/known_issue.md +++ b/known_issue.md @@ -173,6 +173,24 @@ CLI를 계속 쓰는 한 없앨 수 없다. Higgsfield HTTP API로 가면 presig CLI `generate create` 응답에 크레딧 필드가 없어서(⑥ i2v에서 확인) 원본이 쓰던 값 2를 그대로 상수로 뒀다. 요금이 바뀌면 조용히 틀린 값이 기록된다. +## [Playreel] ⑥ narration + +### 나레이션이 템플릿이라 작품이 달라도 문장이 같다 + +7문장 중 다섯이 고정 문자열이고, 값이 들어가는 자리는 공연명·장르·출연진·기간·장소뿐이다. +축제 3문장은 VLM이 포스터를 보고 쓰는데 여기는 `meta`를 문자열에 끼워 넣는다. + +슬롯 이름과 내용도 어긋난다. `수상` 자리에 장르 문장이, `넘버` 자리에 출연진 문장이 들어간다. + +원본도 같다. `playreel_pipeline._narration_lines`가 문장까지 동일하다. +다만 원본에는 데모용 `FAKE_LINES`가 따로 있고 거기에는 "브로드웨이가 사랑한 무대가 +서울에 옵니다", "렛 잇 고, 그 순간을 눈앞에서" 같은 문장이 손으로 적혀 있다. +프론트 목 데이터도 그 톤이다. 슬롯 이름이 `수상`·`넘버`인 이유가 그것이고, +그 문장을 만들어내는 코드는 원본에도 없다. + +재료는 이미 있다. ② split이 `synopsis`·`cast`·`still`·`discount` 태그로 잘라두고 +이미지도 blob에 있다. `extract_cast`가 cast 섹션을 VLM에 물어보는 방식을 그대로 쓸 수 있다. + ## [Playreel] ⑪ compose ### 스크롤 범위를 사람이 정하는 경로가 없다