(WIP) config 자동 생성 및 gate4 / 동작 검증 실패 (local RAM 부족)
8
backend/answers/person_box_answer.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"""⑩ hybrid config 생성 — 인물 영역 응답 스키마."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from answers.detect_answer import GridBox
|
||||||
|
|
||||||
|
|
||||||
|
class PersonBoxAnswer(BaseModel):
|
||||||
|
person: GridBox | None
|
||||||
BIN
backend/assets/fonts/GyeonggiTitleVOTF-Bold.otf
Normal file
10
backend/assets/fonts/LICENSE-Pretendard.md
Normal 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 (재배포·임베딩 허용)
|
||||||
BIN
backend/assets/fonts/Pretendard-Black.otf
Normal file
BIN
backend/assets/fonts/Pretendard-Bold.otf
Normal file
BIN
backend/assets/fonts/Pretendard-Medium.otf
Normal file
BIN
backend/assets/fonts/Pretendard-Regular.otf
Normal file
BIN
backend/assets/fonts/Pretendard-SemiBold.otf
Normal file
12
backend/assets/nol/img_brand_logo.svg
Normal 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 |
BIN
backend/assets/nol/nol_og_1200x600.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
backend/assets/nol/nol_wordmark_black.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
backend/assets/nol/nol_wordmark_white.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
backend/assets/nol/qr_26007169.png
Normal file
|
After Width: | Height: | Size: 570 B |
BIN
backend/assets/nol/qr_26007416.png
Normal file
|
After Width: | Height: | Size: 568 B |
BIN
backend/assets/nol/qr_26012145.png
Normal file
|
After Width: | Height: | Size: 570 B |
34
backend/models/longcut.py
Normal 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
|
||||||
11
backend/raw_prompt/person_box.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
포스터에 격자를 씌웠다.
|
||||||
|
빨간 가로선 = 세로 위치(위에서부터 %), 하늘색 세로선 = 가로 위치(왼쪽부터 %).
|
||||||
|
|
||||||
|
**격자 눈금을 읽어서** 사람(배우·인물·캐릭터)이 그려지거나 찍힌 영역을 하나의 사각으로 답하라.
|
||||||
|
여러 명이면 전부 감싸는 하나의 사각으로 잡는다.
|
||||||
|
|
||||||
|
- 글자·로고·QR은 사람이 아니다.
|
||||||
|
- 사람이 없으면 null.
|
||||||
|
- text는 null로 둔다.
|
||||||
|
|
||||||
|
추측하지 말고 격자를 읽어라.
|
||||||
112
backend/services/build_layer_config.py
Normal 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))
|
||||||
268
backend/services/compose_long.py
Normal 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)
|
||||||
@ -81,7 +81,8 @@ async def synthesize(sentences: list[str], voice: str = "nova", *,
|
|||||||
raise ValueError("나레이션 문장이 없다")
|
raise ValueError("나레이션 문장이 없다")
|
||||||
|
|
||||||
if typecast:
|
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:
|
else:
|
||||||
spoken = await asyncio.gather(
|
spoken = await asyncio.gather(
|
||||||
*(speak_openai(text, voice, speed, instructions) for text in sentences))
|
*(speak_openai(text, voice, speed, instructions) for text in sentences))
|
||||||
|
|||||||
64
backend/tests/playreel/test_build_layer_config.py
Normal 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())
|
||||||
86
backend/tests/playreel/test_compose_long.py
Normal 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()
|
||||||
31
backend/tests/run_playreel_after_gate4.sh
Executable 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
@ -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)
|
||||||
@ -172,3 +172,19 @@ CLI를 계속 쓰는 한 없앨 수 없다. Higgsfield HTTP API로 가면 presig
|
|||||||
|
|
||||||
CLI `generate create` 응답에 크레딧 필드가 없어서(⑥ i2v에서 확인) 원본이 쓰던 값 2를
|
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`의 "상세페이지 풀프레임 스크롤"은 완성본 확인용
|
||||||
|
체크 항목이다.
|
||||||
|
|
||||||
|
**우리는 읽는 속도로 자동 계산한다.** 큐 길이 동안 화면 높이의 정해진 배수만큼 스크롤하고,
|
||||||
|
이미지가 길면 위쪽만 보여준다. 사람이 "여기부터 저기까지"를 고르는 경로는 없다.
|
||||||
|
|
||||||
|
배수 값이 검증된 게 아니다. 원본이 손으로 고른 구간과 결과가 다를 수 있다.
|
||||||
|
|||||||