feat: StationOverlay 렌더링 최적화 및 스무딩 적용 close #1

- 텍스트(측점/POI) 전 프레임 사전 계산 Map (requestIdleCallback 백그라운드)
- 드론 데이터 이동 평균 스무딩 (smoothFrame ±N프레임)
- 30fps→60fps 프레임 간 선형 보간 (performance.now() 기반)
- EMA(지수이동평균) 표시 위치 스무딩 (α=0.01 기본값)
- 글씨 2배 크기, bold, strokeText 테두리, 배경 박스 제거
- 카메라 파라미터 패널에 smooth/EMA α 슬라이더 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-01 15:11:39 +09:00
commit 2aae3d1c0d
89 changed files with 15739 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
import React, { useState } from 'react';
import type { Annotation } from '@abcvideo/shared';
import { secondsToTimecode } from '../../utils/timecode';
interface Props {
annotations: Annotation[];
currentTime: number;
onSeek: (time: number) => void;
onDelete: (id: string) => void;
onExport: (format: string) => void;
}
export default function AnnotationPanel({
annotations,
currentTime,
onSeek,
onDelete,
onExport,
}: Props) {
const [tab, setTab] = useState<'subtitle' | 'memo'>('subtitle');
const filtered = annotations.filter((a) => a.type === tab);
return (
<div className="flex flex-col h-full">
{/* Tabs */}
<div className="flex border-b border-gray-700">
{(['subtitle', 'memo'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-2 text-sm ${
tab === t ? 'text-white border-b-2 border-blue-500' : 'text-gray-400'
}`}
>
{t === 'subtitle' ? '자막' : '메모'}
</button>
))}
</div>
{/* List */}
<div className="flex-1 overflow-y-auto divide-y divide-gray-800">
{filtered.length === 0 && (
<p className="text-gray-500 text-sm text-center py-6">
{tab === 'subtitle' ? '자막이 없습니다' : '메모가 없습니다'}
</p>
)}
{filtered.map((a) => {
const active = currentTime >= a.timeStart && currentTime <= a.timeEnd;
return (
<div
key={a.id}
className={`px-3 py-2 cursor-pointer hover:bg-gray-800 ${
active ? 'bg-gray-800 border-l-2 border-yellow-400' : ''
}`}
onClick={() => onSeek(a.timeStart)}
>
<div className="text-xs text-gray-400 font-mono">
{secondsToTimecode(a.timeStart)} {secondsToTimecode(a.timeEnd)}
</div>
<div className="text-sm text-white mt-0.5 truncate">{a.text}</div>
<button
onClick={(e) => { e.stopPropagation(); onDelete(a.id); }}
className="text-xs text-red-400 hover:text-red-300 mt-1"
>
</button>
</div>
);
})}
</div>
{/* Export buttons */}
<div className="p-2 border-t border-gray-700 flex gap-1 flex-wrap">
{['vtt', 'srt', 'json', 'csv'].map((f) => (
<button
key={f}
onClick={() => onExport(f)}
className="text-xs bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded"
>
{f.toUpperCase()}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { useCaptureStore } from '../../store/captureStore';
import { secondsToTimecode } from '../../utils/timecode';
interface Props {
onSeek: (time: number) => void;
}
export default function CaptureList({ onSeek }: Props) {
const { captures, removeCapture, clearCaptures } = useCaptureStore();
if (captures.length === 0) {
return (
<div className="text-gray-500 text-xs p-4 text-center">
<br />
<span className="text-gray-600">Shift+S로 </span>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-3 py-1">
<span className="text-xs text-gray-400">{captures.length}</span>
<button
onClick={clearCaptures}
className="text-xs text-gray-500 hover:text-red-400 transition-colors"
>
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-1 px-2 pb-2">
{captures.map((cap) => (
<div
key={cap.id}
className="group relative cursor-pointer rounded overflow-hidden border border-gray-700 hover:border-blue-500 transition-colors"
onClick={() => onSeek(cap.time)}
>
<img
src={cap.dataUrl}
alt={cap.filename}
className="w-full h-auto object-cover"
draggable={false}
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/70 px-2 py-0.5 flex items-center justify-between">
<span className="text-xs text-white font-mono">
{secondsToTimecode(cap.time)}
</span>
<button
onClick={(e) => { e.stopPropagation(); removeCapture(cap.id); }}
className="text-xs text-gray-400 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="삭제"
>
</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import React, { useEffect, useState } from 'react';
import { usePlayerStore } from '../../store/playerStore';
interface VideoItem { videoId: string; filename: string; }
interface Props {
onSelect: (videoId: string, filename: string) => void;
}
export default function VideoList({ onSelect }: Props) {
const [videos, setVideos] = useState<VideoItem[]>([]);
const { source } = usePlayerStore();
const activeId = source?.kind === 'server' ? source.videoId : null;
useEffect(() => {
fetch('/api/videos')
.then((r) => r.json())
.then(setVideos)
.catch(() => {});
}, []);
if (videos.length === 0) {
return (
<div className="text-gray-500 text-sm p-4 text-center">
</div>
);
}
return (
<div className="divide-y divide-gray-800">
{videos.map((v) => (
<button
key={v.videoId}
onClick={() => onSelect(v.videoId, v.filename)}
className={`w-full text-left px-4 py-3 hover:bg-gray-800 transition-colors ${
activeId === v.videoId ? 'bg-gray-800 border-l-2 border-blue-500' : ''
}`}
>
<div className="text-sm text-white truncate">{v.filename}</div>
<div className="text-xs text-gray-500 mt-0.5">{v.videoId}</div>
</button>
))}
</div>
);
}