"""⑪ 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, ImageOps 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초당 화면 높이의 몇 배를 훑을지. 5초 씬에 이미지 높이의 15~30%가 읽기 좋은 속도다 SCROLL_SCREENS_PER_SECOND = 0.35 # 프레임보다 짧은 롱이미지를 늘릴 때 쓰는 값. 글자 없는 배경 띠를 거울 타일링한다 INK_DEVIATION = 32 # 배경색에서 이만큼 벗어난 픽셀을 잉크로 본다 INK_ROW_RATIO = 0.003 # 행의 잉크 비율이 이 미만이면 글자 없는 행 EXTEND_FEATHER = 40 # 이음매를 이만큼 겹쳐 섞는다 MIN_BLANK_BAND = 24 # 늘리기에 쓸 배경 띠의 최소 높이 PROOF_COUNT = 8 # 완성본 전체에서 고르게 뽑는 시점 수 PROOF_SIZE = (180, 320) PROOF_GAP = 10 THUMBNAIL_QUALITY = 90 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", "info") 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 ink_rows(image: Image.Image) -> np.ndarray: """행마다 글자·그림이 있는지. 배경 중앙값에서 벗어난 픽셀의 비율로 본다.""" pixels = np.asarray(image.convert("L")).astype(np.int16) background = np.median(pixels) return (np.abs(pixels - background) > INK_DEVIATION).mean(axis=1) > INK_ROW_RATIO def blank_band(image: Image.Image) -> tuple[int, int]: """늘리기에 쓸, 글자 없는 가장 긴 행 구간. 없으면 맨 아래 띠를 쓴다.""" blank = ~ink_rows(image) best = (0, 0) start = None for y, empty in enumerate(np.append(blank, False)): if empty and start is None: start = y elif not empty and start is not None: if y - start > best[1] - best[0]: best = (start, y) start = None if best[1] - best[0] >= MIN_BLANK_BAND: return best return max(0, image.height - MIN_BLANK_BAND), image.height def extend_to_height(image: Image.Image, height: int = H) -> Image.Image: """프레임보다 짧은 롱이미지를 배경 띠의 거울 타일링으로 늘린다. 짧다고 블러 배경 위에 얹으면 화면 절반이 포스터로 남는다. 배경만 이어 붙여 풀프레임을 채우고, 이음매는 페더로 지운다. """ if image.height >= height: return image top, bottom = blank_band(image) band = image.crop((0, top, image.width, bottom)) canvas = Image.new("RGB", (image.width, height)) canvas.paste(image, (0, 0)) y, flipped = image.height, True while y < height: tile = ImageOps.flip(band) if flipped else band tile = tile.crop((0, 0, tile.width, min(tile.height, height - y))) canvas.paste(tile, (0, y)) _feather_seam(canvas, y, image.width) y += tile.height flipped = not flipped return canvas def _feather_seam(canvas: Image.Image, seam: int, width: int, feather: int = EXTEND_FEATHER) -> None: """이음매 위아래를 선형 혼합해 가로줄이 보이지 않게 한다.""" top, bottom = max(0, seam - feather), min(canvas.height, seam + feather) if bottom - top < 2: return patch = np.asarray(canvas.crop((0, top, width, bottom))).astype(np.float32) blurred = np.asarray( canvas.crop((0, top, width, bottom)).filter(ImageFilter.GaussianBlur(6)) ).astype(np.float32) weight = 1.0 - np.abs(np.linspace(-1.0, 1.0, bottom - top)) mixed = patch + (blurred - patch) * weight[:, None, None] canvas.paste(Image.fromarray(mixed.clip(0, 255).astype(np.uint8)), (0, top)) def to_surface(image: Image.Image) -> Image.Image: """롱이미지를 폭 1080에 맞추고 프레임 높이까지 늘린다. 자르지 않는다 — 섹션 y좌표가 이 이미지 기준이라 위를 깎으면 시작점이 어긋난다. """ scaled = image.convert("RGB").resize( (W, max(1, round(image.height * W / image.width))), Image.LANCZOS) return extend_to_height(scaled, H) 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_top(surface: Image.Image, fraction: float) -> int: """이미지 높이 대비 비율을 실제 crop 시작 y로. 끝을 넘지 않게 묶는다.""" span = max(0, surface.height - H) return min(max(0, round(surface.height * fraction)), span) def scroll_scene(surface: Image.Image, progress: float, y0: float, y1: float) -> Image.Image: """롱이미지를 y0~y1 구간까지 훑는다. 프레임을 가득 채운다 — 배경이 비지 않는다.""" start, end = scroll_top(surface, y0), scroll_top(surface, y1) top = start + round((end - start) * ease_sine(progress)) return surface.crop((0, top, W, top + H)) 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 cue_spans(timeline: NarrationTimeline) -> list[tuple[float, float]]: """큐마다 (시작, 끝). 0번은 0초에서 시작해야 첫 프레임이 포스터가 된다.""" starts = [0.0] + [cue.start for cue in timeline.cues[1:]] ends = [cue.start for cue in timeline.cues[1:]] + [timeline.total] return list(zip(starts, ends, strict=True)) def scroll_starts(sections: Sequence[DetailSection], surfaces: dict[str, Image.Image]) -> list[DetailSection]: """훑기 시작점이 될 섹션을 태그 우선순위대로. 롱이미지가 없는 섹션은 뺀다.""" usable = [section for section in sections if section.source in surfaces] by_tag: dict[str, list[DetailSection]] = {} for section in usable: 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) return ordered def scroll_window(section: DetailSection, source: Image.Image, surface: Image.Image, seconds: float) -> tuple[float, float]: """섹션이 시작점, 큐 길이가 훑는 거리. 둘 다 롱이미지 높이 대비 비율로 돌려준다.""" scale = W / source.width start = round(section.y0 * scale) reach = round(seconds * SCROLL_SCREENS_PER_SECOND * H) return start / surface.height, (start + reach) / surface.height def build_scene_plan(sections: Sequence[DetailSection], spans: Sequence[tuple[float, float]], sources: dict[str, Image.Image], surfaces: dict[str, Image.Image]) -> LongcutPlan: """큐 앞 둘은 클립, 마지막은 포스터, 사이는 롱이미지를 훑는다.""" ordered = scroll_starts(sections, surfaces) fallback = max(surfaces, key=lambda name: surfaces[name].height, default=None) scenes: list[Scene] = [] index = 0 cursor = 0.0 # 폴백이 이어 읽을 지점 for cue, (start, end) in enumerate(spans): if cue < CLIP_CUES: scenes.append(Scene(cue=cue, kind="clip")) elif cue == len(spans) - 1: scenes.append(Scene(cue=cue, kind="poster")) elif index < len(ordered): section = ordered[index] y0, y1 = scroll_window(section, sources[section.source], surfaces[section.source], end - start) scenes.append(Scene(cue=cue, kind="scroll", section=section.name, tag=section.tag, source=section.source, y0=y0, y1=y1)) index, cursor = index + 1, y1 elif fallback is not None: # 쓸 섹션이 떨어져도 포스터로 때우지 않는다. 앞 씬이 멈춘 자리에서 이어 읽어야 # 같은 화면이 두 번 나오지 않는다 reach = round((end - start) * SCROLL_SCREENS_PER_SECOND * H) y1 = cursor + reach / surfaces[fallback].height scenes.append(Scene(cue=cue, kind="scroll", source=fallback, y0=cursor, y1=y1)) cursor = y1 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, surfaces: dict[str, Image.Image], info: EndBandInfo, qr: Image.Image | None) -> Iterator[Image.Image]: background = blurred_bg(poster) spans = cue_spans(timeline) clip_frames = list(decode_frames(clip)) 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 = spans[cue] progress = (at - start) / max(end - start, 0.01) if scene.kind == "scroll" and scene.source in surfaces: return scroll_scene(surfaces[scene.source], progress, scene.y0 or 0.0, scene.y1 or 0.0) 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, (start, _) in enumerate(spans) if at >= start) image = frame_at(cue, at) remaining = (spans[cue][1] - at) * FPS if cue < len(spans) - 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 - spans[cue][0], qr) yield image def proof_sheet(tiles: list[Image.Image]) -> Image.Image: width = (PROOF_SIZE[0] + PROOF_GAP) * len(tiles) - PROOF_GAP sheet = Image.new("RGB", (width, PROOF_SIZE[1]), "black") for order, tile in enumerate(tiles): sheet.paste(tile, (order * (PROOF_SIZE[0] + PROOF_GAP), 0)) return sheet def compose_longcut(poster: bytes | Path | Image.Image, clip: bytes, timeline: NarrationTimeline, narration_mp3: bytes, sections: Sequence[DetailSection], info: EndBandInfo, *, details: dict[str, Image.Image] | None = None, bgm_mp3: bytes | None = None, qr: Image.Image | None = None, plan: LongcutPlan | None = None, preview: bool = False) -> LongcutResult: """details는 source 이름(detail_01…) → 상세페이지 롱이미지. 스크롤은 이 위에서 일어난다.""" if isinstance(poster, Image.Image): source = poster.convert("RGB") else: source = Image.open(poster if isinstance(poster, Path) else io.BytesIO(poster)).convert("RGB") sources = dict(details or {}) surfaces = {name: to_surface(image) for name, image in sources.items()} spans = cue_spans(timeline) plan = plan or build_scene_plan(sections, spans, sources, surfaces) total_frames = round(timeline.total * FPS) # 훅 구간만 보면 뒤쪽 스크롤과 엔드밴드를 검수할 수 없어 전체에서 고르게 뽑는다 proof_at = {round(step * total_frames / PROOF_COUNT) for step in range(PROOF_COUNT)} tiles: list[Image.Image] = [] thumbnail = io.BytesIO() def frames() -> Iterator[Image.Image]: for index, frame in enumerate( compose_frames(timeline, plan, source, clip, surfaces, info, qr)): if index == 0: frame.save(thumbnail, "JPEG", quality=THUMBNAIL_QUALITY) if preview and index in proof_at: tiles.append(frame.resize(PROOF_SIZE)) yield frame video = encode_mp4(frames(), (W, H), mix_audio(narration_mp3, bgm_mp3, timeline.total), fps=FPS) return LongcutResult(video=video, thumbnail=thumbnail.getvalue(), duration=timeline.total, frames=total_frames, plan=plan, proof=proof_sheet(tiles) if tiles else None)