playreel/backend/pipelines/poster_alive.py
2026-09-15 16:00:54 +09:00

151 lines
6.4 KiB
Python

"""포스터 한 장 → 8초 숏폼
한 함수가 한 단계를 맡아 앞 단계 컬럼에서 입력을 되읽고 순수 서비스를 부른 뒤
산출물 컬럼과 state를 갱신하고 커밋함
이미지·오디오·영상은 blob에 올린 URL만 컬럼에 남음
"""
import asyncio
import io
from PIL import Image
from sqlalchemy.ext.asyncio import AsyncSession
from answers.narration_answer import BgmStyle
from models.detect import Regions
from models.motion import MotionPlan
from models.pipeline_state import PosterAliveState, initial_timings
from models.tts import NarrationTimeline
from pipelines.artifact import load_image, load_optional, store_bytes, store_image
from services.bgm import generate_bgm
from services.detect import detect
from services.i2v import MIN_LONG_EDGE_PX, animate
from services.motion import plan_motion
from services.narration import generate_narration
from services.render import render
from services.tts import synthesize
from services.upscale_poster import as_png_path
from tables.task import PosterAliveTask, new_task_id
from tables.user import UNASSIGNED_USER_ID
from utils import blob
from utils.image import sniff_extension
PIPELINE = "poster_alive"
async def create_task(session: AsyncSession, name: str, poster: bytes, *,
skip_review: bool = False,
user_id: str = UNASSIGNED_USER_ID) -> PosterAliveTask:
# poster_url이 NOT NULL이라 blob에 올린 뒤에야 행을 넣을 수 있다.
# 그 경로에 id가 필요하므로 여기서 미리 만든다
task = PosterAliveTask(id=new_task_id(), name=name, skip_review=skip_review,
user_id=user_id,
stage_timings=initial_timings(PosterAliveState))
with Image.open(io.BytesIO(poster)) as image:
task.poster_width, task.poster_height = image.size
# i2v만 거절하는 하드 게이트라 여기서는 기록만 함
task.is_low_resolution = max(image.size) < MIN_LONG_EDGE_PX
task.poster_url = await store_bytes(PIPELINE, task.id,
f"poster.{sniff_extension(poster)}", poster)
session.add(task)
await session.commit()
return task
async def run_detect(session: AsyncSession, task: PosterAliveTask) -> None:
result = await detect(await load_image(task.poster_url))
task.detect_regions = result.data.model_dump()
task.detect_grid_url = await store_image(PIPELINE, task.id, "detect_grid.jpg", result.grid)
task.detect_check_url = await store_image(PIPELINE, task.id, "detect_check.jpg",
result.check)
task.state = PosterAliveState.NARRATION_TEXT
await session.commit()
async def run_narration_text(session: AsyncSession, task: PosterAliveTask) -> None:
regions = Regions.model_validate(task.detect_regions)
answer = await generate_narration(await load_image(task.poster_url), regions)
task.narration_lines = answer.narration
task.narration_voice = answer.voice
task.bgm_style = answer.bgm_style.model_dump()
task.poster_metadata = answer.metadata.model_dump()
# 올릴 때 이름을 안 줬으면 포스터에서 읽은 행사명이 이름이 된다
if not task.name:
task.name = answer.metadata.event_name
task.state = PosterAliveState.MOTION
await session.commit()
async def run_motion(session: AsyncSession, task: PosterAliveTask) -> None:
plan = await plan_motion(await load_image(task.poster_url))
task.motion_plan = plan.model_dump()
task.state = PosterAliveState.TTS
await session.commit()
async def run_tts(session: AsyncSession, task: PosterAliveTask) -> None:
audio = await synthesize(task.narration_lines, task.narration_voice)
task.narration_timeline = audio.timeline.model_dump()
task.narration_audio_url = await store_bytes(PIPELINE, task.id, "narration.mp3", audio.mp3)
task.state = PosterAliveState.BGM
await session.commit()
async def run_bgm(session: AsyncSession, task: PosterAliveTask) -> None:
timeline = NarrationTimeline.model_validate(task.narration_timeline)
audio = await generate_bgm(BgmStyle.model_validate(task.bgm_style), timeline.total,
title=task.name)
task.bgm_audio_url = await store_bytes(PIPELINE, task.id, "bgm.mp3", audio.mp3)
task.bgm_task_id = audio.task_id
task.bgm_target_seconds = audio.seconds
task.bgm_duration = audio.duration
task.state = PosterAliveState.I2V
await session.commit()
async def run_i2v(session: AsyncSession, task: PosterAliveTask) -> None:
plan = MotionPlan.model_validate(task.motion_plan)
regions = Regions.model_validate(task.detect_regions)
poster = await blob.download_bytes(task.poster_url)
# CLI가 파일 경로만 받고, 팔레트 이미지를 그대로 올리면 잡이 실패로 돌아옴
with as_png_path(poster) as poster_path:
result = await animate(plan.prompt, poster_path, regions)
task.clip_url = await store_bytes(PIPELINE, task.id, "clip.mp4", result.clip)
task.clip_model = result.model
task.clip_credits = result.credits
task.title_gate = result.title_gate.model_dump()
task.credits_used += result.credits
task.state = PosterAliveState.RENDER
await session.commit()
async def run_render(session: AsyncSession, task: PosterAliveTask) -> None:
timeline = (NarrationTimeline.model_validate(task.narration_timeline)
if task.narration_timeline else None)
clip = await blob.download_bytes(task.clip_url)
poster = await load_image(task.poster_url)
narration = await load_optional(task.narration_audio_url)
bgm = await load_optional(task.bgm_audio_url)
# 프레임 합성과 인코딩이 수 분간 CPU를 잡아 이벤트 루프를 막음
result = await asyncio.to_thread(render, clip, poster, narration=narration, bgm=bgm,
timeline=timeline)
task.video_url = await store_bytes(PIPELINE, task.id, "final.mp4", result.video,
download_as=f"{task.name}.mp4")
task.thumbnail_url = await store_bytes(PIPELINE, task.id, "thumbnail.jpg",
result.thumbnail,
download_as=f"{task.name}.jpg")
task.video_duration = result.duration
task.video_frames = result.frames
task.video_width = result.width
task.video_height = result.height
task.state = PosterAliveState.COMPLETED
await session.commit()