(WIP) config 자동 생성 및 gate4 / 동작 검증 실패 (local RAM 부족)

This commit is contained in:
jaehwang 2026-09-07 17:20:27 +09:00
parent 89d9062f35
commit c3f40899a4
25 changed files with 679 additions and 1 deletions

View File

@ -0,0 +1,8 @@
"""⑩ hybrid config 생성 — 인물 영역 응답 스키마."""
from pydantic import BaseModel
from answers.detect_answer import GridBox
class PersonBoxAnswer(BaseModel):
person: GridBox | None

Binary file not shown.

View File

@ -0,0 +1,10 @@
# Pretendard
번들 사유: 정보 밴드 텍스트 합성(`text_overlay.py`)에 한글 폰트가 필요하다.
macOS 시스템 폰트(AppleSDGothicNeo)를 쓰면 컨테이너 배포에서 깨지므로,
재배포가 허용된 폰트를 저장소에 함께 둔다.
- 저작자: orioncactus (길형진)
- 버전: v1.3.9
- 출처: https://github.com/orioncactus/pretendard
- 라이선스: SIL Open Font License 1.1 (재배포·임베딩 허용)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,12 @@
<svg width="49" height="18" viewBox="0 0 49 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_9080)">
<path d="M26.414 0C21.4716 0 17.4506 4.03749 17.4506 9C17.4506 13.9625 21.4716 18 26.414 18C31.3563 18 35.3774 13.9625 35.3774 9C35.3774 4.03749 31.3573 0 26.414 0ZM26.414 14.1739C23.5731 14.1739 21.2611 11.8534 21.2611 9C21.2611 6.14656 23.5721 3.82609 26.414 3.82609C29.2558 3.82609 31.5668 6.14656 31.5668 9C31.5668 11.8534 29.2558 14.1739 26.414 14.1739Z" fill="#8B8D92"/>
<path d="M12.2443 9.7285L8.80246 3.00926C7.85551 1.23765 5.91219 0.375165 3.96788 0.864468C2.02257 1.35377 0.71582 3.03408 0.71582 5.04389V16.7981C0.71582 16.946 0.835425 17.0661 0.982706 17.0661H4.22784C4.37512 17.0661 4.49473 16.946 4.49473 16.7981V5.09153C4.49473 4.99129 4.49473 4.63796 4.88814 4.53871C5.28056 4.43946 5.46145 4.76996 5.50889 4.85829L11.9082 16.7852C12.0011 16.9589 12.181 17.067 12.3777 17.067H15.7366C15.8838 17.067 16.0034 16.947 16.0034 16.7991V1.2039C16.0034 1.05602 15.8838 0.935929 15.7366 0.935929H12.5122C12.3649 0.935929 12.2453 1.05602 12.2453 1.2039V9.7285H12.2443Z" fill="#8B8D92"/>
<path d="M42.6624 13.2152C41.5553 13.2152 40.6578 12.314 40.6578 11.2024V1.20192C40.6578 1.05404 40.5382 0.933944 40.3909 0.933944H37.0775C36.9303 0.933944 36.8107 1.05404 36.8107 1.20192V11.4148C36.8107 14.5362 39.3303 17.0661 42.439 17.0661H48.0179C48.1652 17.0661 48.2848 16.946 48.2848 16.7981V13.4821C48.2848 13.3343 48.1652 13.2142 48.0179 13.2142H42.6614L42.6624 13.2152Z" fill="#8B8D92"/>
</g>
<defs>
<clipPath id="clip0_1_9080">
<rect width="49" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

34
backend/models/longcut.py Normal file
View File

@ -0,0 +1,34 @@
from dataclasses import dataclass
from typing import Literal
from PIL import Image
from pydantic import BaseModel
SceneKind = Literal["clip", "stills", "scroll", "poster"]
class Scene(BaseModel):
"""나레이션 큐 하나에 대응하는 화면."""
cue: int
kind: SceneKind
section: str | None = None # scroll일 때 쓸 섹션 이름
tag: str | None = None # 그 섹션의 태그. 왜 골랐는지 남긴다
class LongcutPlan(BaseModel):
scenes: list[Scene]
class EndBandInfo(BaseModel):
"""엔드밴드 두 줄. 위가 공연명, 아래가 일시·장소."""
title: str
detail: str
@dataclass
class LongcutResult:
video: bytes
duration: float
frames: int
plan: LongcutPlan
proof: Image.Image | None = None

