143 lines
6.0 KiB
Python
143 lines
6.0 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 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) -> PosterAliveTask:
|
|
# poster_url이 NOT NULL이라 blob에 올린 뒤에야 행을 넣을 수 있다.
|
|
# 그 경로에 id가 필요하므로 여기서 미리 만든다
|
|
task = PosterAliveTask(id=new_task_id(), name=name, skip_review=skip_review,
|
|
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()
|
|
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)
|
|
task.thumbnail_url = await store_bytes(PIPELINE, task.id, "thumbnail.jpg",
|
|
result.thumbnail)
|
|
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()
|