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:
86
client/src/components/sidebar/AnnotationPanel.tsx
Normal file
86
client/src/components/sidebar/AnnotationPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
client/src/components/sidebar/CaptureList.tsx
Normal file
62
client/src/components/sidebar/CaptureList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
client/src/components/sidebar/VideoList.tsx
Normal file
46
client/src/components/sidebar/VideoList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user