"""⑪ 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)