fix: 时间轴

This commit is contained in:
root
2025-07-10 23:29:03 +08:00
parent 67aa4f1c22
commit b64c0e7452
4 changed files with 289 additions and 112 deletions

View File

@@ -0,0 +1,93 @@
import React from 'react'
interface TimelineRulerProps {
duration: number // Total duration in seconds
currentTime: number // Current playhead position in seconds
scale: number // Pixels per second
onTimeClick?: (time: number) => void
}
export const TimelineRuler: React.FC<TimelineRulerProps> = ({
duration,
currentTime,
scale,
onTimeClick
}) => {
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 100)
return `${minutes}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`
}
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (!onTimeClick) return
const rect = event.currentTarget.getBoundingClientRect()
const clickX = event.clientX - rect.left
const timelineWidth = rect.width
const clickTime = (clickX / timelineWidth) * duration
onTimeClick(Math.max(0, Math.min(duration, clickTime)))
}
// Generate time markers
const markers = []
const interval = duration > 60 ? 10 : duration > 30 ? 5 : 1 // Adaptive interval
for (let i = 0; i <= Math.ceil(duration / interval) * interval; i += interval) {
if (i <= duration) {
markers.push(i)
}
}
return (
<div
className="relative h-12 bg-gray-100 rounded border cursor-pointer select-none"
onClick={handleClick}
>
{/* Time markers */}
<div className="absolute inset-0 flex items-end">
{markers.map((time) => (
<div
key={time}
className="absolute flex flex-col items-center"
style={{ left: `${(time / duration) * 100}%` }}
>
<div className="w-px h-6 bg-gray-400"></div>
<span className="text-xs text-gray-600 mt-1 whitespace-nowrap">
{formatTime(time)}
</span>
</div>
))}
</div>
{/* Minor tick marks */}
<div className="absolute inset-0 flex items-end">
{Array.from({ length: Math.floor(duration) + 1 }, (_, i) => (
<div
key={`minor-${i}`}
className="absolute w-px h-3 bg-gray-300"
style={{ left: `${(i / duration) * 100}%` }}
/>
))}
</div>
{/* Current time indicator (playhead) */}
<div
className="absolute top-0 w-0.5 h-full bg-red-500 z-20 pointer-events-none"
style={{ left: `${(currentTime / duration) * 100}%` }}
>
<div className="absolute -top-2 -left-2 w-4 h-4 bg-red-500 rounded-full shadow-lg"></div>
<div className="absolute -top-8 -left-8 px-2 py-1 bg-red-500 text-white text-xs rounded whitespace-nowrap">
{formatTime(currentTime)}
</div>
</div>
{/* Hover indicator */}
<div className="absolute inset-0 opacity-0 hover:opacity-100 transition-opacity">
<div className="w-full h-full bg-blue-500 bg-opacity-5"></div>
</div>
</div>
)
}

View File

