219 lines
9.8 KiB
Python
219 lines
9.8 KiB
Python
"""
|
|
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
|