Compare commits

...

3 Commits

Author SHA1 Message Date
Haewon Kam
fb9c84aa62 style(web): 무빙포스터 로고를 Playreel과 같은 세로 락업으로
ADO2 로고 옆에 워드마크를 붙이던 인라인 배치를 Playreel 셸(.pr-wordmark)과
같은 문법으로 바꾼다 — ADO2 로고가 위, 제품 워드마크가 아래, 아래 줄을 로고
폭에 맞춰 락업이 사각형으로 앉는다.

MOVING POSTER 는 PLAYREEL 보다 글자가 길어 같은 18px 를 쓰면 로고 폭을
넘는다. 브라우저에서 재서 16.4px + 자간 0.14em 으로 잡았다.
자간은 마지막 글자 뒤에도 붙으므로 음수 마진으로 걷어내야 오른쪽 끝이 맞는다.

실측(로고 h24 = 162.54px):
  락업 컨테이너 폭 162.54px — 로고와 정확히 일치
  워드마크 잉크 폭 162.34px — 오른쪽 끝 오차 0.2px
  (참고: Playreel 자체 워드마크는 같은 기준에서 2.75px 어긋나 있다)
  390px 모바일 행: 헤더 71.4px, 메뉴 가로 스크롤 유지, 잘림 없음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 12:04:55 +09:00
Haewon Kam
d50dc52c39 fix(web): 게이트 ① 구획 선택에 썸네일을 그린다
thumb_url을 백엔드가 내려보내는데 화면은 라벨 칩만 그렸다.
실물은 19조각에 라벨이 겹쳐서("유의사항" 일곱 개) 무엇을 고르는지
알 수 없는 상태였다.

- SectionPicker: 썸네일 그리드(auto-fill 84px, 390px에서 4열).
  번호 배지 · 선택 표시 · 잠금(캐스팅 스케줄) · 높이 툴팁
- 문구를 새 동작에 맞춘다 — 조각을 잘라 붙이는 것이 아니라
  고른 지점부터 상세페이지를 이어서 훑는다
- 목 데이터를 실측 19조각으로 교체. 6개짜리 깔끔한 목을 두었던 탓에
  라벨이 겹치는 실물 모양을 개발 중에 못 봤다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:24:58 +09:00
Haewon Kam
0e53fa294b fix(compose): 상세페이지 스크롤을 롱이미지로 되돌린다
조각을 화면에 얹는 방식이라 스크롤이 한 번도 일어나지 않았다.
심규선 상세페이지 실측 — 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>
2026-09-09 15:24:58 +09:00
10 changed files with 286 additions and 83 deletions

View File

@ -11,7 +11,8 @@ class DetailSection:
index: int # 그 원본 안에서의 순번 index: int # 그 원본 안에서의 순번
y0: int y0: int
y1: int y1: int
image: Image.Image # compose는 좌표만 쓴다 — 조각 그림 없이 만들어도 된다
image: Image.Image | None = None
tag: str = UNTAGGED tag: str = UNTAGGED
@property @property

View File

@ -11,8 +11,12 @@ class Scene(BaseModel):
"""나레이션 큐 하나에 대응하는 화면.""" """나레이션 큐 하나에 대응하는 화면."""
cue: int cue: int
kind: SceneKind kind: SceneKind
section: str | None = None # scroll일 때 쓸 섹션 이름 section: str | None = None # scroll 시작점이 된 섹션 이름
tag: str | None = None # 그 섹션의 태그. 왜 골랐는지 남긴다 tag: str | None = None # 그 섹션의 태그. 왜 골랐는지 남긴다
# scroll은 조각이 아니라 롱이미지 위에서 일어난다. 아래 셋이 그 좌표다
source: str | None = None # 훑을 롱이미지 (detail_01 …)
y0: float | None = None # 시작 위치. 롱이미지 높이 대비 0~1
y1: float | None = None # 끝 위치
class LongcutPlan(BaseModel): class LongcutPlan(BaseModel):

View File

