diff --git a/backend/answers/person_box_answer.py b/backend/answers/person_box_answer.py new file mode 100644 index 0000000..acfb9da --- /dev/null +++ b/backend/answers/person_box_answer.py @@ -0,0 +1,8 @@ +"""⑩ hybrid config 생성 — 인물 영역 응답 스키마.""" +from pydantic import BaseModel + +from answers.detect_answer import GridBox + + +class PersonBoxAnswer(BaseModel): + person: GridBox | None diff --git a/backend/assets/fonts/GyeonggiTitleVOTF-Bold.otf b/backend/assets/fonts/GyeonggiTitleVOTF-Bold.otf new file mode 100644 index 0000000..a82daf5 Binary files /dev/null and b/backend/assets/fonts/GyeonggiTitleVOTF-Bold.otf differ diff --git a/backend/assets/fonts/LICENSE-Pretendard.md b/backend/assets/fonts/LICENSE-Pretendard.md new file mode 100644 index 0000000..882cfb5 --- /dev/null +++ b/backend/assets/fonts/LICENSE-Pretendard.md @@ -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 (재배포·임베딩 허용) diff --git a/backend/assets/fonts/Pretendard-Black.otf b/backend/assets/fonts/Pretendard-Black.otf new file mode 100644 index 0000000..a0d849e Binary files /dev/null and b/backend/assets/fonts/Pretendard-Black.otf differ diff --git a/backend/assets/fonts/Pretendard-Bold.otf b/backend/assets/fonts/Pretendard-Bold.otf new file mode 100644 index 0000000..8e5e30a Binary files /dev/null and b/backend/assets/fonts/Pretendard-Bold.otf differ diff --git a/backend/assets/fonts/Pretendard-Medium.otf b/backend/assets/fonts/Pretendard-Medium.otf new file mode 100644 index 0000000..0575069 Binary files /dev/null and b/backend/assets/fonts/Pretendard-Medium.otf differ diff --git a/backend/assets/fonts/Pretendard-Regular.otf b/backend/assets/fonts/Pretendard-Regular.otf new file mode 100644 index 0000000..08bf4cf Binary files /dev/null and b/backend/assets/fonts/Pretendard-Regular.otf differ diff --git a/backend/assets/fonts/Pretendard-SemiBold.otf b/backend/assets/fonts/Pretendard-SemiBold.otf new file mode 100644 index 0000000..e7e36ab Binary files /dev/null and b/backend/assets/fonts/Pretendard-SemiBold.otf differ diff --git a/backend/assets/nol/img_brand_logo.svg b/backend/assets/nol/img_brand_logo.svg new file mode 100644 index 0000000..406bf5e --- /dev/null +++ b/backend/assets/nol/img_brand_logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/backend/assets/nol/nol_og_1200x600.png b/backend/assets/nol/nol_og_1200x600.png new file mode 100644 index 0000000..212833e Binary files /dev/null and b/backend/assets/nol/nol_og_1200x600.png differ diff --git a/backend/assets/nol/nol_wordmark_black.png b/backend/assets/nol/nol_wordmark_black.png new file mode 100644 index 0000000..e19ddd2 Binary files /dev/null and b/backend/assets/nol/nol_wordmark_black.png differ diff --git a/backend/assets/nol/nol_wordmark_white.png b/backend/assets/nol/nol_wordmark_white.png new file mode 100644 index 0000000..df849a8 Binary files /dev/null and b/backend/assets/nol/nol_wordmark_white.png differ diff --git a/backend/assets/nol/qr_26007169.png b/backend/assets/nol/qr_26007169.png new file mode 100644 index 0000000..80134a4 Binary files /dev/null and b/backend/assets/nol/qr_26007169.png differ diff --git a/backend/assets/nol/qr_26007416.png b/backend/assets/nol/qr_26007416.png new file mode 100644 index 0000000..fe815ea Binary files /dev/null and b/backend/assets/nol/qr_26007416.png differ diff --git a/backend/assets/nol/qr_26012145.png b/backend/assets/nol/qr_26012145.png new file mode 100644 index 0000000..1a7cb67 Binary files /dev/null and b/backend/assets/nol/qr_26012145.png differ diff --git a/backend/models/longcut.py b/backend/models/longcut.py new file mode 100644 index 0000000..e035433 --- /dev/null +++ b/backend/models/longcut.py @@ -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 diff --git a/backend/raw_prompt/person_box.txt b/backend/raw_prompt/person_box.txt new file mode 100644 index 0000000..f94176a --- /dev/null +++ b/backend/raw_prompt/person_box.txt @@ -0,0 +1,11 @@ +포스터에 격자를 씌웠다. +빨간 가로선 = 세로 위치(위에서부터 %), 하늘색 세로선 = 가로 위치(왼쪽부터 %). + +**격자 눈금을 읽어서** 사람(배우·인물·캐릭터)이 그려지거나 찍힌 영역을 하나의 사각으로 답하라. +여러 명이면 전부 감싸는 하나의 사각으로 잡는다. + +- 글자·로고·QR은 사람이 아니다. +- 사람이 없으면 null. +- text는 null로 둔다. + +추측하지 말고 격자를 읽어라. diff --git a/backend/services/build_layer_config.py b/backend/services/build_layer_config.py new file mode 100644 index 0000000..f80eaff --- /dev/null +++ b/backend/services/build_layer_config.py @@ -0,0 +1,112 @@ +"""⑩ hybrid 설정을 포스터에서 뽑는다. + +원본은 이 값을 사람이 손으로 재서 configs/nol_.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)) diff --git a/backend/services/compose_long.py b/backend/services/compose_long.py new file mode 100644 index 0000000..3fef056 --- /dev/null +++ b/backend/services/compose_long.py @@ -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) diff --git a/backend/services/tts.py b/backend/services/tts.py index 7decaa9..3c9fee3 100644 --- a/backend/services/tts.py +++ b/backend/services/tts.py @@ -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)) diff --git a/backend/tests/playreel/test_build_layer_config.py b/backend/tests/playreel/test_build_layer_config.py new file mode 100644 index 0000000..57bdcad --- /dev/null +++ b/backend/tests/playreel/test_build_layer_config.py @@ -0,0 +1,64 @@ +"""⑩ config 생성 수동 확인용. + +사용: uv run python -m tests.playreel.test_build_layer_config +upscaled.png 에서 detect + 인물 사각을 뽑아 layer_config.json 을 만든다. +결과는 nol_/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()) diff --git a/backend/tests/playreel/test_compose_long.py b/backend/tests/playreel/test_compose_long.py new file mode 100644 index 0000000..d7ad463 --- /dev/null +++ b/backend/tests/playreel/test_compose_long.py @@ -0,0 +1,86 @@ +"""⑪ compose 수동 확인용 — 30초 롱컷 조립. + +사용: uv run python -m tests.playreel.test_compose_long [variant] +hybrid.mp4 가 있으면 그것을, 없으면 clip.mp4 를 훅으로 쓴다. +결과는 nol_/longcut_.mp4 와 longcut_.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() diff --git a/backend/tests/run_playreel_after_gate4.sh b/backend/tests/run_playreel_after_gate4.sh new file mode 100755 index 0000000..19cde0d --- /dev/null +++ b/backend/tests/run_playreel_after_gate4.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Playreel 롱컷 — 게이트 ④ 승인 뒤, 게이트 ⑤(final_confirm) 직전까지. +# 사용: tests/run_playreel_after_gate4.sh [variant] +set -euo pipefail +cd "$(dirname "$0")/.." + +goods_id="${1:?사용: $0 [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 < 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) diff --git a/known_issue.md b/known_issue.md index cd19e6b..d53f0c5 100644 --- a/known_issue.md +++ b/known_issue.md @@ -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`의 "상세페이지 풀프레임 스크롤"은 완성본 확인용 +체크 항목이다. + +**우리는 읽는 속도로 자동 계산한다.** 큐 길이 동안 화면 높이의 정해진 배수만큼 스크롤하고, +이미지가 길면 위쪽만 보여준다. 사람이 "여기부터 저기까지"를 고르는 경로는 없다. + +배수 값이 검증된 게 아니다. 원본이 손으로 고른 구간과 결과가 다를 수 있다.