playreel/backend/services/render.py

277 lines
12 KiB
Python

"""⑦ render — i2v 클립을 1080x1920 배포본으로 합성한다.
QR은 프롬프트로 못 살린다. 원본 3071px → 출력 1248px 축소만으로도 데이터 모듈이 뭉개져
스캔이 안 되므로, 원본에서 오려 다시 얹는다. 기관 로고바도 같은 이유·같은 방법이고,
포스터 영역 안쪽에 뭉갠 채 남은 것들도 제자리에 덮는다.
루프는 핑퐁이 아니라 크로스페이드다. 생성형 불꽃은 터짐→소멸이라는 방향이 있는 사건이라
역재생이 눈에 보인다.
"""
import io
from collections import deque
from collections.abc import Iterator
from dataclasses import dataclass
from itertools import chain
from pathlib import Path
import numpy as np
from PIL import Image
from models.render import RenderResult
from models.tts import NarrationTimeline
from utils.audio import SAMPLE_RATE, apply_limiter, decode_audio, decode_mp3
from utils.image import bar_box, content_box, logo_bar, round_mask
from utils.qr import qr_box, qr_card
from utils.video import decode_frames, encode_mp4
OUT_W, OUT_H = 1080, 1920
FPS = 24
POSTER_W = 1000 # 9:16 안에서 포스터가 차지할 폭 (좌우 40px 여백)
TOP_PAD = 86
LOOP_OVERLAP = 16 # 겹침 프레임. 소재가 잔잔할수록 키워야 이음매가 죽는다
MIN_LOOP_SECONDS = 3
NARRATION_VOL, SFX_VOL, BGM_VOL = 1.45, 0.35, 0.20
BGM_FADE_IN = 1.0
BGM_FADE_OUT = 1.5
TAIL_FADE = 0.125
QR_DRIFT_RATIO = 0.04 # plate에서 찾은 QR이 이보다 어긋나면 패치를 포기한다
QR_SEARCH_PAD = 0.06
BAND_QR_MAX_H = 190
BAND_SIDE_PAD = 74
BAND_GAP = 60
@dataclass
class Layout:
"""0번 프레임에서 한 번 구해두고 모든 프레임에 그대로 적용한다."""
plate: tuple[int, int, int, int]
poster_size: tuple[int, int]
poster_top: int
background: tuple[int, ...]
band_y: int
band_h: int
bar_patch: tuple[Image.Image, tuple[int, int]] | None = None
qr_patch: tuple[Image.Image, tuple[int, int], Image.Image] | None = None
band_qr: tuple[Image.Image, tuple[int, int]] | None = None
band_bar: tuple[Image.Image, tuple[int, int]] | None = None
def norm_to_out(nx: float, ny: float, source: tuple[int, int], out_w: int) -> tuple[int, int]:
"""원본 정규좌표 → 출력 포스터 이미지 안의 픽셀 좌표."""
source_w, source_h = source
return round(nx * out_w), round(ny * out_w * source_h / source_w)
def band_colour(frame: Image.Image, plate: tuple[int, int, int, int]) -> tuple[int, ...]:
"""포스터 상단에서 뽑은 색은 채도가 높아 포스터와 경쟁하므로 밝기만 죽인다."""
left, top, right, bottom = plate
pixels = np.asarray(frame.convert("RGB")).astype(np.float32)
median = np.median(pixels[top:top + (bottom - top) // 5, left:right].reshape(-1, 3), axis=0)
return tuple(int(value * 0.34) for value in median)
def build_layout(frame: Image.Image, poster: Image.Image) -> Layout:
left, top, right, bottom = content_box(frame)
plate_w = right - left
source_w, source_h = poster.size
full_h = plate_w * source_h / source_w
source_bar = bar_box(poster)
source_qr = qr_box(poster)
has_band = bool(source_qr or source_bar)
if has_band:
poster_w = POSTER_W
poster_h = round(poster_w * (bottom - top) / plate_w)
poster_top = TOP_PAD
if TOP_PAD + poster_h >= OUT_H - 80:
raise RuntimeError(f"포스터가 너무 길다(밴드 {OUT_H - TOP_PAD - poster_h}px)")
else:
# 얹을 게 없으면 밴드는 존재 이유가 없다 — 포스터를 폭까지 키우고 상하 균등 여백
poster_w = OUT_W
poster_h = round(poster_w * (bottom - top) / plate_w)
if poster_h > OUT_H - 2 * TOP_PAD:
poster_h = OUT_H - 2 * TOP_PAD
poster_w = round(poster_h * plate_w / (bottom - top))
poster_top = (OUT_H - poster_h) // 2
layout = Layout(plate=(left, top, right, bottom), poster_size=(poster_w, poster_h),
poster_top=poster_top, background=band_colour(frame, (left, top, right, bottom)),
band_y=poster_top + poster_h, band_h=OUT_H - poster_top - poster_h)
if source_bar:
_, bar_y0 = norm_to_out(0.0, source_bar[1] / source_h, poster.size, poster_w)
_, bar_y1 = norm_to_out(0.0, source_bar[3] / source_h, poster.size, poster_w)
if 0 < bar_y0 < poster_h:
layout.bar_patch = (poster.crop(source_bar).resize((poster_w, bar_y1 - bar_y0),
Image.LANCZOS), (0, bar_y0))
if source_qr:
layout.qr_patch = inner_qr_patch(frame, poster, source_qr, layout, full_h)
if has_band:
layout.band_qr, layout.band_bar = band_assets(poster, layout)
return layout
def inner_qr_patch(frame: Image.Image, poster: Image.Image, source_qr: tuple[int, int, int, int],
layout: Layout, full_h: float):
"""포스터 안쪽 QR은 잘라낼 수 없으므로(그림 위에 얹혀 있다) 원본에서 오려 제자리에 덮는다."""
left, top, right, _ = layout.plate
plate_w = right - left
source_w, source_h = poster.size
poster_w, poster_h = layout.poster_size
x0, y0 = norm_to_out(source_qr[0] / source_w, source_qr[1] / source_h, poster.size, poster_w)
x1, y1 = norm_to_out(source_qr[2] / source_w, source_qr[3] / source_h, poster.size, poster_w)
# 네 귀퉁이를 다 뒤지면 엉뚱한 흰 덩어리를 찾았다고 오인해 drift 검사가 무의미해진다
window = (max(0.0, source_qr[0] / source_w - QR_SEARCH_PAD),
max(0.0, source_qr[1] / source_h - QR_SEARCH_PAD),
min(1.0, source_qr[2] / source_w + QR_SEARCH_PAD),
min(1.0, source_qr[3] / source_h + QR_SEARCH_PAD))
seen = qr_box(frame.crop((left, top, right, top + round(full_h))), strict=False, window=window)
if seen is None:
return None
drift = float(np.hypot(
(seen[0] + seen[2]) / 2 - (source_qr[0] + source_qr[2]) / 2 / source_w * plate_w,
(seen[1] + seen[3]) / 2 - (source_qr[1] + source_qr[3]) / 2 / source_h * full_h))
if drift > QR_DRIFT_RATIO * plate_w or y1 >= poster_h:
# 엉뚱한 자리에 QR을 찍는 것보다 안 찍는 게 낫다
return None
card = qr_card(poster, pad=0)
if card is None:
return None
patch = card.resize((x1 - x0, y1 - y0), Image.LANCZOS)
return patch, (x0, y0), round_mask(patch.size)
def band_assets(poster: Image.Image, layout: Layout):
"""밴드에 키워서 얹을 QR과 로고바. 밴드의 큰 QR이 스캔용이다."""
card = qr_card(poster)
band_qr = None
next_x = BAND_SIDE_PAD
if card:
height = min(BAND_QR_MAX_H, layout.band_h - 40)
resized = card.resize((round(card.width * height / card.height), height), Image.LANCZOS)
band_qr = (resized, (BAND_SIDE_PAD,
layout.band_y + (layout.band_h - resized.height) // 2))
next_x += resized.width + BAND_GAP
band_bar = None
bar = logo_bar(poster)
if bar:
width = OUT_W - next_x - BAND_SIDE_PAD
resized = bar.resize((width, round(bar.height * width / bar.width)), Image.LANCZOS)
band_bar = (resized, (next_x, layout.band_y + (layout.band_h - resized.height) // 2))
return band_qr, band_bar
def composite(frame: Image.Image, layout: Layout) -> Image.Image:
poster_area = frame.convert("RGB").crop(layout.plate).resize(layout.poster_size, Image.LANCZOS)
if layout.bar_patch:
poster_area.paste(*layout.bar_patch)
if layout.qr_patch:
patch, position, mask = layout.qr_patch
poster_area.paste(patch, position, mask)
canvas = Image.new("RGB", (OUT_W, OUT_H), layout.background)
canvas.paste(poster_area, ((OUT_W - layout.poster_size[0]) // 2, layout.poster_top))
if layout.band_qr:
canvas.paste(*layout.band_qr)
if layout.band_bar:
canvas.paste(*layout.band_bar)
return canvas
def loop_frames(clip: bytes, layout: Layout, tail: list[Image.Image],
loop_length: int, overlap: int) -> Iterator[Image.Image]:
"""머리에 꼬리를 녹여 마지막 프레임이 첫 프레임으로 자연스럽게 이어지게 한다."""
for index, frame in enumerate(decode_frames(clip)):
if index >= loop_length:
return
composited = composite(frame, layout)
if index < overlap:
weight = (index + 1) / (overlap + 1)
blended = (np.asarray(composited).astype(np.float32) * weight
+ np.asarray(tail[index]).astype(np.float32) * (1 - weight))
composited = Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8))
yield composited
def fade(pcm: np.ndarray, seconds: float, out: bool) -> np.ndarray:
length = min(len(pcm), round(seconds * SAMPLE_RATE))
if length <= 0:
return pcm
ramp = np.linspace(0.0, 1.0, length, dtype=np.float32)
faded = pcm.astype(np.float32)
if out:
faded[len(pcm) - length:] *= ramp[::-1]
else:
faded[:length] *= ramp
return faded
def mix_audio(clip: bytes, narration: bytes | None, bgm: bytes | None,
duration: float) -> np.ndarray:
"""나레이션 1.45 / SFX 0.35 / BGM 0.20. 리미터가 컴프레서처럼 작동해 목소리가 또렷해진다."""
total = round(duration * SAMPLE_RATE)
track = np.zeros(total, dtype=np.float32)
sfx = decode_audio(clip)
if sfx is not None:
sfx = sfx[:total]
track[:len(sfx)] += sfx.astype(np.float32) * SFX_VOL
if narration:
voice = decode_mp3(narration)[:total]
track[:len(voice)] += voice.astype(np.float32) * NARRATION_VOL
if bgm:
music = decode_mp3(bgm)
if len(music) < total:
music = np.tile(music, total // len(music) + 1)
music = fade(fade(music[:total].astype(np.float32) * BGM_VOL, BGM_FADE_IN, out=False),
BGM_FADE_OUT, out=True)
track += music
track = fade(track, TAIL_FADE, out=True)
return apply_limiter(np.clip(track, -32768, 32767).astype(np.int16))
def render(clip: bytes, poster: Path | Image.Image, *,
narration: bytes | None = None, bgm: bytes | None = None,
timeline: NarrationTimeline | None = None,
overlap: int = LOOP_OVERLAP) -> RenderResult:
source = (Image.open(poster) if isinstance(poster, Path) else poster).convert("RGB")
# 1회차 — 레이아웃을 잡고 꼬리 프레임만 남긴다. 전부 들고 있으면 1GB를 넘는다.
layout, tail_frames, frame_count = None, deque(maxlen=overlap), 0
for frame in decode_frames(clip):
if layout is None:
layout = build_layout(frame, source)
tail_frames.append(frame)
frame_count += 1
if layout is None:
raise RuntimeError("클립에서 프레임을 못 뽑았다")
tail = [composite(frame, layout) for frame in tail_frames]
loop_length = frame_count - overlap
if loop_length < FPS * MIN_LOOP_SECONDS:
raise RuntimeError(f"루프 길이가 너무 짧다 ({loop_length}프레임) — overlap을 줄일 것")
duration = loop_length / FPS
if timeline and timeline.cues[-1].end > duration:
raise RuntimeError(f"나레이션이 {timeline.cues[-1].end:.2f}초로 영상 {duration:.2f}초를 "
"넘는다 — overlap을 줄이거나 나레이션을 짧게")
# 2회차 — 순서대로 합성해 인코더에 바로 밀어넣는다
frames = loop_frames(clip, layout, tail, loop_length, overlap)
first = next(frames)
thumbnail = io.BytesIO()
first.save(thumbnail, "JPEG", quality=92)
video = encode_mp4(chain([first], frames), (OUT_W, OUT_H),
mix_audio(clip, narration, bgm, duration), fps=FPS)
return RenderResult(video=video, thumbnail=thumbnail.getvalue(), duration=duration,
frames=loop_length, width=OUT_W, height=OUT_H)