playreel/backend/services/compose_long.py

269 lines
12 KiB
Python

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