View File

@ -0,0 +1,11 @@
포스터에 격자를 씌웠다.
빨간 가로선 = 세로 위치(위에서부터 %), 하늘색 세로선 = 가로 위치(왼쪽부터 %).
**격자 눈금을 읽어서** 사람(배우·인물·캐릭터)이 그려지거나 찍힌 영역을 하나의 사각으로 답하라.
여러 명이면 전부 감싸는 하나의 사각으로 잡는다.
- 글자·로고·QR은 사람이 아니다.
- 사람이 없으면 null.
- text는 null로 둔다.
추측하지 말고 격자를 읽어라.

View File

@ -0,0 +1,112 @@
"""⑩ hybrid 설정을 포스터에서 뽑는다.
원본은 값을 사람이 손으로 재서 configs/nol_<slug>.json에 적었고, 신규 공연은 거기서
멈췄다. 좌표는 detect가 이미 찾은 영역을 쓰고, 인물 사각만 따로 묻는다.
타이밍(punches·sweep·glows) 이미지에서 있는 값이 아니라 기본 템플릿이다.
"""
from pathlib import Path
import numpy as np
from PIL import Image
from answers.person_box_answer import PersonBoxAnswer
from models.detect import Box, Regions
from models.hybrid import LayerConfig, MaskSpec, Rect
from services.detect import detect
from settings import settings
from utils.common_llm import StructuredLLM
from utils.image import to_data_uri, vlm_grid_overlay
from utils.prompt import load_prompt
# 고정층은 글리프보다 넓게 잡는다. 글자만 덮으면 베벨·그림자가 밀려 겹쳐 보인다.
FIXED_RECT_PAD = 0.012
DARK_POSTER_LUMINANCE = 60 # 이보다 어두우면 사각 고정층이 검은 상자가 된다
SWEEP_AT = 2.4
FIRST_PUNCH_AT = 3.1
PUNCH_GAP = 0.7 # 겹치면 이웃 복원이 안 된다
DATE_PUNCH_AT = 5.5
PUNCH_PEAK_MAX = 1.5
PUNCH_PEAK_MIN = 1.05
PUNCH_KINDS = ("title", "datetime")
PROTECT_KINDS = ("place", "logo")
PERSON_BOX_PROMPT = load_prompt("person_box")
person_llm = StructuredLLM("gpt-4o", settings.chatgpt_api_key)
def padded_rect(box: Box, pad: float = FIXED_RECT_PAD) -> Rect:
return (max(0.0, box.x0 - pad), max(0.0, box.y0 - pad),
min(1.0, box.x1 + pad), min(1.0, box.y1 + pad))
def punch_peak(rect: Rect) -> float:
"""넓은 블록일수록 덜 키운다 — 키운 크롭이 프레임을 넘으면 잘린다."""
width = max(rect[2] - rect[0], 1e-3)
return round(min(PUNCH_PEAK_MAX, max(PUNCH_PEAK_MIN, 1 / width)), 2)
def is_dark_poster(poster: Image.Image) -> bool:
return float(np.asarray(poster.convert("L")).mean()) < DARK_POSTER_LUMINANCE
async def ask_person_box(poster: Image.Image) -> Rect | None:
answer = await person_llm.ask_with_images(
PersonBoxAnswer, PERSON_BOX_PROMPT,
[("격자를 씌운 포스터:", to_data_uri(vlm_grid_overlay(poster)))])
if answer.person is None:
return None
box = answer.person
return (box.x0 / 100, box.y0 / 100, box.x1 / 100, box.y1 / 100)
def emphasis(regions: Regions, dark: bool) -> tuple[list, list]:
"""밝은 포스터는 펀치로, 검정 포스터는 glow로 강조한다.
검정 포스터에서 사각을 키워 덮으면 사각이 검은 상자로 보인다.
"""
punches, glows = [], []
at = FIRST_PUNCH_AT
for kind in PUNCH_KINDS:
box = regions.regions.get(kind)
if box is None:
continue
rect = padded_rect(box)
when = DATE_PUNCH_AT if kind == "datetime" else at
if dark:
glows.append((rect, when))
else:
punches.append((rect, when, punch_peak(rect)))
at += PUNCH_GAP
return punches, glows
def build_from_regions(regions: Regions, person: Rect | None, dark: bool) -> LayerConfig:
fixed_rects = [padded_rect(box) for box in regions.regions.values()]
title = regions.regions.get("title")
punches, glows = emphasis(regions, dark)
return LayerConfig(
mask=MaskSpec(mode="glyph" if dark else "rect"),
fixed_rects=fixed_rects,
sil_box=person or (0.0, 0.0, 0.0, 0.0),
sil_poly=[], # 다각형은 손으로 그려야 해서 비운다
glyphs={"title": padded_rect(title)} if title else {},
sweep_at={"title": SWEEP_AT} if title else {},
punches=punches,
glows=glows,
protect=[padded_rect(regions.regions[kind]) for kind in PROTECT_KINDS
if kind in regions.regions],
glyph_dark_ink=not dark,
)
async def build_layer_config(poster: Path | Image.Image,
regions: Regions | None = None) -> LayerConfig:
source = (Image.open(poster) if isinstance(poster, Path) else poster).convert("RGB")
if regions is None:
regions = (await detect(source)).data
return build_from_regions(regions, await ask_person_box(source), is_dark_poster(source))