@ -141,12 +141,15 @@ def final_checks(task: PlayreelTask) -> list[dict]:
playreel에는 그 단계가 없어 검사 자체가 돌지 않는다. playreel에는 그 단계가 없어 검사 자체가 돌지 않는다.
""" """
plan = LongcutPlan.model_validate(task.scene_plan or {"scenes": []}) plan = LongcutPlan.model_validate(task.scene_plan or {"scenes": []})
scroll_tags = {scene.tag for scene in plan.scenes if scene.kind == "scroll"} scrolls = [scene for scene in plan.scenes if scene.kind == "scroll"]
scroll_tags = {scene.tag for scene in scrolls}
return [ return [
{"key": "hybrid", "label": "원본 글자 합성", {"key": "hybrid", "label": "원본 글자 합성",
"ok": bool(task.hybrid_clip_url)}, "ok": bool(task.hybrid_clip_url)},
# 씬 종류가 scroll이라고 적힌 것만으로는 부족하다 — 실제로 훑은 거리를 본다.
# 조각이 화면보다 짧아 한 픽셀도 안 움직인 채 통과한 적이 있다
{"key": "scroll", "label": "상세페이지 스크롤", {"key": "scroll", "label": "상세페이지 스크롤",
"ok": any(scene.kind == "scroll" for scene in plan.scenes)}, "ok": any((scene.y1 or 0.0) > (scene.y0 or 0.0) for scene in scrolls)},
{"key": "schedule", "label": "캐스팅 스케줄 포함", {"key": "schedule", "label": "캐스팅 스케줄 포함",
"ok": REQUIRED_SECTION_TAG in scroll_tags}, "ok": REQUIRED_SECTION_TAG in scroll_tags},
{"key": "bgm", "label": "배경음악", {"key": "bgm", "label": "배경음악",

View File

@ -55,6 +55,20 @@ async def load_sections(entries: list[dict]) -> list[DetailSection]:
for entry in entries] for entry in entries]
def section_specs(entries: list[dict]) -> list[DetailSection]:
"""compose용 — 조각 그림은 안 받는다. 스크롤은 롱이미지 위에서 일어나고
섹션은 어디부터 훑을지를 정하는 좌표로만 쓰인다"""
return [DetailSection(source=entry["source"], index=entry["index"],
y0=entry["y0"], y1=entry["y1"], tag=entry["tag"])
for entry in entries]
async def load_details(task: PlayreelTask) -> dict[str, Image.Image]:
"""상세페이지 롱이미지. 이름은 split이 붙인 source와 같아야 한다"""
return {f"detail_{order:02d}": Image.open(io.BytesIO(await blob.download_bytes(url)))
for order, url in enumerate(task.detail_image_urls or [], 1)}
def working_poster_url(task: PlayreelTask) -> str: def working_poster_url(task: PlayreelTask) -> str:
"""업스케일이 있으면 그것이 이후 단계의 원본이 됨""" """업스케일이 있으면 그것이 이후 단계의 원본이 됨"""
return task.upscaled_poster_url or task.poster_url return task.upscaled_poster_url or task.poster_url
@ -234,7 +248,8 @@ async def run_compose(session: AsyncSession, task: PlayreelTask) -> None:
meta = NolMeta.model_validate(task.nol_meta) meta = NolMeta.model_validate(task.nol_meta)
timeline = NarrationTimeline.model_validate(task.narration_timeline) timeline = NarrationTimeline.model_validate(task.narration_timeline)
selected = [entry for entry in task.detail_sections or [] if entry["selected"]] selected = [entry for entry in task.detail_sections or [] if entry["selected"]]
sections = await load_sections(selected) sections = section_specs(selected)
details = await load_details(task)
# 하이브리드 합성을 못 거친 클립은 제목이 흔들림 # 하이브리드 합성을 못 거친 클립은 제목이 흔들림
clip = await blob.download_bytes(task.hybrid_clip_url or task.clip_url) clip = await blob.download_bytes(task.hybrid_clip_url or task.clip_url)
@ -245,7 +260,8 @@ async def run_compose(session: AsyncSession, task: PlayreelTask) -> None:
result = await asyncio.to_thread( result = await asyncio.to_thread(
compose_longcut, poster, clip, timeline, narration, sections, band_info(meta), compose_longcut, poster, clip, timeline, narration, sections, band_info(meta),
bgm_mp3=bgm, qr=Image.open(qr_path) if qr_path.exists() else None, preview=True) details=details, bgm_mp3=bgm,
qr=Image.open(qr_path) if qr_path.exists() else None, preview=True)
filename = f"{task.slug}_30_v{task.version + 1}.mp4" filename = f"{task.slug}_30_v{task.version + 1}.mp4"
task.video_url = await store_bytes(PIPELINE, task.id, filename, result.video, task.video_url = await store_bytes(PIPELINE, task.id, filename, result.video,

View File

@ -1,8 +1,9 @@
"""⑪ compose — 무빙포스터 훅 + 상세페이지 스크롤로 30초 롱컷을 조립한다. """⑪ compose — 무빙포스터 훅 + 상세페이지 스크롤로 30초 롱컷을 조립한다.
씬 경계는 나레이션 큐다. 0초는 포스터 원본이라 첫 프레임이 곧 썸네일이 된다. 씬 경계는 나레이션 큐다. 0초는 포스터 원본이라 첫 프레임이 곧 썸네일이 된다.
상세페이지는 잘라 붙이지 않고 롱이미지를 실제로 스크롤한다. 상세페이지는 잘라 붙이지 않고 롱이미지를 실제로 스크롤한다 — 조각은 "어디부터 읽을지"를
포스터·롱이미지는 세로 롱이라 9:16을 못 채워서 포스터 블러판 위에 얹는다. 정하는 시작점일 뿐이고, 화면에 흐르는 것은 언제나 원본 롱이미지다.
포스터는 세로 롱이라 9:16을 못 채워서 포스터 블러판 위에 얹는다.
텍스트 오버레이는 넣지 않는다. 남는 글자는 엔드밴드뿐이다. 텍스트 오버레이는 넣지 않는다. 남는 글자는 엔드밴드뿐이다.
""" """
@ -12,7 +13,7 @@ from collections.abc import Iterator, Sequence
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from PIL import Image, ImageDraw, ImageFilter from PIL import Image, ImageDraw, ImageFilter, ImageOps
from models.detail_section import DetailSection from models.detail_section import DetailSection
from models.longcut import EndBandInfo, LongcutPlan, LongcutResult, Scene from models.longcut import EndBandInfo, LongcutPlan, LongcutResult, Scene
@ -30,9 +31,15 @@ DUCK_THRESHOLD = 0.03
DUCK_ATTACK_MS, DUCK_RELEASE_MS = 60, 500 DUCK_ATTACK_MS, DUCK_RELEASE_MS = 60, 500
BGM_FADE_OUT = 1.2 BGM_FADE_OUT = 1.2
# 큐 1초당 화면 높이의 몇 배를 훑을지. 원본은 사람이 구간을 골랐다(known_issue 참조). # 큐 1초당 화면 높이의 몇 배를 훑을지. 5초 씬에 이미지 높이의 15~30%가 읽기 좋은 속도다
SCROLL_SCREENS_PER_SECOND = 0.35 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_COUNT = 8 # 완성본 전체에서 고르게 뽑는 시점 수
PROOF_SIZE = (180, 320) PROOF_SIZE = (180, 320)
PROOF_GAP = 10 PROOF_GAP = 10
@ -46,7 +53,8 @@ NOL_WORDMARK = ASSET_DIR / "nol_wordmark_white.png"
# 큐 순서대로 채울 씬. 앞 둘은 클립, 마지막은 포스터, 나머지는 이 태그 우선순위로 고른다. # 큐 순서대로 채울 씬. 앞 둘은 클립, 마지막은 포스터, 나머지는 이 태그 우선순위로 고른다.
CLIP_CUES = 2 CLIP_CUES = 2
SCROLL_TAG_PRIORITY = ("synopsis", "still", "cast", "schedule", "discount", "event", "notice") # 공지·교차배너는 스크롤 시작점으로 쓰지 않는다 — 약관을 읽히려고 만드는 영상이 아니다
SCROLL_TAG_PRIORITY = ("synopsis", "still", "cast", "schedule", "discount", "event", "info")
REQUIRED_TAGS = ("schedule",) # 캐스팅 스케줄은 예매 전에 확인하는 정보라 빠지면 안 된다 REQUIRED_TAGS = ("schedule",) # 캐스팅 스케줄은 예매 전에 확인하는 정보라 빠지면 안 된다
@ -61,20 +69,78 @@ def blurred_bg(poster: Image.Image) -> Image.Image:
return Image.eval(blurred, lambda v: int(v * 0.45)) return Image.eval(blurred, lambda v: int(v * 0.45))
def trim_blank(image: Image.Image, threshold: int = 235) -> Image.Image: def ink_rows(image: Image.Image) -> np.ndarray:
"""상하단 흰·균일 여백 제거. split이 남긴 12px 여백이 화면에 흰 줄로 보인다.""" """행마다 글자·그림이 있는지. 배경 중앙값에서 벗어난 픽셀의 비율로 본다."""
pixels = np.asarray(image.convert("RGB")).astype(int) pixels = np.asarray(image.convert("L")).astype(np.int16)
white = (pixels.min(axis=2) > threshold).mean(axis=1) > 0.98 background = np.median(pixels)
uniform = pixels.std(axis=1).max(axis=1) < 4 return (np.abs(pixels - background) > INK_DEVIATION).mean(axis=1) > INK_ROW_RATIO
content = np.where(~(white | uniform))[0]
if len(content) == 0:
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 return image
return image.crop((0, int(content[0]), image.width, int(content[-1]) + 1))
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 fit_width(image: Image.Image) -> Image.Image: def _feather_seam(canvas: Image.Image, seam: int, width: int,
image = trim_blank(image) feather: int = EXTEND_FEATHER) -> None:
return image.resize((W, max(1, round(image.height * W / image.width))), Image.LANCZOS) """이음매 위아래를 선형 혼합해 가로줄이 보이지 않게 한다."""
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: def still_scene(still: Image.Image, progress: float, index: int) -> Image.Image:
@ -95,15 +161,17 @@ def still_scene(still: Image.Image, progress: float, index: int) -> Image.Image:
return frame return frame
def scroll_scene(image: Image.Image, background: Image.Image, progress: float, def scroll_top(surface: Image.Image, fraction: float) -> int:
duration: float) -> Image.Image: """이미지 높이 대비 비율을 실제 crop 시작 y로. 끝을 넘지 않게 묶는다."""
"""읽는 속도로 훑는다. 이미지가 길면 위쪽만 보여준다.""" span = max(0, surface.height - H)
span = max(0, image.height - H) return min(max(0, round(surface.height * fraction)), span)
reach = min(span, round(duration * SCROLL_SCREENS_PER_SECOND * H))
top = int(reach * ease_sine(progress))
frame = background.copy() def scroll_scene(surface: Image.Image, progress: float, y0: float, y1: float) -> Image.Image:
frame.paste(image.crop((0, top, W, min(image.height, top + H))), (0, 0)) """롱이미지를 y0~y1 구간까지 훑는다. 프레임을 가득 채운다 — 배경이 비지 않는다."""
return frame 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: def poster_scene(poster: Image.Image, background: Image.Image, progress: float) -> Image.Image:
@ -149,10 +217,19 @@ def end_band(frame: Image.Image, info: EndBandInfo, seconds: float,
draw.text((60, top + offset + slide), text, font=font(weight, size), fill=fill) draw.text((60, top + offset + slide), text, font=font(weight, size), fill=fill)
def build_scene_plan(sections: Sequence[DetailSection], cue_count: int) -> LongcutPlan: 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]] = {} by_tag: dict[str, list[DetailSection]] = {}
for section in sections: for section in usable:
by_tag.setdefault(section.tag, []).append(section) by_tag.setdefault(section.tag, []).append(section)
ordered: list[DetailSection] = [] ordered: list[DetailSection] = []
@ -162,18 +239,48 @@ def build_scene_plan(sections: Sequence[DetailSection], cue_count: int) -> Longc
for section in by_tag.get(tag, []): for section in by_tag.get(tag, []):
if section not in ordered: if section not in ordered:
ordered.append(section) ordered.append(section)
return ordered
scenes = []
scroll_index = 0 def scroll_window(section: DetailSection, source: Image.Image, surface: Image.Image,
for cue in range(cue_count): 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: if cue < CLIP_CUES:
scenes.append(Scene(cue=cue, kind="clip")) scenes.append(Scene(cue=cue, kind="clip"))
elif cue == cue_count - 1: elif cue == len(spans) - 1:
scenes.append(Scene(cue=cue, kind="poster")) scenes.append(Scene(cue=cue, kind="poster"))
elif scroll_index < len(ordered): elif index < len(ordered):
section = ordered[scroll_index] section = ordered[index]
scenes.append(Scene(cue=cue, kind="scroll", section=section.name, tag=section.tag)) y0, y1 = scroll_window(section, sources[section.source],
scroll_index += 1 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: else:
scenes.append(Scene(cue=cue, kind="poster")) scenes.append(Scene(cue=cue, kind="poster"))
return LongcutPlan(scenes=scenes) return LongcutPlan(scenes=scenes)
@ -214,13 +321,11 @@ def mix_audio(narration_mp3: bytes, bgm_mp3: bytes | None, duration: float) -> n
def compose_frames(timeline: NarrationTimeline, plan: LongcutPlan, poster: Image.Image, def compose_frames(timeline: NarrationTimeline, plan: LongcutPlan, poster: Image.Image,
clip: bytes, sections: dict[str, Image.Image], clip: bytes, surfaces: dict[str, Image.Image],
info: EndBandInfo, qr: Image.Image | None) -> Iterator[Image.Image]: info: EndBandInfo, qr: Image.Image | None) -> Iterator[Image.Image]:
background = blurred_bg(poster) background = blurred_bg(poster)
starts = [0.0] + [cue.start for cue in timeline.cues[1:]] spans = cue_spans(timeline)
bounds = [cue.start for cue in timeline.cues[1:]] + [timeline.total]
clip_frames = list(decode_frames(clip)) 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} scenes = {scene.cue: scene for scene in plan.scenes}
def frame_at(cue: int, at: float) -> Image.Image: def frame_at(cue: int, at: float) -> Image.Image:
@ -231,23 +336,24 @@ def compose_frames(timeline: NarrationTimeline, plan: LongcutPlan, poster: Image
frame = background.copy() frame = background.copy()
frame.paste(fitted, (0, (H - fitted.height) // 2)) frame.paste(fitted, (0, (H - fitted.height) // 2))
return frame return frame
start, end = starts[cue], bounds[cue] start, end = spans[cue]
progress = (at - start) / max(end - start, 0.01) progress = (at - start) / max(end - start, 0.01)
if scene.kind == "scroll" and scene.section in scroll_images: if scene.kind == "scroll" and scene.source in surfaces:
return scroll_scene(scroll_images[scene.section], background, progress, end - start) return scroll_scene(surfaces[scene.source], progress,
scene.y0 or 0.0, scene.y1 or 0.0)
return poster_scene(poster, background, progress) return poster_scene(poster, background, progress)
total_frames = round(timeline.total * FPS) total_frames = round(timeline.total * FPS)
for index in range(total_frames): for index in range(total_frames):
at = index / FPS at = index / FPS
cue = max(order for order in range(len(timeline.cues)) if at >= starts[order]) cue = max(order for order, (start, _) in enumerate(spans) if at >= start)
image = frame_at(cue, at) image = frame_at(cue, at)
remaining = (bounds[cue] - at) * FPS remaining = (spans[cue][1] - at) * FPS
if cue < len(timeline.cues) - 1 and remaining < CROSSFADE_FRAMES \ if cue < len(spans) - 1 and remaining < CROSSFADE_FRAMES \
and scenes[cue + 1].kind != "clip": and scenes[cue + 1].kind != "clip":
image = Image.blend(image, frame_at(cue + 1, at), 1 - remaining / CROSSFADE_FRAMES) image = Image.blend(image, frame_at(cue + 1, at), 1 - remaining / CROSSFADE_FRAMES)
if scenes[cue].kind == "poster": if scenes[cue].kind == "poster":
end_band(image, info, at - starts[cue], qr) end_band(image, info, at - spans[cue][0], qr)
yield image yield image
@ -262,18 +368,21 @@ def proof_sheet(tiles: list[Image.Image]) -> Image.Image:
def compose_longcut(poster: bytes | Path | Image.Image, clip: bytes, def compose_longcut(poster: bytes | Path | Image.Image, clip: bytes,
timeline: NarrationTimeline, narration_mp3: bytes, timeline: NarrationTimeline, narration_mp3: bytes,
sections: Sequence[DetailSection], info: EndBandInfo, *, sections: Sequence[DetailSection], info: EndBandInfo, *,
details: dict[str, Image.Image] | None = None,
bgm_mp3: bytes | None = None, qr: Image.Image | None = None, bgm_mp3: bytes | None = None, qr: Image.Image | None = None,
plan: LongcutPlan | None = None, plan: LongcutPlan | None = None,
preview: bool = False) -> LongcutResult: preview: bool = False) -> LongcutResult:
"""details는 source 이름(detail_01…) → 상세페이지 롱이미지. 스크롤은 이 위에서 일어난다."""
if isinstance(poster, Image.Image): if isinstance(poster, Image.Image):
source = poster.convert("RGB") source = poster.convert("RGB")
else: else:
source = Image.open(poster if isinstance(poster, Path) source = Image.open(poster if isinstance(poster, Path)
else io.BytesIO(poster)).convert("RGB") else io.BytesIO(poster)).convert("RGB")
plan = plan or build_scene_plan(sections, len(timeline.cues)) sources = dict(details or {})
used = {scene.section for scene in plan.scenes if scene.section} surfaces = {name: to_surface(image) for name, image in sources.items()}
images = {section.name: section.image for section in sections if section.name in used} spans = cue_spans(timeline)
plan = plan or build_scene_plan(sections, spans, sources, surfaces)
total_frames = round(timeline.total * FPS) total_frames = round(timeline.total * FPS)
# 훅 구간만 보면 뒤쪽 스크롤과 엔드밴드를 검수할 수 없어 전체에서 고르게 뽑는다 # 훅 구간만 보면 뒤쪽 스크롤과 엔드밴드를 검수할 수 없어 전체에서 고르게 뽑는다
@ -283,7 +392,7 @@ def compose_longcut(poster: bytes | Path | Image.Image, clip: bytes,
def frames() -> Iterator[Image.Image]: def frames() -> Iterator[Image.Image]:
for index, frame in enumerate( for index, frame in enumerate(
compose_frames(timeline, plan, source, clip, images, info, qr)): compose_frames(timeline, plan, source, clip, surfaces, info, qr)):
if index == 0: if index == 0:
frame.save(thumbnail, "JPEG", quality=THUMBNAIL_QUALITY) frame.save(thumbnail, "JPEG", quality=THUMBNAIL_QUALITY)
if preview and index in proof_at: if preview and index in proof_at:

View File

@ -31,6 +31,16 @@ def load_sections(sections_dir: Path) -> list[DetailSection]:
for item in json.loads(index_path.read_text(encoding="utf-8"))] for item in json.loads(index_path.read_text(encoding="utf-8"))]
def load_details(product_dir: Path) -> dict[str, Image.Image]:
"""상세페이지 롱이미지. 스크롤은 조각이 아니라 이 위에서 일어난다."""
details = {}
for path in sorted((product_dir / "details").glob("detail_*.*")):
details[path.stem] = Image.open(path)
if not details:
raise SystemExit(f"{product_dir / 'details'} 비었음 — test_fetch_nol 을 먼저 돌릴 것")
return details
def band_info(meta: NolMeta) -> EndBandInfo: def band_info(meta: NolMeta) -> EndBandInfo:
period = f"{meta.play_start_date or ''} ~ {meta.play_end_date or ''}".strip(" ~") 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) detail = " · ".join(part for part in (period.replace("-", "."), meta.place_name) if part)
@ -66,6 +76,7 @@ def main() -> None:
product_dir / "upscaled.png", clip_path.read_bytes(), timeline, product_dir / "upscaled.png", clip_path.read_bytes(), timeline,
(product_dir / "narration.mp3").read_bytes(), (product_dir / "narration.mp3").read_bytes(),
load_sections(product_dir / "sections"), band_info(meta), load_sections(product_dir / "sections"), band_info(meta),
details=load_details(product_dir),
bgm_mp3=bgm_path.read_bytes() if bgm_path.exists() else None, bgm_mp3=bgm_path.read_bytes() if bgm_path.exists() else None,
qr=Image.open(qr_path) if qr_path.exists() else None) qr=Image.open(qr_path) if qr_path.exists() else None)
@ -75,7 +86,9 @@ def main() -> None:
for scene in result.plan.scenes: for scene in result.plan.scenes:
label = f"{scene.section} [{scene.tag}]" if scene.section else "" label = f"{scene.section} [{scene.tag}]" if scene.section else ""
print(f" 큐{scene.cue} {scene.kind:7} {label}") span = (f" {scene.source} {scene.y0:.3f}→{scene.y1:.3f}"
if scene.kind == "scroll" else "")
print(f" 큐{scene.cue} {scene.kind:7} {label}{span}")
if not qr_path.exists(): if not qr_path.exists():
print(f"※ {qr_path.name} 없음 — 엔드밴드에 QR을 안 넣는다") print(f"※ {qr_path.name} 없음 — 엔드밴드에 QR을 안 넣는다")
print(f"\n{result.frames}프레임 · {result.duration:.2f}초 · {len(result.video) / 1e6:.1f}MB") print(f"\n{result.frames}프레임 · {result.duration:.2f}초 · {len(result.video) / 1e6:.1f}MB")

View File

@ -14,9 +14,9 @@ export default function Ado2Layout({ children }: { children: React.ReactNode })
<> <>
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-logo"> <div className="sidebar-logo">
<Link href="/" style={{ color: "var(--color-text-white)", display: "inline-flex", alignItems: "baseline", gap: 8, textDecoration: "none" }}> <Link href="/" className="mp-wordmark">
<Ado2Logo height={20} /> <Ado2Logo height={24} />
<span className="sidebar-product">MOVING POSTER</span> <span className="mp-mark">MOVING POSTER</span>
</Link> </Link>
</div> </div>
<nav className="sidebar-menu"> <nav className="sidebar-menu">

View File

@ -419,6 +419,19 @@ body { margin: 0; -webkit-font-smoothing: antialiased; letter-spacing: -0.006em;
.note a { color: var(--color-mint); } .note a { color: var(--color-mint); }
.sidebar-product { font-size: 10px; font-weight: 800; letter-spacing: 0.1em; color: var(--color-mint); white-space: nowrap; } .sidebar-product { font-size: 10px; font-weight: 800; letter-spacing: 0.1em; color: var(--color-mint); white-space: nowrap; }
/* ── (ado2) 셸 워드마크 — Playreel 락업(.pr-wordmark)과 같은 문법 ──
ADO2 로고 위 · 제품 워드마크 아래. 아래 줄을 로고 폭에 맞춰 락업이 사각형으로 앉는다.
로고 폭은 height × 149/22 이므로 h24 = 162.5px. MOVING POSTER 는 PLAYREEL 보다
글자가 길어 자간·크기를 그 폭에 맞게 따로 잡았다(실측으로 맞춤). */
.mp-wordmark { display: inline-flex; flex-direction: column; gap: 6px; text-decoration: none; align-items: flex-start; }
.mp-wordmark svg { color: var(--color-text-gray-300); }
.mp-mark {
/* 16.4px + 0.14em 에서 글자 잉크 폭이 162.3px — 로고(h24 = 162.5px)와 0.2px 차이다.
자간은 마지막 글자 뒤에도 붙으므로 그만큼 음수 마진으로 걷어내야 오른쪽 끝이 맞는다. */
font-size: 16.4px; font-weight: 800; letter-spacing: 0.14em; margin-right: -0.14em;
color: var(--color-text-white); line-height: 1; white-space: nowrap;
}
.entry-stack { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 560px; margin-top: 1.5rem; } .entry-stack { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 560px; margin-top: 1.5rem; }
.entry { display: flex; flex-direction: column; gap: 0.85rem; padding: 1.25rem; position: relative; } .entry { display: flex; flex-direction: column; gap: 0.85rem; padding: 1.25rem; position: relative; }
.entry--hot { border-color: var(--color-mint-30); } .entry--hot { border-color: var(--color-mint-30); }
@ -471,6 +484,7 @@ body { margin: 0; -webkit-font-smoothing: antialiased; letter-spacing: -0.006em;
.sidebar-menu { flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; } .sidebar-menu { flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.sidebar-menu::-webkit-scrollbar { display: none; } .sidebar-menu::-webkit-scrollbar { display: none; }
.sidebar-product { display: none; } .sidebar-product { display: none; }
/* 워드마크는 남긴다 — Playreel 셸(.pr-mark)과 같은 처리 */
.playreel-steps { grid-template-columns: repeat(2, 1fr); } .playreel-steps { grid-template-columns: repeat(2, 1fr); }
.result-grid { grid-template-columns: 1fr; } .result-grid { grid-template-columns: 1fr; }
.result-grid .result-video { min-height: 240px; } .result-grid .result-video { min-height: 240px; }

View File

@ -8,8 +8,8 @@
import { useState } from "react"; import { useState } from "react";
import { import {
GATE_META, type AnalysisReview, type ClipReview, type FetchReview, type FinalReview, GATE_META, type AnalysisReview, type ClipReview, type DetailSection, type FetchReview,
type GateKey, type GateReview, type NarrationReview, type FinalReview, type GateKey, type GateReview, type NarrationReview,
} from "@/lib/playreel"; } from "@/lib/playreel";
// ── 공통 셸 ────────────────────────────────────────────────────────── // ── 공통 셸 ──────────────────────────────────────────────────────────
@ -85,6 +85,46 @@ function Warn({ children }: { children: React.ReactNode }) {
const two = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--spacing-page-md)", alignItems: "start" } as const; const two = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--spacing-page-md)", alignItems: "start" } as const;
// 상세페이지 조각은 라벨이 겹친다 — "유의사항"만 일곱 개인 페이지가 흔하다.
// 칩으로 그리면 같은 글자가 줄줄이 서서 무엇을 고르는지 알 수 없어 썸네일을 그린다.
function SectionPicker({ sections, onToggle }: {
sections: DetailSection[]; onToggle: (id: string) => void;
}) {
const chosen = sections.filter((s) => s.selected).length;
return (
<>
<p className="field-label" style={{ margin: "1.5rem 0 0.35rem" }}>상세페이지에서 훑기 시작할 지점</p>
<p style={{ margin: "0 0 0.7rem", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", lineHeight: 1.5 }}>
고른 지점부터 상세페이지를 이어서 훑습니다. 조각을 잘라 붙이는 것이 아닙니다.
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))", gap: 8, maxHeight: 340, overflowY: "auto", paddingRight: 4 }}>
{sections.map((s, i) => (
<button key={s.id} type="button" title={`${s.label} · ${s.height.toLocaleString()}px`}
onClick={s.required ? undefined : () => onToggle(s.id)}
style={{
position: "relative", padding: 0, borderRadius: "var(--radius-md)", overflow: "hidden",
cursor: s.required ? "default" : "pointer", textAlign: "left", background: "transparent",
border: s.selected ? "2px solid var(--color-mint)" : "2px solid var(--color-border-white-10)",
opacity: s.selected ? 1 : 0.5,
}}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={s.thumb_url} alt={s.label}
style={{ width: "100%", aspectRatio: "3 / 4", objectFit: "cover", objectPosition: "top", display: "block", background: "#000" }} />
<span style={{ position: "absolute", top: 4, left: 4, minWidth: 16, padding: "1px 4px", borderRadius: 4, background: "rgba(0,0,0,0.65)", color: "#fff", fontSize: 10, textAlign: "center" }}>{i + 1}</span>
<span style={{ display: "flex", alignItems: "center", gap: 4, padding: "3px 5px", fontSize: 10, lineHeight: 1.3, background: "rgba(0,0,0,0.65)", color: s.selected ? "var(--color-mint)" : "var(--color-text-gray-400)" }}>
{s.required ? <Lock size={10} /> : s.selected ? <Ico d={CHECK} size={10} /> : <Ico d={PLUS} size={10} />}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.label}</span>
</span>
</button>
))}
</div>
<p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", display: "flex", alignItems: "center", gap: 6 }}>
<Lock size={11} /> {sections.length}개 중 {chosen}개 선택 · 캐스팅 스케줄은 항상 포함됩니다.
</p>
</>
);
}
// ── ① 수집 확인 ─────────────────────────────────────────────────────── // ── ① 수집 확인 ───────────────────────────────────────────────────────
export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (edits: { sections: string[]; meta: FetchReview["meta"] }) => void }) { export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (edits: { sections: string[]; meta: FetchReview["meta"] }) => void }) {
const [sections, setSections] = useState(data.sections); const [sections, setSections] = useState(data.sections);
@ -106,18 +146,11 @@ export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (ed
캐스트 · {meta.cast.join(", ")} 캐스트 · {meta.cast.join(", ")}
</p> </p>
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}>영상에 넣을 상세페이지 부분</p> <SectionPicker sections={sections}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}> onToggle={(id) => {
{sections.map((s) => ( const n = sections.map((x) => x.id === id ? { ...x, selected: !x.selected } : x);
<Chip key={s.id} on={s.selected} locked={s.required} setSections(n); emit(n);
onClick={() => { const n = sections.map((x) => x.id === s.id ? { ...x, selected: !x.selected } : x); setSections(n); emit(n); }}> }} />
{s.label}
</Chip>
))}
</div>
<p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", display: "flex", alignItems: "center", gap: 6 }}>
<Lock size={11} /> 캐스팅 스케줄은 항상 포함됩니다.
</p>
{data.poster_width <= 800 && ( {data.poster_width <= 800 && (
<Warn>포스터가 {data.poster_width}px로 작습니다. 다음 단계에서 화질을 2배 보정합니다(2크레딧). 원본 파일이 있으면 무빙포스터 경로에서 직접 올리는 편이 더 선명합니다.</Warn> <Warn>포스터가 {data.poster_width}px로 작습니다. 다음 단계에서 화질을 2배 보정합니다(2크레딧). 원본 파일이 있으면 무빙포스터 경로에서 직접 올리는 편이 더 선명합니다.</Warn>
)} )}

View File

@ -158,6 +158,23 @@ const PH = (w: number, h: number, text: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="#003538"/><text x="50%" y="50%" fill="#a6ffea" font-size="20" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`, `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="#003538"/><text x="50%" y="50%" fill="#a6ffea" font-size="20" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`,
)}`; )}`;
// 실물이 오는 모양. 심규선 콘서트 상세페이지 실측 19조각이다 — 라벨이 겹치고 대부분 공지다.
// 이 자리에 6개짜리 깔끔한 목을 두었다가 게이트 ①이 같은 글자 열아홉 개만 뱉는 걸 놓쳤다.
const MOCK_SECTIONS: DetailSection[] = ([
["keyvisual", "키비주얼", 1108], ["synopsis", "작품 소개", 313], ["fragment", "조각", 244],
["info", "관람 정보", 270], ["notice", "유의사항", 608], ["info", "관람 정보", 346],
["fragment", "조각", 270], ["notice", "유의사항", 887], ["schedule", "캐스팅 스케줄", 582],
["notice", "유의사항", 1149], ["notice", "유의사항", 968], ["info", "관람 정보", 383],
["discount", "할인 안내", 473], ["fragment", "조각", 285], ["notice", "유의사항", 875],
["notice", "유의사항", 1063], ["fragment", "조각", 152], ["notice", "유의사항", 1068],
["info", "관람 정보", 371],
] as const).map(([tag, label, height], i) => ({
id: `detail_01_s${String(i + 1).padStart(2, "0")}`,
tag, label, height, thumb_url: PH(180, 240, String(i + 1)),
required: tag === "schedule" || undefined,
selected: tag !== "fragment",
}));
export function mockJob(gate: GateKey | "running" | "done" | "failed"): PlayreelJob { export function mockJob(gate: GateKey | "running" | "done" | "failed"): PlayreelJob {
const base: PlayreelJob = { const base: PlayreelJob = {
id: "mock", kind: "playreel", name: "뮤지컬 〈겨울왕국〉", status: "awaiting_review", stage: null, id: "mock", kind: "playreel", name: "뮤지컬 〈겨울왕국〉", status: "awaiting_review", stage: null,
@ -178,14 +195,7 @@ export function mockJob(gate: GateKey | "running" | "done" | "failed"): Playreel
return { ...base, gate, stages: stages(2), review: { gate, data: { return { ...base, gate, stages: stages(2), review: { gate, data: {
poster_url: PH(300, 420, "포스터 750px"), poster_width: 750, poster_url: PH(300, 420, "포스터 750px"), poster_width: 750,
meta: { title: "뮤지컬 〈겨울왕국〉", date_text: "2026.11.25 ~ 2027.03.01", place: "샤롯데씨어터", cast: ["박혜나", "정선아", "이지혜"], genre: "뮤지컬" }, meta: { title: "뮤지컬 〈겨울왕국〉", date_text: "2026.11.25 ~ 2027.03.01", place: "샤롯데씨어터", cast: ["박혜나", "정선아", "이지혜"], genre: "뮤지컬" },
sections: [ sections: MOCK_SECTIONS,
{ id: "s1", tag: "story", label: "작품 소개", thumb_url: PH(160, 90, "소개"), height: 1800, selected: true },
{ id: "s2", tag: "awards", label: "수상·세계관", thumb_url: PH(160, 90, "수상"), height: 900, selected: true },
{ id: "s3", tag: "cast", label: "캐스트", thumb_url: PH(160, 90, "캐스트"), height: 1400, selected: true },
{ id: "s4", tag: "schedule", label: "캐스팅 스케줄", thumb_url: PH(160, 90, "스케줄"), height: 2200, required: true, selected: true },
{ id: "s5", tag: "discount", label: "할인 안내", thumb_url: PH(160, 90, "할인"), height: 700, selected: true },
{ id: "s6", tag: "notice", label: "유의사항", thumb_url: PH(160, 90, "유의"), height: 1200, selected: false },
],
} } }; } } };
case "analysis_confirm": case "analysis_confirm":
return { ...base, gate, stages: stages(4), credits_used: 2, review: { gate, data: { return { ...base, gate, stages: stages(4), credits_used: 2, review: { gate, data: {