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>
96 lines
2.7 KiB
Svelte
96 lines
2.7 KiB
Svelte
<script>
|
|
// 오디오 플레이어 + 전사 세그먼트 sticky 패널.
|
|
// 줄 클릭 시 audio.currentTime = seg.start 로 점프.
|
|
// 재생 중인 세그먼트는 하이라이트.
|
|
|
|
import { api, getAccessToken } from '$lib/api';
|
|
|
|
let { docId } = $props();
|
|
|
|
let audioEl = $state(null);
|
|
let segments = $state([]);
|
|
let currentTime = $state(0);
|
|
let loading = $state(true);
|
|
let error = $state(null);
|
|
|
|
let token = $derived(getAccessToken());
|
|
let fileSrc = $derived(`/api/documents/${docId}/file?token=${token}`);
|
|
|
|
$effect(() => {
|
|
if (docId != null) loadSegments(docId);
|
|
});
|
|
|
|
async function loadSegments(id) {
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
const resp = await api(`/audio/${id}/segments`);
|
|
segments = resp?.segments ?? [];
|
|
} catch (err) {
|
|
segments = [];
|
|
error = '전사 세그먼트를 불러오지 못했습니다';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function seekTo(start) {
|
|
if (!audioEl) return;
|
|
audioEl.currentTime = Math.max(0, start);
|
|
audioEl.play().catch(() => {});
|
|
}
|
|
|
|
function handleTimeUpdate() {
|
|
if (audioEl) currentTime = audioEl.currentTime;
|
|
}
|
|
|
|
function formatTime(sec) {
|
|
if (sec == null || isNaN(sec)) return '00:00';
|
|
const m = Math.floor(sec / 60);
|
|
const s = Math.floor(sec % 60);
|
|
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
let activeIdx = $derived(
|
|
segments.findIndex((s) => currentTime >= s.start && currentTime < s.end)
|
|
);
|
|
</script>
|
|
|
|
<div class="flex flex-col h-full">
|
|
<div class="sticky top-0 z-10 bg-surface border-b border-default p-3">
|
|
<audio
|
|
bind:this={audioEl}
|
|
src={fileSrc}
|
|
controls
|
|
preload="metadata"
|
|
class="w-full"
|
|
ontimeupdate={handleTimeUpdate}
|
|
>
|
|
<track kind="captions" />
|
|
</audio>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto p-4 space-y-1">
|
|
{#if loading}
|
|
<p class="text-muted text-sm">전사 불러오는 중…</p>
|
|
{:else if error}
|
|
<p class="text-error text-sm">{error}</p>
|
|
{:else if segments.length === 0}
|
|
<p class="text-muted text-sm">전사 결과가 아직 없습니다 (STT 처리 대기 중일 수 있음).</p>
|
|
{:else}
|
|
{#each segments as seg, i (seg.start)}
|
|
<button
|
|
type="button"
|
|
class="w-full text-left flex gap-3 px-2 py-1 rounded transition-colors {i === activeIdx ? 'bg-accent-muted' : 'hover:bg-surface-hover'}"
|
|
onclick={() => seekTo(seg.start)}
|
|
>
|
|
<span class="text-xs tabular-nums text-muted min-w-[3.5rem] mt-0.5">
|
|
{formatTime(seg.start)}
|
|
</span>
|
|
<span class="text-sm leading-relaxed">{seg.text}</span>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|