View File

@ -0,0 +1,268 @@
"""⑪ compose — 무빙포스터 훅 + 상세페이지 스크롤로 30초 롱컷을 조립한다.
경계는 나레이션 큐다. 0초는 포스터 원본이라 프레임이 썸네일이 된다.
상세페이지는 잘라 붙이지 않고 롱이미지를 실제로 스크롤한다.
포스터·롱이미지는 세로 롱이라 9:16 채워서 포스터 블러판 위에 얹는다.
텍스트 오버레이는 넣지 않는다. 남는 글자는 엔드밴드뿐이다.
"""
import io
import math
from collections.abc import Iterator, Sequence
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
from models.detail_section import DetailSection
from models.longcut import EndBandInfo, LongcutPlan, LongcutResult, Scene
from models.tts import NarrationTimeline
from utils.audio import SAMPLE_RATE, apply_limiter, decode_mp3
from utils.font import font
from utils.video import decode_frames, encode_mp4
W, H, FPS = 1080, 1920, 24
CROSSFADE_FRAMES = 10
NARRATION_VOL, BGM_VOL = 1.45, 0.55
DUCK_DEPTH = 0.25 # 나레이션 구간에서 BGM을 이 배까지 누른다 (약 -12dB)
DUCK_THRESHOLD = 0.03
DUCK_ATTACK_MS, DUCK_RELEASE_MS = 60, 500
BGM_FADE_OUT = 1.2
# 큐 1초당 화면 높이의 몇 배를 훑을지. 원본은 사람이 구간을 골랐다(known_issue 참조).
SCROLL_SCREENS_PER_SECOND = 0.35
BAND_HEIGHT = 300
BAND_RISE_SECONDS = 0.6
NOL_BLUE = (0x41, 0x54, 0xFF)
ASSET_DIR = Path(__file__).resolve().parent.parent / "assets" / "nol"
NOL_WORDMARK = ASSET_DIR / "nol_wordmark_white.png"
# 큐 순서대로 채울 씬. 앞 둘은 클립, 마지막은 포스터, 나머지는 이 태그 우선순위로 고른다.
CLIP_CUES = 2
SCROLL_TAG_PRIORITY = ("synopsis", "still", "cast", "schedule", "discount", "event", "notice")
REQUIRED_TAGS = ("schedule",) # 캐스팅 스케줄은 예매 전에 확인하는 정보라 빠지면 안 된다
def ease_sine(t: float) -> float:
return 0.5 - 0.5 * math.cos(math.pi * min(max(t, 0.0), 1.0))
def blurred_bg(poster: Image.Image) -> Image.Image:
small = poster.copy()
small.thumbnail((270, 480))
blurred = small.resize((W, H), Image.LANCZOS).filter(ImageFilter.GaussianBlur(28))
return Image.eval(blurred, lambda v: int(v * 0.45))
def trim_blank(image: Image.Image, threshold: int = 235) -> Image.Image:
"""상하단 흰·균일 여백 제거. split이 남긴 12px 여백이 화면에 흰 줄로 보인다."""
pixels = np.asarray(image.convert("RGB")).astype(int)
white = (pixels.min(axis=2) > threshold).mean(axis=1) > 0.98
uniform = pixels.std(axis=1).max(axis=1) < 4
content = np.where(~(white | uniform))[0]
if len(content) == 0:
return image
return image.crop((0, int(content[0]), image.width, int(content[-1]) + 1))
def fit_width(image: Image.Image) -> Image.Image:
image = trim_blank(image)
return image.resize((W, max(1, round(image.height * W / image.width))), Image.LANCZOS)
def still_scene(still: Image.Image, progress: float, index: int) -> Image.Image:
"""켄번즈. 가로형은 높이를 채워 좌우로 팬하고, 세로형은 폭을 채워 위아래로 민다."""
width, height = still.size
if width / height >= W / H:
scale = H / height * (1.0 + 0.04 * ease_sine(progress))
scaled_w, scaled_h = int(width * scale), int(height * scale)
span = max(0, scaled_w - W)
shift = ease_sine(progress) if index % 2 == 0 else 1 - ease_sine(progress)
x, y = -int(span * shift), -(scaled_h - H) // 2
else:
scale = W / width * (1.0 + 0.04 * ease_sine(progress))
scaled_w, scaled_h = int(width * scale), int(height * scale)
x, y = -(scaled_w - W) // 2, -int(max(0, scaled_h - H) * ease_sine(progress))
frame = Image.new("RGB", (W, H), (0, 0, 0))
frame.paste(still.resize((scaled_w, scaled_h), Image.LANCZOS), (x, y))
return frame
def scroll_scene(image: Image.Image, background: Image.Image, progress: float,
duration: float) -> Image.Image:
"""읽는 속도로 훑는다. 이미지가 길면 위쪽만 보여준다."""
span = max(0, image.height - H)
reach = min(span, round(duration * SCROLL_SCREENS_PER_SECOND * H))
top = int(reach * ease_sine(progress))
frame = background.copy()
frame.paste(image.crop((0, top, W, min(image.height, top + H))), (0, 0))
return frame
def poster_scene(poster: Image.Image, background: Image.Image, progress: float) -> Image.Image:
"""정착 — 전체를 보여주며 아주 천천히 밀어 넣는다."""
width, height = poster.size
scale = W / width * 0.93 * (1.0 + 0.02 * progress)
scaled = poster.resize((int(width * scale), int(height * scale)), Image.LANCZOS)
frame = background.copy()
frame.paste(scaled, ((W - scaled.width) // 2, (H - scaled.height) // 2))
return frame
def end_band(frame: Image.Image, info: EndBandInfo, seconds: float,
qr: Image.Image | None) -> None:
"""하단 NOL 밴드가 0.6초에 걸쳐 올라오고, 그 뒤 CTA와 정보 두 줄이 들어온다."""
top = H - int(BAND_HEIGHT * ease_sine(seconds / BAND_RISE_SECONDS))
ImageDraw.Draw(frame).rectangle((0, top, W, H), fill=NOL_BLUE)
if seconds < BAND_RISE_SECONDS:
return
next_x = 60
if NOL_WORDMARK.exists():
mark = Image.open(NOL_WORDMARK).convert("RGBA")
mark_h = 78
mark = mark.resize((int(mark.width * mark_h / mark.height), mark_h), Image.LANCZOS)
frame.paste(mark, (next_x, top + 44), mark)
next_x += mark.width + 26
draw = ImageDraw.Draw(frame)
draw.text((next_x, top + 44), "티켓 예매", font=font("Bold", 58), fill=(255, 255, 255))
if qr is not None:
size = BAND_HEIGHT - 80
frame.paste(qr.convert("RGB").resize((size, size), Image.NEAREST),
(W - 60 - size, top + 40))
for order, (text, weight, size, offset, fill) in enumerate((
(info.title, "SemiBold", 40, 150, (255, 255, 255)),
(info.detail, "Medium", 36, 208, (225, 230, 255)))):
appear = seconds - 0.7 - 0.16 * order
if appear < 0:
continue
slide = int((1 - ease_sine(appear / 0.45)) * 30)
draw.text((60, top + offset + slide), text, font=font(weight, size), fill=fill)
def build_scene_plan(sections: Sequence[DetailSection], cue_count: int) -> LongcutPlan:
"""큐 앞 둘은 클립, 마지막은 포스터, 사이는 태그 우선순위로 채운다."""
by_tag: dict[str, list[DetailSection]] = {}
for section in sections:
by_tag.setdefault(section.tag, []).append(section)
ordered: list[DetailSection] = []
for tag in REQUIRED_TAGS: # 빠지면 안 되는 것부터 자리를 잡는다
ordered += by_tag.get(tag, [])[:1]
for tag in SCROLL_TAG_PRIORITY:
for section in by_tag.get(tag, []):
if section not in ordered:
ordered.append(section)
scenes = []
scroll_index = 0
for cue in range(cue_count):
if cue < CLIP_CUES:
scenes.append(Scene(cue=cue, kind="clip"))
elif cue == cue_count - 1:
scenes.append(Scene(cue=cue, kind="poster"))
elif scroll_index < len(ordered):
section = ordered[scroll_index]
scenes.append(Scene(cue=cue, kind="scroll", section=section.name, tag=section.tag))
scroll_index += 1
else:
scenes.append(Scene(cue=cue, kind="poster"))
return LongcutPlan(scenes=scenes)
def duck(bgm: np.ndarray, narration: np.ndarray) -> np.ndarray:
"""나레이션이 나오는 동안 BGM을 누른다. 원본의 sidechaincompress를 대신한다."""
level = np.abs(narration).astype(np.float32) / 32768.0
attack = 1.0 / max(1, DUCK_ATTACK_MS * SAMPLE_RATE // 1000)
release = 1.0 / max(1, DUCK_RELEASE_MS * SAMPLE_RATE // 1000)
envelope = np.zeros_like(level)
current = 0.0
for index, value in enumerate(level):
rate = attack if value > current else release
current += (value - current) * rate
envelope[index] = current
over = np.clip((envelope - DUCK_THRESHOLD) / DUCK_THRESHOLD, 0.0, 1.0)
return bgm * (1.0 - (1.0 - DUCK_DEPTH) * over)
def mix_audio(narration_mp3: bytes, bgm_mp3: bytes | None, duration: float) -> np.ndarray:
total = round(duration * SAMPLE_RATE)
narration = np.zeros(total, dtype=np.float32)
voice = decode_mp3(narration_mp3)[:total]
narration[:len(voice)] = voice.astype(np.float32) * NARRATION_VOL
track = narration.copy()
if bgm_mp3:
music = decode_mp3(bgm_mp3)
if len(music) < total:
music = np.tile(music, total // len(music) + 1)
music = music[:total].astype(np.float32) * BGM_VOL
fade = min(total, round(BGM_FADE_OUT * SAMPLE_RATE))
music[total - fade:] *= np.linspace(1.0, 0.0, fade, dtype=np.float32)
track = track + duck(music, narration)
return apply_limiter(np.clip(track, -32768, 32767).astype(np.int16))
def compose_frames(timeline: NarrationTimeline, plan: LongcutPlan, poster: Image.Image,
clip: bytes, sections: dict[str, Image.Image],
info: EndBandInfo, qr: Image.Image | None) -> Iterator[Image.Image]:
background = blurred_bg(poster)
starts = [0.0] + [cue.start for cue in timeline.cues[1:]]
bounds = [cue.start for cue in timeline.cues[1:]] + [timeline.total]
clip_frames = list(decode_frames(clip))
scroll_images = {name: fit_width(image) for name, image in sections.items()}
scenes = {scene.cue: scene for scene in plan.scenes}
def frame_at(cue: int, at: float) -> Image.Image:
scene = scenes[cue]
if scene.kind == "clip":
source = clip_frames[min(int(at * FPS), len(clip_frames) - 1)]
fitted = source.resize((W, round(source.height * W / source.width)), Image.LANCZOS)
frame = background.copy()
frame.paste(fitted, (0, (H - fitted.height) // 2))
return frame
start, end = starts[cue], bounds[cue]
progress = (at - start) / max(end - start, 0.01)
if scene.kind == "scroll" and scene.section in scroll_images:
return scroll_scene(scroll_images[scene.section], background, progress, end - start)
return poster_scene(poster, background, progress)
total_frames = round(timeline.total * FPS)
for index in range(total_frames):
at = index / FPS
cue = max(order for order in range(len(timeline.cues)) if at >= starts[order])
image = frame_at(cue, at)
remaining = (bounds[cue] - at) * FPS
if cue < len(timeline.cues) - 1 and remaining < CROSSFADE_FRAMES \
and scenes[cue + 1].kind != "clip":
image = Image.blend(image, frame_at(cue + 1, at), 1 - remaining / CROSSFADE_FRAMES)
if scenes[cue].kind == "poster":
end_band(image, info, at - starts[cue], qr)
yield image
def compose_longcut(poster: bytes | Path | Image.Image, clip: bytes,
timeline: NarrationTimeline, narration_mp3: bytes,
sections: Sequence[DetailSection], info: EndBandInfo, *,
bgm_mp3: bytes | None = None, qr: Image.Image | None = None,
plan: LongcutPlan | None = None) -> LongcutResult:
if isinstance(poster, Image.Image):
source = poster.convert("RGB")
else:
source = Image.open(poster if isinstance(poster, Path)
else io.BytesIO(poster)).convert("RGB")
plan = plan or build_scene_plan(sections, len(timeline.cues))
used = {scene.section for scene in plan.scenes if scene.section}
images = {section.name: section.image for section in sections if section.name in used}
frames = compose_frames(timeline, plan, source, clip, images, info, qr)
video = encode_mp4(frames, (W, H), mix_audio(narration_mp3, bgm_mp3, timeline.total),
fps=FPS)
return LongcutResult(video=video, duration=timeline.total,
frames=round(timeline.total * FPS), plan=plan)

View File

@ -81,7 +81,8 @@ async def synthesize(sentences: list[str], voice: str = "nova", *,
raise ValueError("나레이션 문장이 없다")
if typecast:
spoken = await asyncio.gather(*(speak_typecast(text, typecast) for text in sentences))
# Typecast는 동시 요청에 429를 준다. 원본도 순차로 돈다.
spoken = [await speak_typecast(text, typecast) for text in sentences]
else:
spoken = await asyncio.gather(
*(speak_openai(text, voice, speed, instructions) for text in sentences))

View File

@ -0,0 +1,64 @@
"""⑩ config 생성 수동 확인용.
사용: uv run python -m tests.playreel.test_build_layer_config <goods_id>
upscaled.png 에서 detect + 인물 사각을 뽑아 layer_config.json 만든다.
결과는 nol_<goods_id>/layer_config.json layer_config_check.jpg.
"""
import asyncio
import sys
from pathlib import Path
from PIL import Image, ImageDraw
from services.build_layer_config import build_layer_config
TEST_RESULT_DIR = Path(__file__).parent.parent / "test_result" / "playreel"
COLORS = {"fixed": (255, 60, 60), "sil": (255, 255, 0), "punch": (60, 220, 255)}
def draw_check(poster: Image.Image, config) -> Image.Image:
overlay = poster.convert("RGB").copy()
width, height = overlay.size
draw = ImageDraw.Draw(overlay)
line = max(3, width // 300)
def box(rect, color, label):
draw.rectangle([rect[0] * width, rect[1] * height, rect[2] * width, rect[3] * height],
outline=color, width=line)
draw.text((rect[0] * width + 6, rect[1] * height + 4), label, fill=color)
for rect in config.fixed_rects:
box(rect, COLORS["fixed"], "fixed")
if config.sil_box[2] > config.sil_box[0]:
box(config.sil_box, COLORS["sil"], "person")
for rect, at, peak in config.punches:
box(rect, COLORS["punch"], f"punch {at}s x{peak}")
return overlay
async def main() -> None:
goods_id = sys.argv[1]
product_dir = TEST_RESULT_DIR / f"nol_{goods_id}"
poster_path = product_dir / "upscaled.png"
if not poster_path.exists():
raise SystemExit(f"{poster_path} 없음 — test_upscale_poster 를 먼저 돌릴 것")
config = await build_layer_config(poster_path)
config_path = product_dir / "layer_config.json"
check_path = product_dir / "layer_config_check.jpg"
config_path.write_text(config.model_dump_json(indent=2), encoding="utf-8")
with Image.open(poster_path) as poster:
draw_check(poster, config).save(check_path, quality=90)
print(f"mask={config.mask.mode} · 고정사각 {len(config.fixed_rects)}"
f" · 글리프 {len(config.glyphs)} · 펀치 {len(config.punches)}")
for rect, at, peak in config.punches:
print(f" punch {at}s x{peak} {tuple(round(v, 3) for v in rect)}")
if config.sil_box[2] <= config.sil_box[0]:
print("※ 인물 사각을 못 잡았다 — 인물이 없거나 VLM이 놓쳤다")
print(f"\n{config_path}\n{check_path}")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,86 @@
"""⑪ compose 수동 확인용 — 30초 롱컷 조립.
사용: uv run python -m tests.playreel.test_compose_long <goods_id> [variant]
hybrid.mp4 있으면 그것을, 없으면 clip.mp4 훅으로 쓴다.
결과는 nol_<goods_id>/longcut_<variant>.mp4 longcut_<variant>.json.
"""
import json
import sys
from pathlib import Path
from PIL import Image
from models.detail_section import DetailSection
from models.longcut import EndBandInfo
from models.nol import NolMeta
from models.tts import NarrationTimeline
from services.compose_long import compose_longcut
TEST_RESULT_DIR = Path(__file__).parent.parent / "test_result" / "playreel"
QR_DIR = Path(__file__).parent.parent.parent / "assets" / "nol"
def load_sections(sections_dir: Path) -> list[DetailSection]:
index_path = sections_dir / "index.json"
if not index_path.exists():
raise SystemExit(f"{index_path} 없음 — test_split_detail 을 먼저 돌릴 것")
return [DetailSection(source=item["source"], index=item["index"],
y0=item["y0"], y1=item["y1"],
image=Image.open(sections_dir / item["file"]),
tag=item["tag"])
for item in json.loads(index_path.read_text(encoding="utf-8"))]
def band_info(meta: NolMeta) -> EndBandInfo:
period = f"{meta.play_start_date or ''} ~ {meta.play_end_date or ''}".strip(" ~")
detail = " · ".join(part for part in (period.replace("-", "."), meta.place_name) if part)
return EndBandInfo(title=meta.goods_name or "", detail=detail)
def main() -> None:
goods_id = sys.argv[1]
variant = sys.argv[2] if len(sys.argv) > 2 else "v1"
product_dir = TEST_RESULT_DIR / f"nol_{goods_id}"
hybrid = product_dir / "hybrid.mp4"
clip_path = hybrid if hybrid.exists() else product_dir / "clip.mp4"
required = [product_dir / "upscaled.png", clip_path,
product_dir / "timeline.json", product_dir / "narration.mp3"]
for path in required:
if not path.exists():
raise SystemExit(f"{path} 없음 — 앞 단계를 먼저 돌릴 것")
if not hybrid.exists():
print("※ hybrid.mp4 가 없어 원본 클립을 쓴다 — 제목이 흔들릴 수 있다")
output_path = product_dir / f"longcut_{variant}.mp4"
if output_path.exists():
raise SystemExit(f"덮어쓰기 금지: {output_path} — variant 를 바꿀 것")
meta = NolMeta.model_validate_json((product_dir / "meta.json").read_text(encoding="utf-8"))
timeline = NarrationTimeline.model_validate_json(
(product_dir / "timeline.json").read_text(encoding="utf-8"))
bgm_path = product_dir / "bgm.mp3"
qr_path = QR_DIR / f"qr_{goods_id}.png"
result = compose_longcut(
product_dir / "upscaled.png", clip_path.read_bytes(), timeline,
(product_dir / "narration.mp3").read_bytes(),
load_sections(product_dir / "sections"), band_info(meta),
bgm_mp3=bgm_path.read_bytes() if bgm_path.exists() else None,
qr=Image.open(qr_path) if qr_path.exists() else None)
output_path.write_bytes(result.video)
(product_dir / f"longcut_{variant}.json").write_text(
result.plan.model_dump_json(indent=2), encoding="utf-8")
for scene in result.plan.scenes:
label = f"{scene.section} [{scene.tag}]" if scene.section else ""
print(f"{scene.cue} {scene.kind:7} {label}")
if not qr_path.exists():
print(f"{qr_path.name} 없음 — 엔드밴드에 QR을 안 넣는다")
print(f"\n{result.frames}프레임 · {result.duration:.2f}초 · {len(result.video) / 1e6:.1f}MB")
print(output_path)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Playreel 롱컷 — 게이트 ④ 승인 뒤, 게이트 ⑤(final_confirm) 직전까지.
# 사용: tests/run_playreel_after_gate4.sh <goods_id> [variant]
set -euo pipefail
cd "$(dirname "$0")/.."
goods_id="${1:?사용: $0 <goods_id> [variant]}"
variant="${2:-v1}"
config="tests/test_result/playreel/nol_${goods_id}/layer_config.json"
run() { echo; echo "── $1 ──"; shift; uv run python -m "$@"; }
if [ ! -f "$config" ]; then
run "⑩ config 생성" tests.playreel.test_build_layer_config "$goods_id"
fi
run "⑩ hybrid" tests.playreel.test_hybrid_poster "$goods_id" "$config"
run "⑪ compose" tests.playreel.test_compose_long "$goods_id" "$variant"
cat <<EOF
══ 게이트 ⑤ final_confirm — 마지막 확인 ══
longcut_${variant}.mp4 를 보고 여섯 가지를 본다
첫 프레임이 포스터 원본인가
제목이 온전한가
상세페이지가 풀프레임으로 스크롤되는가
캐스팅 스케줄이 들어갔는가
엔드밴드에 로고·CTA·QR이 있는가
나레이션이 BGM에 묻히지 않는가
다시 만들 때는 variant 를 바꾼다 — 덮어쓰기는 막혀 있다.
EOF

25
backend/utils/font.py Normal file
View File

@ -0,0 +1,25 @@
"""동봉 폰트 로더. 없으면 그 자리에서 죽는다 — 기본 폰트로 떨어지면 타이포가 어긋난다."""
from functools import lru_cache
from pathlib import Path
from PIL import ImageFont
FONT_DIR = Path(__file__).resolve().parent.parent / "assets" / "fonts"
PRETENDARD = "Pretendard-{weight}.otf"
GYEONGGI = "GyeonggiTitleVOTF-Bold.otf"
@lru_cache(maxsize=32)
def font(weight: str, size: int) -> ImageFont.FreeTypeFont:
path = FONT_DIR / PRETENDARD.format(weight=weight)
if not path.exists():
raise RuntimeError(f"폰트 없음: {path}")
return ImageFont.truetype(str(path), size)
@lru_cache(maxsize=8)
def title_font(size: int) -> ImageFont.FreeTypeFont:
path = FONT_DIR / GYEONGGI
if not path.exists():
raise RuntimeError(f"폰트 없음: {path}")
return ImageFont.truetype(str(path), size)

View File

@ -172,3 +172,19 @@ CLI를 계속 쓰는 한 없앨 수 없다. Higgsfield HTTP API로 가면 presig
CLI `generate create` 응답에 크레딧 필드가 없어서(⑥ i2v에서 확인) 원본이 쓰던 값 2를
그대로 상수로 뒀다. 요금이 바뀌면 조용히 틀린 값이 기록된다.
## [Playreel] ⑪ compose
### 스크롤 범위를 사람이 정하는 경로가 없다
원본은 `compose_long.py``SCENES[slug]["plan"]`에 손으로 적었다. 세 작품만 있고
값은 전부 위쪽 일부만 훑는다 — 0→0.30, 0→0.14, 0→0.40, 0→0.10.
프론트에도 입력이 없다. 게이트 ①의 `FetchGate`가 고르는 것은 어떤 섹션을 쓸지(`sections`)뿐이고
스크롤 범위는 화면에 없다. `final_confirm`의 "상세페이지 풀프레임 스크롤"은 완성본 확인용
체크 항목이다.
**우리는 읽는 속도로 자동 계산한다.** 큐 길이 동안 화면 높이의 정해진 배수만큼 스크롤하고,
이미지가 길면 위쪽만 보여준다. 사람이 "여기부터 저기까지"를 고르는 경로는 없다.
배수 값이 검증된 게 아니다. 원본이 손으로 고른 구간과 결과가 다를 수 있다.