diff --git a/backend/answers/person_box_answer.py b/backend/answers/person_box_answer.py
new file mode 100644
index 0000000..acfb9da
--- /dev/null
+++ b/backend/answers/person_box_answer.py
@@ -0,0 +1,8 @@
+"""⑩ hybrid config 생성 — 인물 영역 응답 스키마."""
+from pydantic import BaseModel
+
+from answers.detect_answer import GridBox
+
+
+class PersonBoxAnswer(BaseModel):
+ person: GridBox | None
diff --git a/backend/assets/fonts/GyeonggiTitleVOTF-Bold.otf b/backend/assets/fonts/GyeonggiTitleVOTF-Bold.otf
new file mode 100644
index 0000000..a82daf5
Binary files /dev/null and b/backend/assets/fonts/GyeonggiTitleVOTF-Bold.otf differ
diff --git a/backend/assets/fonts/LICENSE-Pretendard.md b/backend/assets/fonts/LICENSE-Pretendard.md
new file mode 100644
index 0000000..882cfb5
--- /dev/null
+++ b/backend/assets/fonts/LICENSE-Pretendard.md
@@ -0,0 +1,10 @@
+# Pretendard
+
+번들 사유: 정보 밴드 텍스트 합성(`text_overlay.py`)에 한글 폰트가 필요하다.
+macOS 시스템 폰트(AppleSDGothicNeo)를 쓰면 컨테이너 배포에서 깨지므로,
+재배포가 허용된 폰트를 저장소에 함께 둔다.
+
+- 저작자: orioncactus (길형진)
+- 버전: v1.3.9
+- 출처: https://github.com/orioncactus/pretendard
+- 라이선스: SIL Open Font License 1.1 (재배포·임베딩 허용)
diff --git a/backend/assets/fonts/Pretendard-Black.otf b/backend/assets/fonts/Pretendard-Black.otf
new file mode 100644
index 0000000..a0d849e
Binary files /dev/null and b/backend/assets/fonts/Pretendard-Black.otf differ
diff --git a/backend/assets/fonts/Pretendard-Bold.otf b/backend/assets/fonts/Pretendard-Bold.otf
new file mode 100644
index 0000000..8e5e30a
Binary files /dev/null and b/backend/assets/fonts/Pretendard-Bold.otf differ
diff --git a/backend/assets/fonts/Pretendard-Medium.otf b/backend/assets/fonts/Pretendard-Medium.otf
new file mode 100644
index 0000000..0575069
Binary files /dev/null and b/backend/assets/fonts/Pretendard-Medium.otf differ
diff --git a/backend/assets/fonts/Pretendard-Regular.otf b/backend/assets/fonts/Pretendard-Regular.otf
new file mode 100644
index 0000000..08bf4cf
Binary files /dev/null and b/backend/assets/fonts/Pretendard-Regular.otf differ
diff --git a/backend/assets/fonts/Pretendard-SemiBold.otf b/backend/assets/fonts/Pretendard-SemiBold.otf
new file mode 100644
index 0000000..e7e36ab
Binary files /dev/null and b/backend/assets/fonts/Pretendard-SemiBold.otf differ
diff --git a/backend/assets/nol/img_brand_logo.svg b/backend/assets/nol/img_brand_logo.svg
new file mode 100644
index 0000000..406bf5e
--- /dev/null
+++ b/backend/assets/nol/img_brand_logo.svg
@@ -0,0 +1,12 @@
+
diff --git a/backend/assets/nol/nol_og_1200x600.png b/backend/assets/nol/nol_og_1200x600.png
new file mode 100644
index 0000000..212833e
Binary files /dev/null and b/backend/assets/nol/nol_og_1200x600.png differ
diff --git a/backend/assets/nol/nol_wordmark_black.png b/backend/assets/nol/nol_wordmark_black.png
new file mode 100644
index 0000000..e19ddd2
Binary files /dev/null and b/backend/assets/nol/nol_wordmark_black.png differ
diff --git a/backend/assets/nol/nol_wordmark_white.png b/backend/assets/nol/nol_wordmark_white.png
new file mode 100644
index 0000000..df849a8
Binary files /dev/null and b/backend/assets/nol/nol_wordmark_white.png differ
diff --git a/backend/assets/nol/qr_26007169.png b/backend/assets/nol/qr_26007169.png
new file mode 100644
index 0000000..80134a4
Binary files /dev/null and b/backend/assets/nol/qr_26007169.png differ
diff --git a/backend/assets/nol/qr_26007416.png b/backend/assets/nol/qr_26007416.png
new file mode 100644
index 0000000..fe815ea
Binary files /dev/null and b/backend/assets/nol/qr_26007416.png differ
diff --git a/backend/assets/nol/qr_26012145.png b/backend/assets/nol/qr_26012145.png
new file mode 100644
index 0000000..1a7cb67
Binary files /dev/null and b/backend/assets/nol/qr_26012145.png differ
diff --git a/backend/models/longcut.py b/backend/models/longcut.py
new file mode 100644
index 0000000..e035433
--- /dev/null
+++ b/backend/models/longcut.py
@@ -0,0 +1,34 @@
+from dataclasses import dataclass
+from typing import Literal
+
+from PIL import Image
+from pydantic import BaseModel
+
+SceneKind = Literal["clip", "stills", "scroll", "poster"]
+
+
+class Scene(BaseModel):
+ """나레이션 큐 하나에 대응하는 화면."""
+ cue: int
+ kind: SceneKind
+ section: str | None = None # scroll일 때 쓸 섹션 이름
+ tag: str | None = None # 그 섹션의 태그. 왜 골랐는지 남긴다
+
+
+class LongcutPlan(BaseModel):
+ scenes: list[Scene]
+
+
+class EndBandInfo(BaseModel):
+ """엔드밴드 두 줄. 위가 공연명, 아래가 일시·장소."""
+ title: str
+ detail: str
+
+
+@dataclass
+class LongcutResult:
+ video: bytes
+ duration: float
+ frames: int
+ plan: LongcutPlan
+ proof: Image.Image | None = None
diff --git a/backend/raw_prompt/person_box.txt b/backend/raw_prompt/person_box.txt
new file mode 100644
index 0000000..f94176a
--- /dev/null
+++ b/backend/raw_prompt/person_box.txt
@@ -0,0 +1,11 @@
+포스터에 격자를 씌웠다.
+빨간 가로선 = 세로 위치(위에서부터 %), 하늘색 세로선 = 가로 위치(왼쪽부터 %).
+
+**격자 눈금을 읽어서** 사람(배우·인물·캐릭터)이 그려지거나 찍힌 영역을 하나의 사각으로 답하라.
+여러 명이면 전부 감싸는 하나의 사각으로 잡는다.
+
+- 글자·로고·QR은 사람이 아니다.
+- 사람이 없으면 null.
+- text는 null로 둔다.
+
+추측하지 말고 격자를 읽어라.
diff --git a/backend/services/build_layer_config.py b/backend/services/build_layer_config.py
new file mode 100644
index 0000000..f80eaff
--- /dev/null
+++ b/backend/services/build_layer_config.py
@@ -0,0 +1,112 @@
+"""⑩ hybrid 설정을 포스터에서 뽑는다.
+
+원본은 이 값을 사람이 손으로 재서 configs/nol_.json에 적었고, 신규 공연은 거기서
+멈췄다. 좌표는 detect가 이미 찾은 영역을 쓰고, 인물 사각만 따로 묻는다.
+
+타이밍(punches·sweep·glows)은 이미지에서 잴 수 있는 값이 아니라 기본 템플릿이다.
+"""
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+from answers.person_box_answer import PersonBoxAnswer
+from models.detect import Box, Regions
+from models.hybrid import LayerConfig, MaskSpec, Rect
+from services.detect import detect
+from settings import settings
+from utils.common_llm import StructuredLLM
+from utils.image import to_data_uri, vlm_grid_overlay
+from utils.prompt import load_prompt
+
+# 고정층은 글리프보다 넓게 잡는다. 글자만 덮으면 베벨·그림자가 밀려 겹쳐 보인다.
+FIXED_RECT_PAD = 0.012
+DARK_POSTER_LUMINANCE = 60 # 이보다 어두우면 사각 고정층이 검은 상자가 된다
+
+SWEEP_AT = 2.4
+FIRST_PUNCH_AT = 3.1
+PUNCH_GAP = 0.7 # 겹치면 이웃 복원이 안 된다
+DATE_PUNCH_AT = 5.5
+PUNCH_PEAK_MAX = 1.5
+PUNCH_PEAK_MIN = 1.05
+
+PUNCH_KINDS = ("title", "datetime")
+PROTECT_KINDS = ("place", "logo")
+
+PERSON_BOX_PROMPT = load_prompt("person_box")
+
+person_llm = StructuredLLM("gpt-4o", settings.chatgpt_api_key)
+
+
+def padded_rect(box: Box, pad: float = FIXED_RECT_PAD) -> Rect:
+ return (max(0.0, box.x0 - pad), max(0.0, box.y0 - pad),
+ min(1.0, box.x1 + pad), min(1.0, box.y1 + pad))
+
+
+def punch_peak(rect: Rect) -> float:
+ """넓은 블록일수록 덜 키운다 — 키운 크롭이 프레임을 넘으면 잘린다."""
+ width = max(rect[2] - rect[0], 1e-3)
+ return round(min(PUNCH_PEAK_MAX, max(PUNCH_PEAK_MIN, 1 / width)), 2)
+
+
+def is_dark_poster(poster: Image.Image) -> bool:
+ return float(np.asarray(poster.convert("L")).mean()) < DARK_POSTER_LUMINANCE
+
+
+async def ask_person_box(poster: Image.Image) -> Rect | None:
+ answer = await person_llm.ask_with_images(
+ PersonBoxAnswer, PERSON_BOX_PROMPT,
+ [("격자를 씌운 포스터:", to_data_uri(vlm_grid_overlay(poster)))])
+ if answer.person is None:
+ return None
+ box = answer.person
+ return (box.x0 / 100, box.y0 / 100, box.x1 / 100, box.y1 / 100)
+
+
+def emphasis(regions: Regions, dark: bool) -> tuple[list, list]:
+ """밝은 포스터는 펀치로, 검정 포스터는 glow로 강조한다.
+
+ 검정 포스터에서 사각을 키워 덮으면 그 사각이 검은 상자로 보인다.
+ """
+ punches, glows = [], []
+ at = FIRST_PUNCH_AT
+ for kind in PUNCH_KINDS:
+ box = regions.regions.get(kind)
+ if box is None:
+ continue
+ rect = padded_rect(box)
+ when = DATE_PUNCH_AT if kind == "datetime" else at
+ if dark:
+ glows.append((rect, when))
+ else:
+ punches.append((rect, when, punch_peak(rect)))
+ at += PUNCH_GAP
+ return punches, glows
+
+
+def build_from_regions(regions: Regions, person: Rect | None, dark: bool) -> LayerConfig:
+ fixed_rects = [padded_rect(box) for box in regions.regions.values()]
+ title = regions.regions.get("title")
+ punches, glows = emphasis(regions, dark)
+
+ return LayerConfig(
+ mask=MaskSpec(mode="glyph" if dark else "rect"),
+ fixed_rects=fixed_rects,
+ sil_box=person or (0.0, 0.0, 0.0, 0.0),
+ sil_poly=[], # 다각형은 손으로 그려야 해서 비운다
+ glyphs={"title": padded_rect(title)} if title else {},
+ sweep_at={"title": SWEEP_AT} if title else {},
+ punches=punches,
+ glows=glows,
+ protect=[padded_rect(regions.regions[kind]) for kind in PROTECT_KINDS
+ if kind in regions.regions],
+ glyph_dark_ink=not dark,
+ )
+
+
+async def build_layer_config(poster: Path | Image.Image,
+ regions: Regions | None = None) -> LayerConfig:
+ source = (Image.open(poster) if isinstance(poster, Path) else poster).convert("RGB")
+ if regions is None:
+ regions = (await detect(source)).data
+ return build_from_regions(regions, await ask_person_box(source), is_dark_poster(source))
diff --git a/backend/services/compose_long.py b/backend/services/compose_long.py
new file mode 100644
index 0000000..3fef056
--- /dev/null
+++ b/backend/services/compose_long.py
@@ -0,0 +1,268 @@
+"""⑪ 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)
diff --git a/backend/services/tts.py b/backend/services/tts.py
index 7decaa9..3c9fee3 100644
--- a/backend/services/tts.py
+++ b/backend/services/tts.py
@@ -81,7 +81,8 @@ async def synthesize(sentences: list[str], voice: str = "nova", *,
raise ValueError("나레이션 문장이 없다")
if typecast:
- spoken = await asyncio.gather(*(speak_typecast(text, typecast) for text in sentences))
+ # Typecast는 동시 요청에 429를 준다. 원본도 순차로 돈다.
+ spoken = [await speak_typecast(text, typecast) for text in sentences]
else:
spoken = await asyncio.gather(
*(speak_openai(text, voice, speed, instructions) for text in sentences))
diff --git a/backend/tests/playreel/test_build_layer_config.py b/backend/tests/playreel/test_build_layer_config.py
new file mode 100644
index 0000000..57bdcad
--- /dev/null
+++ b/backend/tests/playreel/test_build_layer_config.py
@@ -0,0 +1,64 @@
+"""⑩ config 생성 수동 확인용.
+
+사용: uv run python -m tests.playreel.test_build_layer_config
+upscaled.png 에서 detect + 인물 사각을 뽑아 layer_config.json 을 만든다.
+결과는 nol_/layer_config.json 과 layer_config_check.jpg.
+"""
+import asyncio
+import sys
+from pathlib import Path
+
+from PIL import Image, ImageDraw
+
+from services.build_layer_config import build_layer_config
+
+TEST_RESULT_DIR = Path(__file__).parent.parent / "test_result" / "playreel"
+COLORS = {"fixed": (255, 60, 60), "sil": (255, 255, 0), "punch": (60, 220, 255)}
+
+
+def draw_check(poster: Image.Image, config) -> Image.Image:
+ overlay = poster.convert("RGB").copy()
+ width, height = overlay.size
+ draw = ImageDraw.Draw(overlay)
+ line = max(3, width // 300)
+
+ def box(rect, color, label):
+ draw.rectangle([rect[0] * width, rect[1] * height, rect[2] * width, rect[3] * height],
+ outline=color, width=line)
+ draw.text((rect[0] * width + 6, rect[1] * height + 4), label, fill=color)
+
+ for rect in config.fixed_rects:
+ box(rect, COLORS["fixed"], "fixed")
+ if config.sil_box[2] > config.sil_box[0]:
+ box(config.sil_box, COLORS["sil"], "person")
+ for rect, at, peak in config.punches:
+ box(rect, COLORS["punch"], f"punch {at}s x{peak}")
+ return overlay
+
+
+async def main() -> None:
+ goods_id = sys.argv[1]
+ product_dir = TEST_RESULT_DIR / f"nol_{goods_id}"
+ poster_path = product_dir / "upscaled.png"
+ if not poster_path.exists():
+ raise SystemExit(f"{poster_path} 없음 — test_upscale_poster 를 먼저 돌릴 것")
+
+ config = await build_layer_config(poster_path)
+
+ config_path = product_dir / "layer_config.json"
+ check_path = product_dir / "layer_config_check.jpg"
+ config_path.write_text(config.model_dump_json(indent=2), encoding="utf-8")
+ with Image.open(poster_path) as poster:
+ draw_check(poster, config).save(check_path, quality=90)
+
+ print(f"mask={config.mask.mode} · 고정사각 {len(config.fixed_rects)}"
+ f" · 글리프 {len(config.glyphs)} · 펀치 {len(config.punches)}")
+ for rect, at, peak in config.punches:
+ print(f" punch {at}s x{peak} {tuple(round(v, 3) for v in rect)}")
+ if config.sil_box[2] <= config.sil_box[0]:
+ print("※ 인물 사각을 못 잡았다 — 인물이 없거나 VLM이 놓쳤다")
+ print(f"\n{config_path}\n{check_path}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/tests/playreel/test_compose_long.py b/backend/tests/playreel/test_compose_long.py
new file mode 100644
index 0000000..d7ad463
--- /dev/null
+++ b/backend/tests/playreel/test_compose_long.py
@@ -0,0 +1,86 @@
+"""⑪ compose 수동 확인용 — 30초 롱컷 조립.
+
+사용: uv run python -m tests.playreel.test_compose_long [variant]
+hybrid.mp4 가 있으면 그것을, 없으면 clip.mp4 를 훅으로 쓴다.
+결과는 nol_/longcut_.mp4 와 longcut_.json.
+"""
+import json
+import sys
+from pathlib import Path
+
+from PIL import Image
+
+from models.detail_section import DetailSection
+from models.longcut import EndBandInfo
+from models.nol import NolMeta
+from models.tts import NarrationTimeline
+from services.compose_long import compose_longcut
+
+TEST_RESULT_DIR = Path(__file__).parent.parent / "test_result" / "playreel"
+QR_DIR = Path(__file__).parent.parent.parent / "assets" / "nol"
+
+
+def load_sections(sections_dir: Path) -> list[DetailSection]:
+ index_path = sections_dir / "index.json"
+ if not index_path.exists():
+ raise SystemExit(f"{index_path} 없음 — test_split_detail 을 먼저 돌릴 것")
+ return [DetailSection(source=item["source"], index=item["index"],
+ y0=item["y0"], y1=item["y1"],
+ image=Image.open(sections_dir / item["file"]),
+ tag=item["tag"])
+ for item in json.loads(index_path.read_text(encoding="utf-8"))]
+
+
+def band_info(meta: NolMeta) -> EndBandInfo:
+ period = f"{meta.play_start_date or ''} ~ {meta.play_end_date or ''}".strip(" ~")
+ detail = " · ".join(part for part in (period.replace("-", "."), meta.place_name) if part)
+ return EndBandInfo(title=meta.goods_name or "", detail=detail)
+
+
+def main() -> None:
+ goods_id = sys.argv[1]
+ variant = sys.argv[2] if len(sys.argv) > 2 else "v1"
+ product_dir = TEST_RESULT_DIR / f"nol_{goods_id}"
+
+ hybrid = product_dir / "hybrid.mp4"
+ clip_path = hybrid if hybrid.exists() else product_dir / "clip.mp4"
+ required = [product_dir / "upscaled.png", clip_path,
+ product_dir / "timeline.json", product_dir / "narration.mp3"]
+ for path in required:
+ if not path.exists():
+ raise SystemExit(f"{path} 없음 — 앞 단계를 먼저 돌릴 것")
+ if not hybrid.exists():
+ print("※ hybrid.mp4 가 없어 원본 클립을 쓴다 — 제목이 흔들릴 수 있다")
+
+ output_path = product_dir / f"longcut_{variant}.mp4"
+ if output_path.exists():
+ raise SystemExit(f"덮어쓰기 금지: {output_path} — variant 를 바꿀 것")
+
+ meta = NolMeta.model_validate_json((product_dir / "meta.json").read_text(encoding="utf-8"))
+ timeline = NarrationTimeline.model_validate_json(
+ (product_dir / "timeline.json").read_text(encoding="utf-8"))
+ bgm_path = product_dir / "bgm.mp3"
+ qr_path = QR_DIR / f"qr_{goods_id}.png"
+
+ result = compose_longcut(
+ product_dir / "upscaled.png", clip_path.read_bytes(), timeline,
+ (product_dir / "narration.mp3").read_bytes(),
+ load_sections(product_dir / "sections"), band_info(meta),
+ bgm_mp3=bgm_path.read_bytes() if bgm_path.exists() else None,
+ qr=Image.open(qr_path) if qr_path.exists() else None)
+
+ output_path.write_bytes(result.video)
+ (product_dir / f"longcut_{variant}.json").write_text(
+ result.plan.model_dump_json(indent=2), encoding="utf-8")
+
+ for scene in result.plan.scenes:
+ label = f"{scene.section} [{scene.tag}]" if scene.section else ""
+ print(f" 큐{scene.cue} {scene.kind:7} {label}")
+ if not qr_path.exists():
+ print(f"※ {qr_path.name} 없음 — 엔드밴드에 QR을 안 넣는다")
+ print(f"\n{result.frames}프레임 · {result.duration:.2f}초 · {len(result.video) / 1e6:.1f}MB")
+ print(output_path)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/tests/run_playreel_after_gate4.sh b/backend/tests/run_playreel_after_gate4.sh
new file mode 100755
index 0000000..19cde0d
--- /dev/null
+++ b/backend/tests/run_playreel_after_gate4.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# Playreel 롱컷 — 게이트 ④ 승인 뒤, 게이트 ⑤(final_confirm) 직전까지.
+# 사용: tests/run_playreel_after_gate4.sh [variant]
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+goods_id="${1:?사용: $0 [variant]}"
+variant="${2:-v1}"
+config="tests/test_result/playreel/nol_${goods_id}/layer_config.json"
+
+run() { echo; echo "── $1 ──"; shift; uv run python -m "$@"; }
+
+if [ ! -f "$config" ]; then
+ run "⑩ config 생성" tests.playreel.test_build_layer_config "$goods_id"
+fi
+run "⑩ hybrid" tests.playreel.test_hybrid_poster "$goods_id" "$config"
+run "⑪ compose" tests.playreel.test_compose_long "$goods_id" "$variant"
+
+cat < ImageFont.FreeTypeFont:
+ path = FONT_DIR / PRETENDARD.format(weight=weight)
+ if not path.exists():
+ raise RuntimeError(f"폰트 없음: {path}")
+ return ImageFont.truetype(str(path), size)
+
+
+@lru_cache(maxsize=8)
+def title_font(size: int) -> ImageFont.FreeTypeFont:
+ path = FONT_DIR / GYEONGGI
+ if not path.exists():
+ raise RuntimeError(f"폰트 없음: {path}")
+ return ImageFont.truetype(str(path), size)
diff --git a/known_issue.md b/known_issue.md
index cd19e6b..d53f0c5 100644
--- a/known_issue.md
+++ b/known_issue.md
@@ -172,3 +172,19 @@ CLI를 계속 쓰는 한 없앨 수 없다. Higgsfield HTTP API로 가면 presig
CLI `generate create` 응답에 크레딧 필드가 없어서(⑥ i2v에서 확인) 원본이 쓰던 값 2를
그대로 상수로 뒀다. 요금이 바뀌면 조용히 틀린 값이 기록된다.
+
+## [Playreel] ⑪ compose
+
+### 스크롤 범위를 사람이 정하는 경로가 없다
+
+원본은 `compose_long.py`의 `SCENES[slug]["plan"]`에 손으로 적었다. 세 작품만 있고
+값은 전부 위쪽 일부만 훑는다 — 0→0.30, 0→0.14, 0→0.40, 0→0.10.
+
+프론트에도 입력이 없다. 게이트 ①의 `FetchGate`가 고르는 것은 어떤 섹션을 쓸지(`sections`)뿐이고
+스크롤 범위는 화면에 없다. `final_confirm`의 "상세페이지 풀프레임 스크롤"은 완성본 확인용
+체크 항목이다.
+
+**우리는 읽는 속도로 자동 계산한다.** 큐 길이 동안 화면 높이의 정해진 배수만큼 스크롤하고,
+이미지가 길면 위쪽만 보여준다. 사람이 "여기부터 저기까지"를 고르는 경로는 없다.
+
+배수 값이 검증된 게 아니다. 원본이 손으로 고른 구간과 결과가 다를 수 있다.