fix: 时间轴
This commit is contained in:
93
src/components/timeline/TimelineRuler.tsx
Normal file
93
src/components/timeline/TimelineRuler.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
154
src/components/timeline/TrackTimeline.tsx
Normal file
154
src/components/timeline/TrackTimeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
src/components/timeline/index.ts
Normal file
2
src/components/timeline/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TimelineRuler } from './TimelineRuler'
|
||||
export { TrackTimeline } from './TrackTimeline'
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user