"""비디오 썸네일 생성 워커 — ffmpeg subprocess 로 50% 지점 1장 추출. PKM/Videos/.thumbs/{doc_id}.jpg 에 저장 후 documents.thumbnail_path 업데이트. quarantine 상태(needs_conversion=true)인 파일은 건너뜀. queue_consumer 와의 배선(stage 매핑)은 §1 category 분기와 묶여 있어 본 모듈은 유틸 + process() 진입점만 제공. queue_consumer 측 wiring 은 §1 의존 파트에서. """ import subprocess import unicodedata from datetime import datetime, timezone from pathlib import Path from sqlalchemy.ext.asyncio import AsyncSession from core.config import settings from core.utils import setup_logger logger = setup_logger("thumbnail_worker") THUMBS_DIR_NAME = "PKM/Videos/.thumbs" FFMPEG_TIMEOUT = 30 def _resolve_path(file_path: str) -> Path | None: """NFC(DB) vs NFD(NFS) 한글 경로 차이 흡수. OCR/STT 서비스와 동일 패턴.""" candidates = [ file_path, unicodedata.normalize("NFD", file_path), unicodedata.normalize("NFC", file_path), ] for c in candidates: p = Path(c) if p.exists(): return p parent = Path(file_path).parent if parent.exists(): target = unicodedata.normalize("NFC", Path(file_path).name) for child in parent.iterdir(): if unicodedata.normalize("NFC", child.name) == target: return child return None def _probe_duration_seconds(path: Path) -> float | None: """ffprobe 로 재생 길이 조회. 실패 시 None.""" try: result = subprocess.run( [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(path), ], capture_output=True, text=True, timeout=FFMPEG_TIMEOUT, ) if result.returncode != 0: return None return float(result.stdout.strip()) except (subprocess.SubprocessError, ValueError): return None def _extract_thumbnail(source: Path, output: Path, seek_seconds: float) -> bool: """ffmpeg 로 seek_seconds 지점 1프레임을 jpg 로 추출. 성공 시 True.""" output.parent.mkdir(parents=True, exist_ok=True) try: result = subprocess.run( [ "ffmpeg", "-y", "-ss", f"{seek_seconds:.2f}", "-i", str(source), "-vframes", "1", "-vf", "scale='min(640,iw)':-1", "-q:v", "3", str(output), ], capture_output=True, text=True, timeout=FFMPEG_TIMEOUT, ) if result.returncode != 0: logger.error(f"[thumbnail] ffmpeg 실패: {source.name} — {result.stderr[-400:]}") return False return output.exists() and output.stat().st_size > 0 except subprocess.SubprocessError as e: logger.error(f"[thumbnail] subprocess 오류: {source.name} — {e}") return False async def process(document_id: int, session: AsyncSession) -> None: """영상 문서 썸네일 생성 진입점 (queue_consumer 에서 호출 예정). needs_conversion=True 는 skip. 파일 위치가 없으면 NFC/NFD resolver 로 보정. """ from models.document import Document doc = await session.get(Document, document_id) if not doc: logger.error(f"[thumbnail] document_id={document_id} 없음") return if getattr(doc, "needs_conversion", False): logger.info(f"[thumbnail] id={document_id} needs_conversion=true → skip") return if not doc.file_path: logger.warning(f"[thumbnail] id={document_id} file_path 없음") return raw = str(Path(settings.nas_mount_path) / doc.file_path) source = _resolve_path(raw) if source is None: logger.error(f"[thumbnail] 원본 없음: {raw}") return duration = _probe_duration_seconds(source) seek = (duration * 0.5) if duration and duration > 0 else 1.0 thumbs_dir = Path(settings.nas_mount_path) / THUMBS_DIR_NAME output = thumbs_dir / f"{document_id}.jpg" ok = _extract_thumbnail(source, output, seek) if not ok: # 썸네일 추출 실패(ffmpeg)는 삼키지 않고 raise (R3) — queue_consumer 가 attempts # 소진까지 재시도 후 status=failed 로 가시화. silent return 이면 큐가 completed 로 # 확정 + 썸네일 영구 누락 + 재시도/추적 0 (silent skip). 손상 영상이면 failed 로 안착. raise RuntimeError( f"thumbnail 추출 실패: document_id={document_id} source={source}" ) doc.thumbnail_path = str(output) doc.updated_at = datetime.now(timezone.utc) await session.commit() logger.info(f"[thumbnail] id={document_id} → {output}")