"""두 파이프라인의 잡 테이블 단계별 산출물을 컬럼으로 들고 있음 이미지·오디오·영상은 blob에 올린 뒤 URL만 저장 """ import uuid from datetime import datetime from sqlalchemy import JSON, Boolean, DateTime, Enum, Float, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column from models.pipeline_state import PlayreelState, PosterAliveState from utils.database import Base URL_LENGTH = 512 TASK_ID_LENGTH = 36 def new_task_id() -> str: return str(uuid.uuid4()) def state_column(enum_type): return Enum(enum_type, values_callable=lambda e: [member.value for member in e]) class TaskBase(Base): """두 테이블이 같이 쓰는 실행 메타 state는 진행도, status는 그 시점의 실행 상태로 축이 다름 """ __abstract__ = True id: Mapped[str] = mapped_column(String(TASK_ID_LENGTH), primary_key=True, default=new_task_id) name: Mapped[str] = mapped_column(String(255), default="") status: Mapped[str] = mapped_column(String(32), default="queued", index=True) # 스테퍼가 그리는 단계별 시각 # {stage: {"status": ..., "started": ..., "ended": ...}} stage_timings: Mapped[dict] = mapped_column(JSON, default=dict) error_stage: Mapped[str | None] = mapped_column(String(32)) error_detail: Mapped[str | None] = mapped_column(Text) credits_used: Mapped[float] = mapped_column(Float, default=0.0) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) class PosterAliveTask(TaskBase): """포스터 한 장 → 8초 숏폼""" __tablename__ = "poster_alive_task" state: Mapped[PosterAliveState] = mapped_column( state_column(PosterAliveState), default=PosterAliveState.DETECT, index=True) # 검수 게이트를 건너뛰고 끝까지 돌린다 skip_review: Mapped[bool] = mapped_column(Boolean, default=False) # 입력 poster_url: Mapped[str] = mapped_column(String(URL_LENGTH)) poster_width: Mapped[int | None] = mapped_column(Integer) poster_height: Mapped[int | None] = mapped_column(Integer) # i2v가 명시적으로 거절하는 하드 게이트 is_low_resolution: Mapped[bool] = mapped_column(Boolean, default=False) # ① detect detect_regions: Mapped[dict | None] = mapped_column(JSON) # Regions detect_grid_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) detect_check_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) # ② narration_text narration_lines: Mapped[list | None] = mapped_column(JSON) # 3문장 narration_voice: Mapped[str | None] = mapped_column(String(32)) bgm_style: Mapped[dict | None] = mapped_column(JSON) # BgmStyle poster_metadata: Mapped[dict | None] = mapped_column(JSON) # NarrationMetadata # ③ motion motion_plan: Mapped[dict | None] = mapped_column(JSON) # MotionPlan # ④ tts narration_timeline: Mapped[dict | None] = mapped_column(JSON) # NarrationTimeline narration_audio_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) # ⑤ bgm bgm_audio_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) bgm_task_id: Mapped[str | None] = mapped_column(String(64)) bgm_target_seconds: Mapped[int | None] = mapped_column(Integer) bgm_duration: Mapped[float | None] = mapped_column(Float) # ⑥ i2v clip_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) clip_model: Mapped[str | None] = mapped_column(String(64)) clip_credits: Mapped[float | None] = mapped_column(Float) title_gate: Mapped[dict | None] = mapped_column(JSON) # TitleGateVerdict # ⑦ render video_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) thumbnail_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) video_duration: Mapped[float | None] = mapped_column(Float) video_frames: Mapped[int | None] = mapped_column(Integer) video_width: Mapped[int | None] = mapped_column(Integer) video_height: Mapped[int | None] = mapped_column(Integer) class PlayreelTask(TaskBase): """공연 상품페이지 URL → 30초 롱컷""" __tablename__ = "playreel_task" state: Mapped[PlayreelState] = mapped_column( state_column(PlayreelState), default=PlayreelState.FETCH, index=True) # 검수 대기 중인 게이트. 승인하면 비워짐 gate: Mapped[str | None] = mapped_column(String(32)) # 최종 승인마다 올라감. 재작업은 새 버전이 됨 version: Mapped[int] = mapped_column(Integer, default=0) # 입력 source_url: Mapped[str] = mapped_column(String(URL_LENGTH)) goods_id: Mapped[str] = mapped_column(String(32), index=True) slug: Mapped[str] = mapped_column(String(64)) # ① fetch nol_meta: Mapped[dict | None] = mapped_column(JSON) # NolMeta poster_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) # 750px이면 검수 화면이 저해상 경고를 띄운다 poster_width: Mapped[int | None] = mapped_column(Integer) poster_height: Mapped[int | None] = mapped_column(Integer) detail_image_urls: Mapped[list | None] = mapped_column(JSON) fetch_failed_urls: Mapped[list | None] = mapped_column(JSON) # ② split # [{name, source, index, y0, y1, tag, url, selected}] detail_sections: Mapped[list | None] = mapped_column(JSON) section_sheet_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) # ③ upscale upscaled_poster_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) upscale_credits: Mapped[int | None] = mapped_column(Integer) upscale_variant: Mapped[str | None] = mapped_column(String(64)) # ④ analyze analysis_grid_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) movable_elements: Mapped[list | None] = mapped_column(JSON) # [MotionChoice] fixed_layers: Mapped[list | None] = mapped_column(JSON) # [FixedLayer] i2v_model: Mapped[str | None] = mapped_column(String(64)) has_ip_risk: Mapped[bool | None] = mapped_column(Boolean) has_qr: Mapped[bool | None] = mapped_column(Boolean) # ⑤ motion motion_plan: Mapped[dict | None] = mapped_column(JSON) # MotionPlan # ⑥ narration cast_extraction: Mapped[dict | None] = mapped_column(JSON) # CastExtraction narration_lines: Mapped[list | None] = mapped_column(JSON) # [{slot, text}] # ⑦ tts narration_timeline: Mapped[dict | None] = mapped_column(JSON) # NarrationTimeline narration_audio_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) narration_voice_id: Mapped[str | None] = mapped_column(String(64)) # ⑧ bgm bgm_audio_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) bgm_task_id: Mapped[str | None] = mapped_column(String(64)) bgm_target_seconds: Mapped[int | None] = mapped_column(Integer) bgm_duration: Mapped[float | None] = mapped_column(Float) # ⑨ i2v clip_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) clip_frames_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) clip_model: Mapped[str | None] = mapped_column(String(64)) clip_credits: Mapped[float | None] = mapped_column(Float) title_gate: Mapped[dict | None] = mapped_column(JSON) # TitleGateVerdict # ⑩ hybrid layer_config: Mapped[dict | None] = mapped_column(JSON) # LayerConfig hybrid_clip_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) hybrid_proof_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) hybrid_frames: Mapped[int | None] = mapped_column(Integer) hybrid_duration: Mapped[float | None] = mapped_column(Float) # ⑪ compose video_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) scene_plan: Mapped[dict | None] = mapped_column(JSON) # LongcutPlan compose_proof_url: Mapped[str | None] = mapped_column(String(URL_LENGTH)) video_duration: Mapped[float | None] = mapped_column(Float) video_frames: Mapped[int | None] = mapped_column(Integer) # ⑫ review review_checks: Mapped[list | None] = mapped_column(JSON) # [{key, label, ok}]