@@ -0,0 +1,154 @@
import React from 'react'
interface TrackSegment {
id: string
type: 'video' | 'audio' | 'image' | 'text' | 'effect'
name: string
start_time: number
end_time: number
duration: number
resource_path?: string
properties?: any
effects?: any[]
}
interface Track {
id: string
name: string
type: 'video' | 'audio' | 'subtitle'
index: number
segments: TrackSegment[]
properties?: any
}
interface TrackTimelineProps {
track: Track
totalDuration: number
currentTime: number
onSegmentClick?: (segment: TrackSegment) => void
onSegmentHover?: (segment: TrackSegment | null) => void
}
export const TrackTimeline: React.FC<TrackTimelineProps> = ({
track,
totalDuration,
currentTime,
onSegmentClick,
onSegmentHover
}) => {
const getSegmentColor = (type: string) => {
switch (type) {
case 'video': return 'bg-blue-500 hover:bg-blue-600'
case 'audio': return 'bg-green-500 hover:bg-green-600'
case 'image': return 'bg-yellow-500 hover:bg-yellow-600'
case 'text': return 'bg-purple-500 hover:bg-purple-600'
case 'effect': return 'bg-gray-500 hover:bg-gray-600'
default: return 'bg-gray-400 hover:bg-gray-500'
}
}
const getSegmentTextColor = (type: string) => {
switch (type) {
case 'video': return 'text-blue-100'
case 'audio': return 'text-green-100'
case 'image': return 'text-yellow-100'
case 'text': return 'text-purple-100'
case 'effect': return 'text-gray-100'
default: return 'text-gray-100'
}
}
const getTrackTypeColor = (type: string) => {
switch (type) {
case 'video': return 'bg-blue-100 text-blue-800 border-blue-200'
case 'audio': return 'bg-green-100 text-green-800 border-green-200'
case 'subtitle': return 'bg-purple-100 text-purple-800 border-purple-200'
default: return 'bg-gray-100 text-gray-800 border-gray-200'
}
}
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60)
const secs = (seconds % 60).toFixed(2)
return `${minutes}:${secs.padStart(5, '0')}`
}
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
{/* Track Header */}
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className={`px-2 py-1 rounded text-xs font-medium border ${getTrackTypeColor(track.type)}`}>
{track.type === 'video' ? '视频' : track.type === 'audio' ? '音频' : '字幕'}
</span>
<h3 className="font-medium text-gray-900">
{track.index + 1}: {track.name}
</h3>
</div>
<div className="flex items-center space-x-4 text-sm text-gray-500">
<span>{track.segments.length} </span>
</div>
</div>
</div>
{/* Timeline */}
<div className="relative h-16 bg-white">
{/* Background grid */}
<div className="absolute inset-0 opacity-10">
{Array.from({ length: Math.floor(totalDuration) + 1 }, (_, i) => (
<div
key={i}
className="absolute top-0 bottom-0 w-px bg-gray-300"
style={{ left: `${(i / totalDuration) * 100}%` }}
/>
))}
</div>
{/* Segments */}
{track.segments.map((segment) => {
const startPercent = (segment.start_time / totalDuration) * 100
const widthPercent = (segment.duration / totalDuration) * 100
return (
<div
key={segment.id}
className={`absolute top-2 bottom-2 rounded-md ${getSegmentColor(segment.type)} ${getSegmentTextColor(segment.type)}
flex items-center px-3 text-sm font-medium cursor-pointer transition-all duration-200
shadow-sm hover:shadow-md transform hover:scale-105 z-10`}
style={{
left: `${startPercent}%`,
width: `${Math.max(widthPercent, 8)}%`, // Minimum width for visibility
minWidth: '80px'
}}
onClick={() => onSegmentClick?.(segment)}
onMouseEnter={() => onSegmentHover?.(segment)}
onMouseLeave={() => onSegmentHover?.(null)}
title={`${segment.name}\n类型: ${segment.type}\n开始: ${formatTime(segment.start_time)}\n结束: ${formatTime(segment.end_time)}\n时长: ${formatTime(segment.duration)}${segment.resource_path ? `\n资源: ${segment.resource_path}` : ''}`}
>
<div className="truncate flex-1">
{segment.name}
</div>
<div className="text-xs opacity-75 ml-2">
{formatTime(segment.duration)}
</div>
</div>
)
})}
{/* Current time indicator */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-20 pointer-events-none"
style={{ left: `${(currentTime / totalDuration) * 100}%` }}
/>
{/* Empty track indicator */}
{track.segments.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-sm italic">
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { TimelineRuler } from './TimelineRuler'
export { TrackTimeline } from './TrackTimeline'

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Play, Pause, RotateCcw, Trash2 } from 'lucide-react'
import { ArrowLeft, Play, Pause, RotateCcw, Trash2, ZoomIn, ZoomOut } from 'lucide-react'
import { TemplateService } from '../services/tauri'
import type { TemplateInfo } from '../hooks/useProgressCommand'
import { TimelineRuler, TrackTimeline } from '../components/timeline'
// 轨道和片段的数据结构
interface TrackSegment {
@@ -89,27 +90,7 @@ const TemplateDetailPage: React.FC = () => {
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
const getSegmentColor = (type: string) => {
switch (type) {
case 'video': return 'bg-blue-500'
case 'audio': return 'bg-green-500'
case 'image': return 'bg-yellow-500'
case 'text': return 'bg-purple-500'
case 'effect': return 'bg-gray-500'
default: return 'bg-gray-400'
}
}
const getSegmentTextColor = (type: string) => {
switch (type) {
case 'video': return 'text-blue-100'
case 'audio': return 'text-green-100'
case 'image': return 'text-yellow-100'
case 'text': return 'text-purple-100'
case 'effect': return 'text-gray-100'
default: return 'text-gray-100'
}
}
const handleDeleteTemplate = async () => {
if (!template || !confirm('确定要删除这个模板吗?此操作不可撤销。')) return
@@ -219,20 +200,27 @@ const TemplateDetailPage: React.FC = () => {
<h2 className="text-lg font-semibold text-gray-900"></h2>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-700">:</label>
<input
type="range"
min="50"
max="200"
value={timelineScale}
onChange={(e) => setTimelineScale(Number(e.target.value))}
className="w-24"
/>
<span className="text-sm text-gray-600">{timelineScale}px/s</span>
<button
onClick={() => setTimelineScale(Math.max(50, timelineScale - 25))}
className="p-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
title="缩小"
>
<ZoomOut size={16} />
</button>
<span className="text-sm text-gray-600 min-w-[60px] text-center">
{timelineScale}px/s
</span>
<button
onClick={() => setTimelineScale(Math.min(200, timelineScale + 25))}
className="p-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
title="放大"
>
<ZoomIn size={16} />
</button>
</div>
<button
onClick={() => setCurrentTime(0)}
className="flex items-center px-3 py-1 text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
className="flex items-center px-3 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
>
<RotateCcw size={14} />
<span className="ml-1"></span>
@@ -242,27 +230,12 @@ const TemplateDetailPage: React.FC = () => {
{/* Time Ruler */}
<div className="mb-4">
<div className="relative h-8 bg-gray-100 rounded">
<div className="absolute inset-0 flex items-center">
{Array.from({ length: Math.ceil(totalDuration) + 1 }, (_, i) => (
<div
key={i}
className="absolute flex flex-col items-center"
style={{ left: `${(i / totalDuration) * 100}%` }}
>
<div className="w-px h-4 bg-gray-400"></div>
<span className="text-xs text-gray-600 mt-1">{formatTime(i)}</span>
</div>
))}
</div>
{/* Current Time Indicator */}
<div
className="absolute top-0 w-0.5 h-full bg-red-500 z-10"
style={{ left: `${(currentTime / totalDuration) * 100}%` }}
>
<div className="absolute -top-2 -left-2 w-4 h-4 bg-red-500 rounded-full"></div>
</div>
</div>
<TimelineRuler
duration={totalDuration}
currentTime={currentTime}
scale={timelineScale}
onTimeClick={setCurrentTime}
/>
</div>
{/* Current Time Display */}
@@ -280,65 +253,20 @@ const TemplateDetailPage: React.FC = () => {
<h2 className="text-lg font-semibold text-gray-900 mb-4"></h2>
<div className="space-y-4">
{templateDetail.tracks.map((track) => (
<div key={track.id} className="border border-gray-200 rounded-lg p-4">
{/* Track Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${
track.type === 'video' ? 'bg-blue-100 text-blue-800' :
track.type === 'audio' ? 'bg-green-100 text-green-800' :
'bg-purple-100 text-purple-800'
}`}>
{track.type === 'video' ? '视频' : track.type === 'audio' ? '音频' : '字幕'}
</span>
<h3 className="font-medium text-gray-900">
{track.index + 1}: {track.name}
</h3>
</div>
<span className="text-sm text-gray-500">
{track.segments.length}
</span>
</div>
{/* Track Timeline */}
<div className="relative h-12 bg-gray-50 rounded border">
{track.segments.map((segment) => {
const startPercent = (segment.start_time / totalDuration) * 100
const widthPercent = (segment.duration / totalDuration) * 100
return (
<div
key={segment.id}
className={`absolute top-1 bottom-1 rounded ${getSegmentColor(segment.type)} ${getSegmentTextColor(segment.type)} flex items-center px-2 text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity`}
style={{
left: `${startPercent}%`,
width: `${widthPercent}%`,
minWidth: '60px'
}}
title={`${segment.name}\n开始: ${formatTime(segment.start_time)}\n结束: ${formatTime(segment.end_time)}\n时长: ${formatTime(segment.duration)}`}
>
<span className="truncate">{segment.name}</span>
</div>
)
})}
</div>
{/* Segment Details */}
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{track.segments.map((segment) => (
<div key={segment.id} className="text-xs text-gray-600 bg-gray-50 rounded p-2">
<div className="font-medium">{segment.name}</div>
<div>: {formatTime(segment.start_time)}</div>
<div>: {formatTime(segment.duration)}</div>
{segment.resource_path && (
<div className="truncate" title={segment.resource_path}>
: {segment.resource_path.split('/').pop()}
</div>
)}
</div>
))}
</div>
</div>
<TrackTimeline
key={track.id}
track={track}
totalDuration={totalDuration}
currentTime={currentTime}
onSegmentClick={(segment) => {
// Jump to segment start time
setCurrentTime(segment.start_time)
}}
onSegmentHover={(segment) => {
// Could show segment details in a tooltip
console.log('Hovering segment:', segment?.name)
}}
/>
))}
</div>
</div>