스타일링 1차 기능 추가

This commit is contained in:
jaehwang 2026-09-09 10:34:58 +09:00
parent 3555fa05a2
commit 2140f7d5c8
12 changed files with 531 additions and 0 deletions

View 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]

View 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
View 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 # 날짜·장소를 밴드로 얹었는가

View File

@ -0,0 +1,17 @@
포스터에 인쇄된 글자를 정확히 읽어라.
주의:
- 장식 서체는 획이 붙거나 끊겨 다른 글자로 보인다. `맥주축제`를 `백주축제`로 읽는 실수가 실제로 났다.
ㅁ/ㅂ, ㅈ/ㅊ, ㅌ/ㄷ을 한 자씩 확인하라.
- 영문·로마자 병기가 있으면 대조해 검산하라.
- 읽어낸 말이 한국어로 뜻이 통하지 않으면 오독이다. 이미지 내용과도 맞는지 확인하라
(맥주잔이 그려져 있으면 `백주축제`가 아니라 `맥주축제`다).
- 없는 항목은 빈 문자열로 둔다. 지어내지 마라.
각 항목:
- title — 행사명. 가장 크게 인쇄된 제목 글자 그대로
- slogan — 따옴표 안 슬로건 등 부제
- date — 날짜 표기 그대로
- time — 시간 표기 그대로
- place — 장소 표기 그대로
- extra — 그 밖에 포스터에 크게 박힌 문구 최대 3개

View 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.

View File

@ -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,})",

View 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

View 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)

View 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

View 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)

View File

@ -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) ??

View File

@ -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
### 스크롤 범위를 사람이 정하는 경로가 없다