import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/_core/hooks/useAuth"; import { trpc } from "@/lib/trpc"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import { BarChart3, Clock, Copy, Download, FileVideo, Play, PlayCircle, Scissors, Sparkles, Trash2, Video, Zap, } from "lucide-react"; import { useLocation } from "wouter"; type ClipDraft = { id: string; startSec: number; endSec: number; label: string; notes: string; source: "manual" | "suggested"; }; const statusMap: Record = { pending: { label: "待分析", color: "bg-yellow-100 text-yellow-700" }, analyzing: { label: "分析中", color: "bg-blue-100 text-blue-700" }, completed: { label: "已完成", color: "bg-green-100 text-green-700" }, failed: { label: "失败", color: "bg-red-100 text-red-700" }, }; const exerciseTypeMap: Record = { forehand: "正手挥拍", backhand: "反手挥拍", serve: "发球", volley: "截击", footwork: "脚步移动", shadow: "影子挥拍", wall: "墙壁练习", recording: "录制归档", live_analysis: "实时分析", }; function formatSeconds(totalSeconds: number) { const seconds = Math.max(0, Math.floor(totalSeconds)); const minutes = Math.floor(seconds / 60); const rest = seconds % 60; return `${minutes.toString().padStart(2, "0")}:${rest.toString().padStart(2, "0")}`; } function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } function localStorageKey(videoId: number) { return `clip-plan:${videoId}`; } function resolveTimelineDurationSec(analysis: any, durationSec: number) { if (durationSec > 0) return durationSec; if (typeof analysis?.durationSec === "number" && analysis.durationSec > 0) return analysis.durationSec; if (typeof analysis?.durationMs === "number" && analysis.durationMs > 0) return analysis.durationMs / 1000; if (typeof analysis?.framesAnalyzed === "number" && analysis.framesAnalyzed > 0) { return Math.max(5, Math.round((analysis.framesAnalyzed / 30) * 10) / 10); } return 0; } function buildSuggestedClips(analysis: any, durationSec: number) { const timelineDurationSec = resolveTimelineDurationSec(analysis, durationSec); if (!analysis?.keyMoments || !Array.isArray(analysis.keyMoments) || timelineDurationSec <= 0) { return [] as ClipDraft[]; } const framesAnalyzed = Math.max(analysis.framesAnalyzed || 0, 1); return analysis.keyMoments.slice(0, 6).map((moment: any, index: number) => { const centerSec = clamp(((moment.frame || 0) / framesAnalyzed) * timelineDurationSec, 0, timelineDurationSec); const startSec = clamp(centerSec - 1.5, 0, Math.max(0, timelineDurationSec - 0.5)); const endSec = clamp(centerSec + 2.5, startSec + 0.5, timelineDurationSec); return { id: `suggested-${index}-${moment.frame || index}`, startSec, endSec, label: moment.description || `建议片段 ${index + 1}`, notes: moment.type ? `来源于分析事件:${moment.type}` : "来源于分析关键时刻", source: "suggested" as const, }; }); } function downloadJson(filename: string, data: unknown) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } function downloadText(filename: string, data: string) { const blob = new Blob([data], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } function buildClipCueSheet(title: string, clips: ClipDraft[]) { return clips.map((clip, index) => ( `${index + 1}. ${clip.label}\n` + ` 区间: ${formatSeconds(clip.startSec)} - ${formatSeconds(clip.endSec)}\n` + ` 时长: ${formatSeconds(Math.max(0, clip.endSec - clip.startSec))}\n` + ` 来源: ${clip.source === "manual" ? "手动" : "分析建议"}\n` + ` 备注: ${clip.notes || "无"}` )).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${new Date().toLocaleString("zh-CN")}\n`; } export default function Videos() { useAuth(); const { data: videos, isLoading } = trpc.video.list.useQuery(); const { data: analyses } = trpc.analysis.list.useQuery(); const [, setLocation] = useLocation(); const previewRef = useRef(null); const [editorOpen, setEditorOpen] = useState(false); const [selectedVideo, setSelectedVideo] = useState(null); const [videoDurationSec, setVideoDurationSec] = useState(0); const [playbackSec, setPlaybackSec] = useState(0); const [clipRange, setClipRange] = useState<[number, number]>([0, 5]); const [clipLabel, setClipLabel] = useState(""); const [clipNotes, setClipNotes] = useState(""); const [clipDrafts, setClipDrafts] = useState([]); const [activePreviewRange, setActivePreviewRange] = useState<[number, number] | null>(null); const getAnalysis = useCallback((videoId: number) => { return analyses?.find((analysis: any) => analysis.videoId === videoId); }, [analyses]); const activeAnalysis = selectedVideo ? getAnalysis(selectedVideo.id) : null; const timelineDurationSec = useMemo( () => resolveTimelineDurationSec(activeAnalysis, videoDurationSec), [activeAnalysis, videoDurationSec], ); const suggestedClips = useMemo( () => buildSuggestedClips(activeAnalysis, timelineDurationSec), [activeAnalysis, timelineDurationSec], ); useEffect(() => { if (!editorOpen || timelineDurationSec <= 0) return; setClipRange((current) => { const start = clamp(current[0] ?? 0, 0, Math.max(0, timelineDurationSec - 0.5)); const minEnd = clamp(start + 0.5, 0.5, timelineDurationSec); const end = clamp(current[1] ?? Math.min(timelineDurationSec, 5), minEnd, timelineDurationSec); if (start === current[0] && end === current[1]) { return current; } return [start, end]; }); }, [editorOpen, timelineDurationSec]); useEffect(() => { if (!selectedVideo) return; try { const saved = localStorage.getItem(localStorageKey(selectedVideo.id)); if (saved) { const parsed = JSON.parse(saved) as ClipDraft[]; setClipDrafts(parsed); return; } } catch { // Ignore corrupted local clip drafts and fall back to suggested clips. } setClipDrafts(suggestedClips); }, [selectedVideo, suggestedClips]); useEffect(() => { if (!selectedVideo) return; localStorage.setItem(localStorageKey(selectedVideo.id), JSON.stringify(clipDrafts)); }, [clipDrafts, selectedVideo]); useEffect(() => { const video = previewRef.current; if (!video || !activePreviewRange) return; const handleTimeUpdate = () => { if (video.currentTime >= activePreviewRange[1]) { video.currentTime = activePreviewRange[0]; } }; video.addEventListener("timeupdate", handleTimeUpdate); return () => { video.removeEventListener("timeupdate", handleTimeUpdate); }; }, [activePreviewRange]); const openEditor = useCallback((video: any) => { setSelectedVideo(video); setEditorOpen(true); setVideoDurationSec(0); setPlaybackSec(0); setClipLabel(""); setClipNotes(""); setClipRange([0, 5]); setActivePreviewRange(null); }, []); const addClip = useCallback((source: "manual" | "suggested", preset?: ClipDraft) => { const nextStart = preset?.startSec ?? clipRange[0]; const nextEnd = preset?.endSec ?? clipRange[1]; const clip: ClipDraft = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, startSec: nextStart, endSec: nextEnd, label: preset?.label || clipLabel || `片段 ${clipDrafts.length + 1}`, notes: preset?.notes || clipNotes, source, }; setClipDrafts((current) => [...current, clip].sort((a, b) => a.startSec - b.startSec)); setClipLabel(""); setClipNotes(""); toast.success("片段已加入轻剪辑草稿"); }, [clipDrafts.length, clipLabel, clipNotes, clipRange]); const totalClipDurationSec = useMemo( () => clipDrafts.reduce((sum, clip) => sum + Math.max(0, clip.endSec - clip.startSec), 0), [clipDrafts], ); if (isLoading) { return (
{[1, 2, 3].map((index) => )}
); } return (

训练视频库

集中管理录制归档、上传分析和实时分析视频。桌面端已提供轻剪辑工作台,可按建议片段或手动入点/出点生成剪辑草稿。

{(!videos || videos.length === 0) ? (

还没有训练视频

上传训练视频后,这里会自动汇总分析结果,并提供轻剪辑入口。

) : (
{videos.map((video: any) => { const analysis = getAnalysis(video.id); const status = statusMap[video.analysisStatus] || statusMap.pending; return (
{video.url ? (

{video.title}

{status.label} {video.exerciseType ? ( {exerciseTypeMap[video.exerciseType] || video.exerciseType} ) : null} {new Date(video.createdAt).toLocaleDateString("zh-CN")} {((video.fileSize || 0) / 1024 / 1024).toFixed(1)}MB
{video.url ? ( ) : null}
{analysis ? (
{Math.round(analysis.overallScore || 0)}分
{(analysis.shotCount ?? 0) > 0 ? (
{analysis.shotCount} 次击球
) : null} {(analysis.strokeConsistency ?? 0) > 0 ? (
一致性 {Math.round(analysis.strokeConsistency ?? 0)}%
) : null} {Array.isArray(analysis.keyMoments) && analysis.keyMoments.length > 0 ? ( {analysis.keyMoments.length} 个建议片段 ) : null}
) : null}
); })}
)} PC 轻剪辑工作台 支持手动设置入点/出点、按分析关键时刻生成建议片段,并把剪辑草稿导出为 JSON。 {selectedVideo ? (
片段设置 建议先在播放器中定位,再设置入点和出点。
草稿片段
{clipDrafts.length}
总剪辑时长
{formatSeconds(totalClipDurationSec)}
建议片段
{suggestedClips.length}
当前区间时长
{formatSeconds(Math.max(0, clipRange[1] - clipRange[0]))}
当前播放
{formatSeconds(playbackSec)}
入点
{formatSeconds(clipRange[0])}
出点
{formatSeconds(clipRange[1])}
{timelineDurationSec > 0 ? ( { if (value.length === 2) { setClipRange([value[0] || 0, value[1] || Math.max(0.5, timelineDurationSec)]); } }} /> ) : null}
setClipLabel(event.target.value)} placeholder="片段名称,例如:正手节奏稳定段" className="h-11 rounded-2xl" />