1e2c004dd4
plan: ~/.claude/plans/luminous-sprouting-hamster.md §3
스키마:
- migrations/147_audio_segments_table.sql: audio_segments (STT 타임스탬프
세그먼트)
- migrations/148_audio_segments_idx.sql: (document_id, start_s) idx
- migrations/149_document_media_cols.sql: documents.thumbnail_path +
needs_conversion
- migrations/150_queue_stage_stt.sql: process_stage += 'stt'
- migrations/151_queue_stage_thumbnail.sql: process_stage += 'thumbnail'
- app/models/audio_segment.py, document.py (thumbnail_path/needs_conversion)
서비스:
- services/stt/{Dockerfile, requirements.txt, server.py} — faster-whisper
large-v3 GPU 컨테이너. /transcribe (filePath/langs/beamSize) +
/health + /ready (cuda device_count + model_loaded). NFC/NFD 경로
resolver (OCR 교훈).
- docker-compose.yml: stt-service 추가 (GPU 1 예약, :3300, NAS ro mount,
stt_models volume, start_period 300s), fastapi env 에 STT_ENDPOINT.
파이프라인 (의존 §1 category):
- app/workers/stt_worker.py 신규: stage='stt' pickup → STT_ENDPOINT 호출 →
extracted_text + audio_segments 저장. Timeout 30분.
- app/workers/thumbnail_worker.py 신규: ffmpeg 50% 지점 1장 →
PKM/Videos/.thumbs/{id}.jpg + thumbnail_path 세팅.
needs_conversion=true 는 skip.
- app/workers/file_watcher.py 확장: PKM/{Inbox, Recordings, Videos}
스캔. 확장자→category, audio→stage=stt, video .mp4/.webm→
stage=thumbnail, video .mov/.mkv/.avi→needs_conversion=true + stage
없음. settings.roon_library_path prefix skip.
- app/workers/queue_consumer.py 확장: stt + thumbnail workers 등록,
BATCH_SIZE(stt=1, thumbnail=3), next_stages 에 stt→[classify] 추가
(audio 는 extract 건너뜀).
- app/Dockerfile: ffmpeg 추가 (썸네일 subprocess 용).
API (의존 §1):
- /api/audio/{id}/segments — AudioSegment ORDER BY start_s
- /api/video/{id}/thumbnail — thumbnail_path FileResponse (쿼리 토큰)
- /api/documents/{id}/file: media_types 에 audio/video mime 포함 (§2
커밋에 이미 포함). Starlette FileResponse 가 Range 자동.
- upload_document: .mov/.mkv/.avi 웹 업로드 거부 (error_code
unsupported_codec). NAS 드롭은 file_watcher 가 quarantine 수용.
프론트:
- AudioPlayer.svelte: HTML5 audio + 전사 세그먼트 sticky 패널 + 줄
클릭 seek. activeIdx 하이라이트.
- VideoPlayer.svelte: HTML5 video direct play + needs_conversion 안내
카드. poster 는 thumbnail endpoint.
- /audio (목록 grid) + /audio/[id] (플레이어)
- /video (썸네일 grid + 변환 필요 배지) + /video/[id] (플레이어)
- Sidebar.svelte: Mic/Film 아이콘 + audio/video 네비 활성, count
배지 (§2 /stats/category-counts 재사용).
설정:
- app/core/config.py: stt_endpoint + roon_library_path.
DoD 배포 후 smoke: /ready cuda:true, 회의 mp3 transcribe, audio
extract 없이 classify 진행(queue 회귀), /audio 재생, .mp4 재생,
.mov 웹 400, .mov NAS quarantine, Sidebar 네비 + count.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
4.3 KiB
Python
130 lines
4.3 KiB
Python
"""비디오 썸네일 생성 워커 — 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:
|
|
return
|
|
|
|
doc.thumbnail_path = str(output)
|
|
doc.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
logger.info(f"[thumbnail] id={document_id} → {output}")
|