조각을 화면에 얹는 방식이라 스크롤이 한 번도 일어나지 않았다. 심규선 상세페이지 실측 — 19조각 중 1920px를 넘는 것이 0개라 scroll_scene의 span이 항상 0이었다. 화면의 13~44%만 조각이고 나머지는 블러 포스터로 남았으며, 같은 정지컷이 6초 넘게 이어졌다. 정본(tech_spec §5)에 있던 extend_to_height가 이식에서 빠진 것이 원인이다. 그것이 없으니 조각이 짧을 때 블러 배경에 상단 정렬로 얹는 수밖에 없었다. - to_surface: 롱이미지를 폭 1080으로. trim_blank를 태우지 않는다 — 위를 깎으면 섹션 y좌표가 전부 어긋난다 - extend_to_height + blank_band: 글자 없는 배경 띠를 거울 타일링, 이음매 페더 40px. 프레임보다 짧아도 풀프레임을 채운다 - scroll_scene: 배경 합성 없이 surface.crop() — 블러가 섞일 여지를 없앤다 - build_scene_plan: 섹션은 훑기 시작점, 큐 길이가 훑는 거리. 섹션이 떨어지면 앞 씬이 멈춘 자리에서 이어 읽는다(커서) - SCROLL_TAG_PRIORITY에서 notice·crossbanner 제거 — 약관을 읽히는 영상이 아니다 - final_checks: 씬 종류가 scroll이라고 적힌 것이 아니라 실제 이동 거리를 본다. 이 검사가 물러서 0픽셀 스크롤이 게이트를 통과했다 실측(심규선 자산 전체 합성, 569프레임 23.72초): 콘텐츠 비율 t=8s 23%→99% · t=11s 13%→92% · t=17s 44%→99% 프레임 간 변화량 0.01(정지) → 22~31 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
407 lines
18 KiB
Python
407 lines
18 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, 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)
|