스타일링 1차 기능 추가
This commit is contained in:
parent
3555fa05a2
commit
2140f7d5c8
17
backend/answers/poster_text_answer.py
Normal file
17
backend/answers/poster_text_answer.py
Normal file
@ -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]
|
||||
21
backend/answers/style_prompt_answer.py
Normal file
21
backend/answers/style_prompt_answer.py
Normal file
@ -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
|
||||
60
backend/models/styling.py
Normal file
60
backend/models/styling.py
Normal file
@ -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 # 날짜·장소를 밴드로 얹었는가
|
||||
17
backend/raw_prompt/poster_text.txt
Normal file
17
backend/raw_prompt/poster_text.txt
Normal file
@ -0,0 +1,17 @@
|
||||
포스터에 인쇄된 글자를 정확히 읽어라.
|
||||
|
||||
주의:
|
||||
- 장식 서체는 획이 붙거나 끊겨 다른 글자로 보인다. `맥주축제`를 `백주축제`로 읽는 실수가 실제로 났다.
|
||||
ㅁ/ㅂ, ㅈ/ㅊ, ㅌ/ㄷ을 한 자씩 확인하라.
|
||||
- 영문·로마자 병기가 있으면 대조해 검산하라.
|
||||
- 읽어낸 말이 한국어로 뜻이 통하지 않으면 오독이다. 이미지 내용과도 맞는지 확인하라
|
||||
(맥주잔이 그려져 있으면 `백주축제`가 아니라 `맥주축제`다).
|
||||
- 없는 항목은 빈 문자열로 둔다. 지어내지 마라.
|
||||
|
||||
각 항목:
|
||||
- title — 행사명. 가장 크게 인쇄된 제목 글자 그대로
|
||||
- slogan — 따옴표 안 슬로건 등 부제
|
||||
- date — 날짜 표기 그대로
|
||||
- time — 시간 표기 그대로
|
||||
- place — 장소 표기 그대로
|
||||
- extra — 그 밖에 포스터에 크게 박힌 문구 최대 3개
|
||||
30
backend/raw_prompt/style_prompt.txt
Normal file
30
backend/raw_prompt/style_prompt.txt
Normal file
@ -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.
|
||||
@ -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,})",
|
||||
|
||||
126
backend/services/styling/info_band.py
Normal file
126
backend/services/styling/info_band.py
Normal file
@ -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
|
||||
37
backend/services/styling/poster_text.py
Normal file
37
backend/services/styling/poster_text.py
Normal file
@ -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)
|
||||
57
backend/services/styling/style_analysis.py
Normal file
57
backend/services/styling/style_analysis.py
Normal file
@ -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
|
||||
144
backend/services/styling/transfer.py
Normal file
144
backend/services/styling/transfer.py
Normal file
@ -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)
|
||||
@ -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) ??
|
||||
|
||||
@ -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
|
||||
|
||||
### 스크롤 범위를 사람이 정하는 경로가 없다
|
||||
|
||||
Loading…
Reference in New Issue
Block a user