"""포스터 한 장 → 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() # 올릴 때 이름을 안 줬으면 포스터에서 읽은 행사명이 이름이 된다 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()