""" Creative Assets Background Tasks 영상 생성 전 사전 준비(이미지 배정 + 자막 생성)를 수행하는 백그라운드 태스크를 정의합니다. """ from sqlalchemy import select from app.database.session import BackgroundSessionLocal from app.home.models import Project, MarketingIntel from app.utils.subtitles import SubtitleContentsGenerator from app.utils.creatomate import CreatomateService from app.utils.logger import get_logger from app.video.services.video import get_image_tags_by_task_id from app.utils.prompts.schemas.image import NARRATIVE_PHASE_LABEL # 로거 설정 logger = get_logger("video") async def generate_subtitle_background( orientation: str, task_id: str, max_retries: int = 3, ) -> None: """자막 + 이미지 배정을 사전에 수행하는 백그라운드 태스크. 내부적으로 `generate_creative_assets_background`를 호출한다. """ await generate_creative_assets_background( orientation=orientation, task_id=task_id, max_retries=max_retries, ) async def generate_creative_assets_background( orientation: str, task_id: str, max_retries: int = 3, ) -> None: """이미지 배정 + 자막 생성을 사전에 수행하는 백그라운드 태스크. 실행 순서: 1. 템플릿 조회 (읽기 전용, 렌더 없음) 2. 이미지 매칭 — get_image_tags_by_task_id → template_matching_taged_image → MarketingIntel.image_match 저장 3. 자막 생성 — 배정된 이미지 컨텍스트(슬롯별 공간/단계 정보)를 pitching 리스트에 포함 → MarketingIntel.subtitle 저장 """ logger.info(f"[generate_creative_assets_background] START - task_id: {task_id}, orientation: {orientation}") for attempt in range(1, max_retries + 1): try: creatomate_service = CreatomateService(orientation=orientation) template = await creatomate_service.get_one_template_data(creatomate_service.template_id) # ── 프로젝트 / 마케팅 인텔 로드 ────────────────────────────────── async with BackgroundSessionLocal() as session: project_result = await session.execute( select(Project) .where(Project.task_id == task_id) .order_by(Project.created_at.desc()) .limit(1) ) project = project_result.scalar_one_or_none() marketing_result = await session.execute( select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) ) marketing_intelligence = marketing_result.scalar_one_or_none() store_address = project.detail_region_info customer_name = project.store_name language = project.language or "Korean" logger.info( f"[generate_creative_assets_background] customer: {customer_name}, " f"address: {store_address}, language: {language} - task_id: {task_id}" ) # ── Step 1: 이미지 매칭 ───────────────────────────────────────── # marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되었으므로 재필터링하지 않는다. taged_image_list = await get_image_tags_by_task_id(task_id) min_image_num = creatomate_service.counting_component( template=template, target_template_type="image", ) duplicate = len(taged_image_list) < min_image_num logger.info( f"[generate_creative_assets_background] image matching — " f"pool={len(taged_image_list)}, slots={min_image_num}, " f"duplicate={duplicate} - task_id: {task_id}" ) image_modifications, assigned_image_tags = creatomate_service.template_matching_taged_image( template=template, taged_image_list=taged_image_list, music_url="", # 음악 URL은 영상 생성 시점에 결정 — 사전 단계에서는 빈 값 address=store_address, duplicate=duplicate, ) # audio-music은 영상 생성 시점에 덮어씌워지므로 image_match에서 제거 image_match = {k: v for k, v in image_modifications.items() if k != "audio-music"} logger.info(f"[generate_creative_assets_background] image_match: {list(image_match.keys())} - task_id: {task_id}") # image_match 저장 async with BackgroundSessionLocal() as session: marketing_result = await session.execute( select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) ) mi = marketing_result.scalar_one_or_none() mi.image_match = image_match await session.commit() logger.info(f"[generate_creative_assets_background] image_match saved - task_id: {task_id}") # ── Step 2: 자막 생성 (이미지 컨텍스트 포함) ────────────────────── # 슬롯별 배정된 이미지의 공간/내러티브 정보를 pitching 레이블에 포함하여 # LLM이 "이 자막이 나올 때 화면에 보이는 이미지"를 인지하고 자막을 작성할 수 있게 함. pitchings_raw = creatomate_service.extract_text_format_from_template(template) pitchings_with_context = _build_pitching_list_with_image_context( pitching_label_list=pitchings_raw, assigned_image_tags=assigned_image_tags, ) subtitle_generator = SubtitleContentsGenerator() generated_subtitles = await subtitle_generator.generate_subtitle_contents( marketing_intelligence=marketing_intelligence.intel_result, pitching_label_list=pitchings_with_context, customer_name=customer_name, detail_region_info=store_address, language=language, industry=project.industry, ) pitching_output_list = generated_subtitles.pitching_results subtitle_modifications = { pitching_output.pitching_tag: pitching_output.pitching_data for pitching_output in pitching_output_list } logger.info(f"[generate_creative_assets_background] subtitle_modifications: {subtitle_modifications}") # subtitle 저장 async with BackgroundSessionLocal() as session: marketing_result = await session.execute( select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) ) mi = marketing_result.scalar_one_or_none() mi.subtitle = subtitle_modifications await session.commit() logger.info( f"[generate_creative_assets_background] DONE - task_id: {task_id} " f"(attempt {attempt}/{max_retries})" ) return except Exception as e: logger.error( f"[generate_creative_assets_background] FAILED (attempt {attempt}/{max_retries}) " f"- task_id: {task_id}, error: {e}", exc_info=True, ) if attempt < max_retries: logger.info( f"[generate_creative_assets_background] 재시도 중... " f"({attempt + 1}/{max_retries}) - task_id: {task_id}" ) logger.error(f"[generate_creative_assets_background] 모든 재시도 실패 - task_id: {task_id}") def _build_pitching_list_with_image_context( pitching_label_list: list[str], assigned_image_tags: dict[str, dict], ) -> list[str]: """pitching 레이블 리스트에 배정 이미지 컨텍스트를 포함하여 반환합니다. 슬롯명(`bedroom-furniture-tight_crop-slow_zoom_in-welcome`)의 5번째 토큰과 pitching 레이어명(`subtitle-welcome-emotion_cue-empathic-001`)의 2번째 토큰이 동일한 narrative_phase이므로 시간 겹침 계산 없이 phase로 직접 매칭합니다. 출력 예시: "subtitle-welcome-emotion_cue-empathic-001 | 화면: lobby / furniture / 진입(welcome) 단계" """ # narrative_phase → 해당 phase에 배정된 image_tag 매핑 구성 # 슬롯명 형식: {space_type}-{subject}-{camera}-{motion}-{narrative_phase} phase_to_tag: dict[str, dict] = {} for slot_name, tag in assigned_image_tags.items(): tokens = slot_name.split("-") if len(tokens) >= 5: phase = tokens[4] # 같은 phase에 슬롯이 여러 개면 첫 번째만 사용 if phase not in phase_to_tag: phase_to_tag[phase] = tag if not phase_to_tag: return pitching_label_list result = [] for label in pitching_label_list: # pitching 레이어명 형식: {track_role}-{narrative_phase}-{content_type}-{tone}-{pair_id} tokens = label.split("-") context_str = "" if len(tokens) >= 2: phase = tokens[1] tag = phase_to_tag.get(phase) if tag: space_types = tag.get("space_type", []) subjects = tag.get("subject", []) space_str = "/".join(space_types[:2]) if space_types else "?" subject_str = subjects[0] if subjects else "?" phase_str = NARRATIVE_PHASE_LABEL.get(phase, phase) context_str = f" | 화면: {space_str} / {subject_str} / {phase_str} 단계" result.append(label + context_str) return result