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 React, { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
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 { TemplateService } from '../services/tauri'
|
||||||
import type { TemplateInfo } from '../hooks/useProgressCommand'
|
import type { TemplateInfo } from '../hooks/useProgressCommand'
|
||||||
|
import { TimelineRuler, TrackTimeline } from '../components/timeline'
|
||||||
|
|
||||||
// 轨道和片段的数据结构
|
// 轨道和片段的数据结构
|
||||||
interface TrackSegment {
|
interface TrackSegment {
|
||||||
@@ -89,27 +90,7 @@ const TemplateDetailPage: React.FC = () => {
|
|||||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
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 () => {
|
const handleDeleteTemplate = async () => {
|
||||||
if (!template || !confirm('确定要删除这个模板吗?此操作不可撤销。')) return
|
if (!template || !confirm('确定要删除这个模板吗?此操作不可撤销。')) return
|
||||||
@@ -219,20 +200,27 @@ const TemplateDetailPage: React.FC = () => {
|
|||||||
<h2 className="text-lg font-semibold text-gray-900">时间轴预览</h2>
|
<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-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<label className="text-sm text-gray-700">缩放:</label>
|
<button
|
||||||
<input
|
onClick={() => setTimelineScale(Math.max(50, timelineScale - 25))}
|
||||||
type="range"
|
className="p-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
|
||||||
min="50"
|
title="缩小"
|
||||||
max="200"
|
>
|
||||||
value={timelineScale}
|
<ZoomOut size={16} />
|
||||||
onChange={(e) => setTimelineScale(Number(e.target.value))}
|
</button>
|
||||||
className="w-24"
|
<span className="text-sm text-gray-600 min-w-[60px] text-center">
|
||||||
/>
|
{timelineScale}px/s
|
||||||
<span className="text-sm text-gray-600">{timelineScale}px/s</span>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentTime(0)}
|
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} />
|
<RotateCcw size={14} />
|
||||||
<span className="ml-1">重置</span>
|
<span className="ml-1">重置</span>
|
||||||
@@ -242,27 +230,12 @@ const TemplateDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Time Ruler */}
|
{/* Time Ruler */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="relative h-8 bg-gray-100 rounded">
|
<TimelineRuler
|
||||||
<div className="absolute inset-0 flex items-center">
|
duration={totalDuration}
|
||||||
{Array.from({ length: Math.ceil(totalDuration) + 1 }, (_, i) => (
|
currentTime={currentTime}
|
||||||
<div
|
scale={timelineScale}
|
||||||
key={i}
|
onTimeClick={setCurrentTime}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current Time Display */}
|
{/* Current Time Display */}
|
||||||
@@ -280,65 +253,20 @@ const TemplateDetailPage: React.FC = () => {
|
|||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">轨道时间轴</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">轨道时间轴</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{templateDetail.tracks.map((track) => (
|
{templateDetail.tracks.map((track) => (
|
||||||
<div key={track.id} className="border border-gray-200 rounded-lg p-4">
|
<TrackTimeline
|
||||||
{/* Track Header */}
|
key={track.id}
|
||||||
<div className="flex items-center justify-between mb-3">
|
track={track}
|
||||||
<div className="flex items-center space-x-3">
|
totalDuration={totalDuration}
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
currentTime={currentTime}
|
||||||
track.type === 'video' ? 'bg-blue-100 text-blue-800' :
|
onSegmentClick={(segment) => {
|
||||||
track.type === 'audio' ? 'bg-green-100 text-green-800' :
|
// Jump to segment start time
|
||||||
'bg-purple-100 text-purple-800'
|
setCurrentTime(segment.start_time)
|
||||||
}`}>
|
|
||||||
{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)}`}
|
onSegmentHover={(segment) => {
|
||||||
>
|
// Could show segment details in a tooltip
|
||||||
<span className="truncate">{segment.name}</span>
|
console.log('Hovering segment:', segment?.name)
|
||||||
</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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user