269 lines
12 KiB
Python
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)
|