feat: initial release v0.3.0
This commit is contained in:
160
src/features/video-editor/components/Preview/RemotionPreview.tsx
Normal file
160
src/features/video-editor/components/Preview/RemotionPreview.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useRef, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Player, PlayerRef } from '@remotion/player'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { VideoComposition } from '../../remotion/VideoComposition'
|
||||
import { VideoEditorProject } from '../../types/editor.types'
|
||||
import { calculateTimelineDuration } from '../../utils/time-utils'
|
||||
|
||||
interface RemotionPreviewProps {
|
||||
project: VideoEditorProject
|
||||
currentFrame: number
|
||||
playing: boolean
|
||||
onFrameChange?: (frame: number) => void
|
||||
onPlayingChange?: (playing: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Remotion Player 预览封装
|
||||
* 支持双向同步:timelineState ↔ Player
|
||||
*/
|
||||
export const RemotionPreview: React.FC<RemotionPreviewProps> = ({
|
||||
project,
|
||||
currentFrame,
|
||||
playing,
|
||||
onFrameChange,
|
||||
onPlayingChange
|
||||
}) => {
|
||||
const t = useTranslations('video')
|
||||
const playerRef = useRef<PlayerRef>(null)
|
||||
const lastSyncedFrame = useRef<number>(0)
|
||||
|
||||
const totalDuration = useMemo(
|
||||
() => calculateTimelineDuration(project.timeline),
|
||||
[project.timeline]
|
||||
)
|
||||
|
||||
// 当 currentFrame 从外部改变时,同步到 Player
|
||||
useEffect(() => {
|
||||
const player = playerRef.current
|
||||
if (!player) return
|
||||
|
||||
// 避免循环更新:只有当帧差距大于 1 时才 seek
|
||||
if (Math.abs(currentFrame - lastSyncedFrame.current) > 1) {
|
||||
player.seekTo(currentFrame)
|
||||
lastSyncedFrame.current = currentFrame
|
||||
}
|
||||
}, [currentFrame])
|
||||
|
||||
// 当 playing 状态改变时,控制 Player 播放/暂停
|
||||
useEffect(() => {
|
||||
const player = playerRef.current
|
||||
if (!player) return
|
||||
|
||||
if (playing) {
|
||||
player.play()
|
||||
} else {
|
||||
player.pause()
|
||||
}
|
||||
}, [playing])
|
||||
|
||||
// 监听 Player 的帧变化,同步到 timelineState
|
||||
useEffect(() => {
|
||||
const player = playerRef.current
|
||||
if (!player) return
|
||||
|
||||
const handleFrameUpdate = () => {
|
||||
const frame = player.getCurrentFrame()
|
||||
lastSyncedFrame.current = frame
|
||||
onFrameChange?.(frame)
|
||||
}
|
||||
|
||||
// Remotion Player 触发 timeupdate 事件
|
||||
player.addEventListener('frameupdate', handleFrameUpdate)
|
||||
|
||||
return () => {
|
||||
player.removeEventListener('frameupdate', handleFrameUpdate)
|
||||
}
|
||||
}, [onFrameChange])
|
||||
|
||||
// 监听 Player 播放状态变化
|
||||
useEffect(() => {
|
||||
const player = playerRef.current
|
||||
if (!player) return
|
||||
|
||||
const handlePlay = () => onPlayingChange?.(true)
|
||||
const handlePause = () => onPlayingChange?.(false)
|
||||
const handleEnded = () => onPlayingChange?.(false)
|
||||
|
||||
player.addEventListener('play', handlePlay)
|
||||
player.addEventListener('pause', handlePause)
|
||||
player.addEventListener('ended', handleEnded)
|
||||
|
||||
return () => {
|
||||
player.removeEventListener('play', handlePlay)
|
||||
player.removeEventListener('pause', handlePause)
|
||||
player.removeEventListener('ended', handleEnded)
|
||||
}
|
||||
}, [onPlayingChange])
|
||||
|
||||
// 如果没有片段,显示占位
|
||||
if (project.timeline.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
aspectRatio: `${project.config.width} / ${project.config.height}`,
|
||||
maxHeight: '100%',
|
||||
background: 'var(--glass-bg-surface)',
|
||||
border: '1px solid var(--glass-stroke-base)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--glass-text-tertiary)'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'center' }}>
|
||||
<AppIcon name="image" className="w-12 h-12" />
|
||||
</div>
|
||||
<span>{t('editor.preview.emptyStartEditing')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
aspectRatio: `${project.config.width} / ${project.config.height}`,
|
||||
maxHeight: '100%',
|
||||
background: 'var(--glass-overlay-strong)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<Player
|
||||
ref={playerRef}
|
||||
component={VideoComposition}
|
||||
inputProps={{
|
||||
clips: project.timeline,
|
||||
bgmTrack: project.bgmTrack,
|
||||
config: project.config
|
||||
}}
|
||||
durationInFrames={Math.max(1, totalDuration)}
|
||||
fps={project.config.fps}
|
||||
compositionWidth={project.config.width}
|
||||
compositionHeight={project.config.height}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
controls={false} // 使用自定义控制
|
||||
loop={false}
|
||||
clickToPlay={false} // 禁用点击播放,由外部控制
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RemotionPreview
|
||||
1
src/features/video-editor/components/Preview/index.ts
Normal file
1
src/features/video-editor/components/Preview/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RemotionPreview } from './RemotionPreview'
|
||||
378
src/features/video-editor/components/Timeline/Timeline.tsx
Normal file
378
src/features/video-editor/components/Timeline/Timeline.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { VideoClip, TimelineState, EditorConfig } from '../../types/editor.types'
|
||||
import { framesToTime } from '../../utils/time-utils'
|
||||
|
||||
interface TimelineProps {
|
||||
clips: VideoClip[]
|
||||
timelineState: TimelineState
|
||||
config: EditorConfig
|
||||
onReorder: (fromIndex: number, toIndex: number) => void
|
||||
onSelectClip: (clipId: string | null) => void
|
||||
onZoomChange: (zoom: number) => void
|
||||
onSeek?: (frame: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴主组件
|
||||
* 使用 dnd-kit 实现拖拽排序
|
||||
*/
|
||||
export const Timeline: React.FC<TimelineProps> = ({
|
||||
clips,
|
||||
timelineState,
|
||||
config,
|
||||
onReorder,
|
||||
onSelectClip,
|
||||
onZoomChange,
|
||||
onSeek
|
||||
}) => {
|
||||
const t = useTranslations('video')
|
||||
// 计算总时长和播放头位置
|
||||
const totalDuration = clips.reduce((sum, clip) => sum + clip.durationInFrames, 0)
|
||||
const playheadPosition = totalDuration > 0 ? (timelineState.currentFrame / totalDuration) * 100 : 0
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5 // 5px 移动才开始拖拽
|
||||
}
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = clips.findIndex(c => c.id === active.id)
|
||||
const newIndex = clips.findIndex(c => c.id === over.id)
|
||||
onReorder(oldIndex, newIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="timeline" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '12px',
|
||||
background: 'var(--glass-bg-surface)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--glass-stroke-base)',
|
||||
height: '100%'
|
||||
}}>
|
||||
{/* 缩放控制 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{ fontSize: '12px', color: 'var(--glass-text-secondary)' }}>{t('editor.timeline.zoomLabel')}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={timelineState.zoom}
|
||||
onChange={(e) => onZoomChange(parseFloat(e.target.value))}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: 'var(--glass-text-tertiary)' }}>
|
||||
{Math.round(timelineState.zoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 进度条 + 播放头 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: '24px',
|
||||
background: 'var(--glass-bg-muted)',
|
||||
border: '1px solid var(--glass-stroke-base)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '70px' // 与轨道标签对齐
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (!onSeek || totalDuration === 0) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const percent = x / rect.width
|
||||
const frame = Math.round(percent * totalDuration)
|
||||
onSeek(Math.max(0, Math.min(totalDuration, frame)))
|
||||
}}
|
||||
>
|
||||
{/* 已播放部分 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: `${playheadPosition}%`,
|
||||
background: 'linear-gradient(90deg, var(--glass-accent-from) 0%, var(--glass-accent-to) 100%)',
|
||||
borderRadius: '4px 0 0 4px',
|
||||
transition: timelineState.playing ? 'none' : 'width 0.1s'
|
||||
}} />
|
||||
{/* 播放头指示器 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `${playheadPosition}%`,
|
||||
top: '-4px',
|
||||
bottom: '-4px',
|
||||
width: '3px',
|
||||
background: 'var(--glass-accent-to)',
|
||||
borderRadius: '2px',
|
||||
boxShadow: '0 0 8px var(--glass-accent-shadow-strong)',
|
||||
transform: 'translateX(-50%)',
|
||||
transition: timelineState.playing ? 'none' : 'left 0.1s'
|
||||
}} />
|
||||
{/* 时间标记 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
fontSize: '10px',
|
||||
color: 'var(--glass-text-tertiary)'
|
||||
}}>
|
||||
{framesToTime(timelineState.currentFrame, config.fps)} / {framesToTime(totalDuration, config.fps)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频轨道 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '56px',
|
||||
background: 'var(--glass-bg-surface-strong)',
|
||||
border: '1px solid var(--glass-stroke-base)',
|
||||
borderRadius: '6px',
|
||||
padding: '0 12px'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--glass-text-secondary)',
|
||||
width: '70px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{t('editor.timeline.videoTrack')}
|
||||
</span>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={clips.map(c => c.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
flex: 1,
|
||||
overflowX: 'auto',
|
||||
paddingRight: '12px'
|
||||
}}>
|
||||
{clips.map((clip, index) => (
|
||||
<SortableClip
|
||||
key={clip.id}
|
||||
clip={clip}
|
||||
index={index}
|
||||
isSelected={timelineState.selectedClipId === clip.id}
|
||||
zoom={timelineState.zoom}
|
||||
fps={config.fps}
|
||||
onClick={() => onSelectClip(clip.id)}
|
||||
/>
|
||||
))}
|
||||
{clips.length === 0 && (
|
||||
<span style={{ fontSize: '12px', color: 'var(--glass-text-tertiary)' }}>
|
||||
{t('editor.timeline.emptyHint')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* 配音轨道 (显示附属音频) */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '40px',
|
||||
background: 'var(--glass-bg-surface-strong)',
|
||||
border: '1px solid var(--glass-stroke-base)',
|
||||
borderRadius: '6px',
|
||||
padding: '0 12px'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--glass-text-secondary)',
|
||||
width: '70px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{t('editor.timeline.audioTrack')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px', flex: 1 }}>
|
||||
{clips.filter(c => c.attachment?.audio).map((clip) => (
|
||||
<div
|
||||
key={`audio-${clip.id}`}
|
||||
style={{
|
||||
width: `${clip.durationInFrames * timelineState.zoom * 2}px`,
|
||||
height: '28px',
|
||||
background: 'var(--glass-tone-success-bg)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
color: 'var(--glass-tone-success-fg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{t('editor.timeline.audioBadge')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BGM 轨道 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '40px',
|
||||
background: 'var(--glass-bg-surface-strong)',
|
||||
border: '1px solid var(--glass-stroke-base)',
|
||||
borderRadius: '6px',
|
||||
padding: '0 12px'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--glass-text-secondary)',
|
||||
width: '70px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
BGM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 可拖拽的片段组件
|
||||
*/
|
||||
interface SortableClipProps {
|
||||
clip: VideoClip
|
||||
index: number
|
||||
isSelected: boolean
|
||||
zoom: number
|
||||
fps: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const SortableClip: React.FC<SortableClipProps> = ({
|
||||
clip,
|
||||
index,
|
||||
isSelected,
|
||||
zoom,
|
||||
fps,
|
||||
onClick
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id: clip.id })
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
width: `${clip.durationInFrames * zoom * 2}px`,
|
||||
minWidth: '60px',
|
||||
height: '40px',
|
||||
background: isSelected
|
||||
? 'var(--glass-accent-from)'
|
||||
: isDragging
|
||||
? 'var(--glass-bg-muted)'
|
||||
: 'var(--glass-bg-surface)',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
color: isSelected ? 'var(--glass-text-on-accent)' : 'var(--glass-text-primary)',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
flexShrink: 0,
|
||||
border: isSelected ? '2px solid var(--glass-stroke-focus)' : '1px solid var(--glass-stroke-base)',
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
zIndex: isDragging ? 100 : 1,
|
||||
position: 'relative'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<span style={{ fontWeight: 'bold' }}>{index + 1}</span>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
bottom: '2px',
|
||||
fontSize: '9px',
|
||||
color: isSelected ? 'rgba(255, 255, 255, 0.8)' : 'var(--glass-text-tertiary)'
|
||||
}}>
|
||||
{framesToTime(clip.durationInFrames, fps)}
|
||||
</span>
|
||||
|
||||
{/* 转场指示器 */}
|
||||
{clip.transition && clip.transition.type !== 'none' && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: '-6px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
background: 'var(--glass-tone-warning-fg)',
|
||||
borderRadius: '50%',
|
||||
fontSize: '8px',
|
||||
color: 'var(--glass-text-on-accent)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10
|
||||
}}>
|
||||
T
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Timeline
|
||||
1
src/features/video-editor/components/Timeline/index.ts
Normal file
1
src/features/video-editor/components/Timeline/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Timeline } from './Timeline'
|
||||
124
src/features/video-editor/components/TransitionPicker.tsx
Normal file
124
src/features/video-editor/components/TransitionPicker.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import type { AppIconName } from '@/components/ui/icons'
|
||||
|
||||
export type TransitionType = 'none' | 'dissolve' | 'fade' | 'slide'
|
||||
|
||||
interface TransitionPickerProps {
|
||||
value: TransitionType
|
||||
duration: number
|
||||
onChange: (type: TransitionType, duration: number) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const TRANSITION_OPTIONS: { type: TransitionType; labelKey: string; icon: AppIconName }[] = [
|
||||
{ type: 'none', labelKey: 'none', icon: 'minus' },
|
||||
{ type: 'dissolve', labelKey: 'dissolve', icon: 'diamond' },
|
||||
{ type: 'fade', labelKey: 'fade', icon: 'clock' },
|
||||
{ type: 'slide', labelKey: 'slide', icon: 'arrowRight' }
|
||||
]
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ value: 10, label: '0.3s' },
|
||||
{ value: 15, label: '0.5s' },
|
||||
{ value: 30, label: '1s' },
|
||||
{ value: 45, label: '1.5s' }
|
||||
]
|
||||
|
||||
export const TransitionPicker: React.FC<TransitionPickerProps> = ({
|
||||
value,
|
||||
duration,
|
||||
onChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const t = useTranslations('video')
|
||||
return (
|
||||
<div className="transition-picker" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '12px',
|
||||
background: 'var(--glass-bg-surface)',
|
||||
border: '1px solid var(--glass-stroke-base)',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--glass-text-secondary)', marginBottom: '4px' }}>
|
||||
{t('editor.transition.title')}
|
||||
</div>
|
||||
|
||||
{/* 转场类型选择 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{TRANSITION_OPTIONS.map(option => (
|
||||
<button
|
||||
key={option.type}
|
||||
onClick={() => onChange(option.type, duration)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '8px 4px',
|
||||
background: value === option.type ? 'var(--glass-accent-from)' : 'var(--glass-bg-muted)',
|
||||
border: value === option.type ? '1px solid var(--glass-stroke-focus)' : '1px solid var(--glass-stroke-base)',
|
||||
borderRadius: '6px',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
name={option.icon}
|
||||
size={16}
|
||||
color={value === option.type ? 'var(--glass-text-on-accent)' : 'var(--glass-text-primary)'}
|
||||
/>
|
||||
<span style={{ fontSize: '10px', color: value === option.type ? 'var(--glass-text-on-accent)' : 'var(--glass-text-primary)' }}>{t(`editor.transition.options.${option.labelKey}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 持续时间选择 */}
|
||||
{value !== 'none' && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<div style={{ fontSize: '11px', color: 'var(--glass-text-tertiary)', marginBottom: '4px' }}>
|
||||
{t('editor.transition.duration')}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{DURATION_OPTIONS.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => onChange(value, option.value)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 8px',
|
||||
background: duration === option.value ? 'var(--glass-accent-from)' : 'var(--glass-bg-muted)',
|
||||
border: duration === option.value ? '1px solid var(--glass-stroke-focus)' : '1px solid var(--glass-stroke-base)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
color: duration === option.value ? 'var(--glass-text-on-accent)' : 'var(--glass-text-primary)',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransitionPicker
|
||||
295
src/features/video-editor/components/VideoEditorStage.tsx
Normal file
295
src/features/video-editor/components/VideoEditorStage.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client'
|
||||
import { logError as _ulogError } from '@/lib/logging/core'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
import React from 'react'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { useEditorState } from '../hooks/useEditorState'
|
||||
import { useEditorActions } from '../hooks/useEditorActions'
|
||||
import { VideoEditorProject } from '../types/editor.types'
|
||||
import { calculateTimelineDuration, framesToTime } from '../utils/time-utils'
|
||||
import { RemotionPreview } from './Preview'
|
||||
import { Timeline } from './Timeline'
|
||||
import { TransitionPicker, TransitionType } from './TransitionPicker'
|
||||
|
||||
interface VideoEditorStageProps {
|
||||
projectId: string
|
||||
episodeId: string
|
||||
initialProject?: VideoEditorProject
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频编辑器主页面
|
||||
*
|
||||
* 布局:
|
||||
* ┌──────────────────────────────────────────────────────────┐
|
||||
* │ Toolbar (返回 | 保存 | 导出) │
|
||||
* ├──────────────┬───────────────────────────────────────────┤
|
||||
* │ 素材库 │ Preview (Remotion Player) │
|
||||
* │ │ │
|
||||
* │ ├───────────────────────────────────────────┤
|
||||
* │ │ Properties Panel │
|
||||
* ├──────────────┴───────────────────────────────────────────┤
|
||||
* │ Timeline │
|
||||
* └──────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
export function VideoEditorStage({
|
||||
projectId,
|
||||
episodeId,
|
||||
initialProject,
|
||||
onBack
|
||||
}: VideoEditorStageProps) {
|
||||
const t = useTranslations('video')
|
||||
const {
|
||||
project,
|
||||
timelineState,
|
||||
isDirty,
|
||||
removeClip,
|
||||
updateClip,
|
||||
reorderClips,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
selectClip,
|
||||
setZoom,
|
||||
markSaved
|
||||
} = useEditorState({ episodeId, initialProject })
|
||||
|
||||
const { saveProject, startRender } = useEditorActions({ projectId, episodeId })
|
||||
|
||||
const totalDuration = calculateTimelineDuration(project.timeline)
|
||||
const totalTime = framesToTime(totalDuration, project.config.fps)
|
||||
const currentTime = framesToTime(timelineState.currentFrame, project.config.fps)
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveProject(project)
|
||||
markSaved()
|
||||
alert(t('editor.alert.saveSuccess'))
|
||||
} catch (error) {
|
||||
_ulogError('Save failed:', error)
|
||||
alert(t('editor.alert.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
await startRender(project.id)
|
||||
alert(t('editor.alert.exportStarted'))
|
||||
} catch (error) {
|
||||
_ulogError('Export failed:', error)
|
||||
alert(t('editor.alert.exportFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const selectedClip = project.timeline.find(c => c.id === timelineState.selectedClipId)
|
||||
|
||||
return (
|
||||
<div className="video-editor-stage" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
background: 'var(--glass-bg-canvas)',
|
||||
color: 'var(--glass-text-primary)'
|
||||
}}>
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--glass-stroke-base)',
|
||||
background: 'var(--glass-bg-surface)'
|
||||
}}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2"
|
||||
>
|
||||
{t('editor.toolbar.back')}
|
||||
</button>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<span style={{ color: 'var(--glass-text-secondary)', fontSize: '14px' }}>
|
||||
{currentTime} / {totalTime}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className={`glass-btn-base px-4 py-2 ${isDirty ? 'glass-btn-primary text-white' : 'glass-btn-secondary'}`}
|
||||
>
|
||||
{isDirty ? t('editor.toolbar.saveDirty') : t('editor.toolbar.saved')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="glass-btn-base glass-btn-tone-success px-4 py-2"
|
||||
>
|
||||
{t('editor.toolbar.export')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Left Panel - Media Library */}
|
||||
<div style={{
|
||||
width: '200px',
|
||||
borderRight: '1px solid var(--glass-stroke-base)',
|
||||
padding: '12px',
|
||||
background: 'var(--glass-bg-surface-strong)'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 12px 0', fontSize: '14px', color: 'var(--glass-text-secondary)' }}>
|
||||
{t('editor.left.title')}
|
||||
</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--glass-text-tertiary)' }}>
|
||||
{t('editor.left.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Center - Preview + Properties */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Preview */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--glass-bg-muted)',
|
||||
padding: '20px'
|
||||
}}>
|
||||
<RemotionPreview
|
||||
project={project}
|
||||
currentFrame={timelineState.currentFrame}
|
||||
playing={timelineState.playing}
|
||||
onFrameChange={seek}
|
||||
onPlayingChange={(playing) => playing ? play() : pause()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Playback Controls */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '16px',
|
||||
padding: '12px',
|
||||
background: 'var(--glass-bg-surface-strong)',
|
||||
borderTop: '1px solid var(--glass-stroke-base)'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => seek(0)}
|
||||
className="glass-btn-base glass-btn-ghost px-3 py-1.5"
|
||||
>
|
||||
<AppIcon name="chevronLeft" className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => timelineState.playing ? pause() : play()}
|
||||
style={{
|
||||
background: 'var(--glass-accent-from)',
|
||||
border: 'none',
|
||||
color: 'var(--glass-text-on-accent)',
|
||||
cursor: 'pointer',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
fontSize: '18px'
|
||||
}}
|
||||
>
|
||||
{timelineState.playing
|
||||
? <AppIcon name="pause" className="w-4 h-4" />
|
||||
: <AppIcon name="play" className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => seek(totalDuration)}
|
||||
className="glass-btn-base glass-btn-ghost px-3 py-1.5"
|
||||
>
|
||||
<AppIcon name="chevronRight" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Properties */}
|
||||
<div style={{
|
||||
width: '280px',
|
||||
borderLeft: '1px solid var(--glass-stroke-base)',
|
||||
padding: '12px',
|
||||
background: 'var(--glass-bg-surface-strong)',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 12px 0', fontSize: '14px', color: 'var(--glass-text-secondary)' }}>
|
||||
{t('editor.right.title')}
|
||||
</h3>
|
||||
{selectedClip ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{/* 基础信息 */}
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>
|
||||
<span style={{ color: 'var(--glass-text-secondary)' }}>{t('editor.right.clipLabel')}</span> {selectedClip.metadata?.description || t('editor.right.clipFallback', { index: project.timeline.findIndex(c => c.id === selectedClip.id) + 1 })}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>
|
||||
<span style={{ color: 'var(--glass-text-secondary)' }}>{t('editor.right.durationLabel')}</span> {framesToTime(selectedClip.durationInFrames, project.config.fps)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 转场设置 */}
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '13px', color: 'var(--glass-text-secondary)' }}>
|
||||
{t('editor.right.transitionLabel')}
|
||||
</h4>
|
||||
<TransitionPicker
|
||||
value={(selectedClip.transition?.type as TransitionType) || 'none'}
|
||||
duration={selectedClip.transition?.durationInFrames || 15}
|
||||
onChange={(type, duration) => {
|
||||
updateClip(selectedClip.id, {
|
||||
transition: type === 'none' ? undefined : { type, durationInFrames: duration }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(t('editor.right.deleteConfirm'))) {
|
||||
removeClip(selectedClip.id)
|
||||
selectClip(null)
|
||||
}
|
||||
}}
|
||||
className="glass-btn-base glass-btn-tone-danger mt-2 px-3 py-2 text-xs"
|
||||
>
|
||||
{t('editor.right.deleteClip')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: 'var(--glass-text-tertiary)' }}>
|
||||
{t('editor.right.selectClipHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div style={{
|
||||
height: '220px',
|
||||
borderTop: '1px solid var(--glass-stroke-base)'
|
||||
}}>
|
||||
<Timeline
|
||||
clips={project.timeline}
|
||||
timelineState={timelineState}
|
||||
config={project.config}
|
||||
onReorder={reorderClips}
|
||||
onSelectClip={selectClip}
|
||||
onZoomChange={setZoom}
|
||||
onSeek={seek}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoEditorStage
|
||||
156
src/features/video-editor/hooks/useEditorActions.ts
Normal file
156
src/features/video-editor/hooks/useEditorActions.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { VideoClip, VideoEditorProject } from '../types/editor.types'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
|
||||
interface UseEditorActionsProps {
|
||||
projectId: string
|
||||
episodeId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板数据类型(灵活接受各种格式)
|
||||
*/
|
||||
interface PanelData {
|
||||
id?: string
|
||||
panelIndex?: number
|
||||
storyboardId: string
|
||||
videoUrl?: string
|
||||
description?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已生成的视频面板创建编辑器项目
|
||||
*/
|
||||
export function createProjectFromPanels(
|
||||
episodeId: string,
|
||||
panels: PanelData[],
|
||||
voiceLines?: Array<{ id: string; speaker: string; content: string; audioUrl?: string | null }>
|
||||
): VideoEditorProject {
|
||||
// 过滤出有视频的面板
|
||||
const videoPanels = panels.filter(p => p.videoUrl)
|
||||
|
||||
// 创建视频片段
|
||||
const timeline: VideoClip[] = videoPanels.map((panel, index) => {
|
||||
// 查找匹配的配音(简单匹配:按索引)
|
||||
const matchedVoice = voiceLines?.[index]
|
||||
|
||||
return {
|
||||
id: `clip_${panel.id || panel.storyboardId}_${panel.panelIndex ?? index}`,
|
||||
src: panel.videoUrl!,
|
||||
durationInFrames: Math.round((panel.duration || 3) * 30), // 默认 3 秒,30fps
|
||||
attachment: {
|
||||
audio: matchedVoice?.audioUrl ? {
|
||||
src: matchedVoice.audioUrl,
|
||||
volume: 1,
|
||||
voiceLineId: matchedVoice.id
|
||||
} : undefined,
|
||||
subtitle: matchedVoice ? {
|
||||
text: matchedVoice.content,
|
||||
style: 'default' as const
|
||||
} : undefined
|
||||
},
|
||||
transition: index < videoPanels.length - 1 ? {
|
||||
type: 'dissolve' as const,
|
||||
durationInFrames: 15 // 0.5s @ 30fps
|
||||
} : undefined,
|
||||
metadata: {
|
||||
panelId: panel.id || `${panel.storyboardId}-${panel.panelIndex ?? index}`,
|
||||
storyboardId: panel.storyboardId,
|
||||
description: panel.description || undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: `editor_${episodeId}_${Date.now()}`,
|
||||
episodeId,
|
||||
schemaVersion: '1.0',
|
||||
config: {
|
||||
fps: 30,
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
timeline,
|
||||
bgmTrack: []
|
||||
}
|
||||
}
|
||||
|
||||
export function useEditorActions({ projectId, episodeId }: UseEditorActionsProps) {
|
||||
/**
|
||||
* 保存项目到服务器
|
||||
*/
|
||||
const saveProject = useCallback(async (project: VideoEditorProject) => {
|
||||
const response = await apiFetch(`/api/novel-promotion/${projectId}/editor`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectData: project })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save project')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}, [projectId])
|
||||
|
||||
/**
|
||||
* 加载项目
|
||||
*/
|
||||
const loadProject = useCallback(async (): Promise<VideoEditorProject | null> => {
|
||||
const response = await apiFetch(`/api/novel-promotion/${projectId}/editor?episodeId=${episodeId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null
|
||||
throw new Error('Failed to load project')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.projectData
|
||||
}, [projectId, episodeId])
|
||||
|
||||
/**
|
||||
* 发起渲染导出
|
||||
*/
|
||||
const startRender = useCallback(async (editorProjectId: string) => {
|
||||
const response = await apiFetch(`/api/novel-promotion/${projectId}/editor/render`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
editorProjectId,
|
||||
format: 'mp4',
|
||||
quality: 'high'
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start render')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}, [projectId])
|
||||
|
||||
/**
|
||||
* 获取渲染状态
|
||||
*/
|
||||
const getRenderStatus = useCallback(async (editorProjectId: string) => {
|
||||
const response = await apiFetch(
|
||||
`/api/novel-promotion/${projectId}/editor/render?id=${editorProjectId}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get render status')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}, [projectId])
|
||||
|
||||
return {
|
||||
saveProject,
|
||||
loadProject,
|
||||
startRender,
|
||||
getRenderStatus
|
||||
}
|
||||
}
|
||||
175
src/features/video-editor/hooks/useEditorState.ts
Normal file
175
src/features/video-editor/hooks/useEditorState.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import {
|
||||
VideoEditorProject,
|
||||
VideoClip,
|
||||
BgmClip,
|
||||
TimelineState,
|
||||
createDefaultProject,
|
||||
generateClipId
|
||||
} from '../index'
|
||||
|
||||
interface UseEditorStateProps {
|
||||
episodeId: string
|
||||
initialProject?: VideoEditorProject
|
||||
}
|
||||
|
||||
export function useEditorState({ episodeId, initialProject }: UseEditorStateProps) {
|
||||
// 项目数据
|
||||
const [project, setProject] = useState<VideoEditorProject>(
|
||||
initialProject || createDefaultProject(episodeId)
|
||||
)
|
||||
|
||||
// 时间轴 UI 状态
|
||||
const [timelineState, setTimelineState] = useState<TimelineState>({
|
||||
currentFrame: 0,
|
||||
playing: false,
|
||||
selectedClipId: null,
|
||||
zoom: 1
|
||||
})
|
||||
|
||||
// 是否有未保存的更改
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
// ========================================
|
||||
// 时间轴片段操作
|
||||
// ========================================
|
||||
|
||||
const addClip = useCallback((clip: Omit<VideoClip, 'id'>) => {
|
||||
const newClip: VideoClip = {
|
||||
...clip,
|
||||
id: generateClipId()
|
||||
}
|
||||
setProject(prev => ({
|
||||
...prev,
|
||||
timeline: [...prev.timeline, newClip]
|
||||
}))
|
||||
setIsDirty(true)
|
||||
return newClip.id
|
||||
}, [])
|
||||
|
||||
const removeClip = useCallback((clipId: string) => {
|
||||
setProject(prev => ({
|
||||
...prev,
|
||||
timeline: prev.timeline.filter(c => c.id !== clipId)
|
||||
}))
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
const updateClip = useCallback((clipId: string, updates: Partial<VideoClip>) => {
|
||||
setProject(prev => ({
|
||||
...prev,
|
||||
timeline: prev.timeline.map(c =>
|
||||
c.id === clipId ? { ...c, ...updates } : c
|
||||
)
|
||||
}))
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
const reorderClips = useCallback((fromIndex: number, toIndex: number) => {
|
||||
setProject(prev => {
|
||||
const newTimeline = [...prev.timeline]
|
||||
const [removed] = newTimeline.splice(fromIndex, 1)
|
||||
newTimeline.splice(toIndex, 0, removed)
|
||||
return { ...prev, timeline: newTimeline }
|
||||
})
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
// ========================================
|
||||
// BGM 操作
|
||||
// ========================================
|
||||
|
||||
const addBgm = useCallback((bgm: Omit<BgmClip, 'id'>) => {
|
||||
const newBgm: BgmClip = {
|
||||
...bgm,
|
||||
id: `bgm_${Date.now()}`
|
||||
}
|
||||
setProject(prev => ({
|
||||
...prev,
|
||||
bgmTrack: [...prev.bgmTrack, newBgm]
|
||||
}))
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
const removeBgm = useCallback((bgmId: string) => {
|
||||
setProject(prev => ({
|
||||
...prev,
|
||||
bgmTrack: prev.bgmTrack.filter(b => b.id !== bgmId)
|
||||
}))
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
// ========================================
|
||||
// 播放控制
|
||||
// ========================================
|
||||
|
||||
const play = useCallback(() => {
|
||||
setTimelineState(prev => ({ ...prev, playing: true }))
|
||||
}, [])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
setTimelineState(prev => ({ ...prev, playing: false }))
|
||||
}, [])
|
||||
|
||||
const seek = useCallback((frame: number) => {
|
||||
setTimelineState(prev => ({ ...prev, currentFrame: frame }))
|
||||
}, [])
|
||||
|
||||
const selectClip = useCallback((clipId: string | null) => {
|
||||
setTimelineState(prev => ({ ...prev, selectedClipId: clipId }))
|
||||
}, [])
|
||||
|
||||
const setZoom = useCallback((zoom: number) => {
|
||||
setTimelineState(prev => ({ ...prev, zoom: Math.max(0.1, Math.min(5, zoom)) }))
|
||||
}, [])
|
||||
|
||||
// ========================================
|
||||
// 项目操作
|
||||
// ========================================
|
||||
|
||||
const resetProject = useCallback(() => {
|
||||
setProject(createDefaultProject(episodeId))
|
||||
setIsDirty(false)
|
||||
}, [episodeId])
|
||||
|
||||
const loadProject = useCallback((data: VideoEditorProject) => {
|
||||
setProject(data)
|
||||
setIsDirty(false)
|
||||
}, [])
|
||||
|
||||
const markSaved = useCallback(() => {
|
||||
setIsDirty(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State
|
||||
project,
|
||||
timelineState,
|
||||
isDirty,
|
||||
|
||||
// Clip actions
|
||||
addClip,
|
||||
removeClip,
|
||||
updateClip,
|
||||
reorderClips,
|
||||
|
||||
// BGM actions
|
||||
addBgm,
|
||||
removeBgm,
|
||||
|
||||
// Playback
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
selectClip,
|
||||
setZoom,
|
||||
|
||||
// Project
|
||||
resetProject,
|
||||
loadProject,
|
||||
markSaved,
|
||||
setProject
|
||||
}
|
||||
}
|
||||
42
src/features/video-editor/index.ts
Normal file
42
src/features/video-editor/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// ========================================
|
||||
// Video Editor Module - Public API
|
||||
// ========================================
|
||||
|
||||
// Types
|
||||
export type {
|
||||
VideoEditorProject,
|
||||
VideoClip,
|
||||
BgmClip,
|
||||
ClipAttachment,
|
||||
ClipTransition,
|
||||
ClipMetadata,
|
||||
EditorConfig,
|
||||
TimelineState,
|
||||
ComputedClip,
|
||||
SaveEditorProjectRequest,
|
||||
RenderRequest,
|
||||
RenderStatus
|
||||
} from './types/editor.types'
|
||||
|
||||
// Utils
|
||||
export {
|
||||
calculateTimelineDuration,
|
||||
computeClipPositions,
|
||||
framesToTime,
|
||||
timeToFrames,
|
||||
generateClipId,
|
||||
createDefaultProject
|
||||
} from './utils/time-utils'
|
||||
|
||||
export {
|
||||
migrateProjectData,
|
||||
validateProjectData
|
||||
} from './utils/migration'
|
||||
|
||||
// Components
|
||||
export { VideoEditorStage } from './components/VideoEditorStage'
|
||||
export { TransitionPicker } from './components/TransitionPicker'
|
||||
|
||||
// Hooks
|
||||
export { useEditorState } from './hooks/useEditorState'
|
||||
export { useEditorActions, createProjectFromPanels } from './hooks/useEditorActions'
|
||||
236
src/features/video-editor/remotion/VideoComposition.tsx
Normal file
236
src/features/video-editor/remotion/VideoComposition.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React from 'react'
|
||||
import { AbsoluteFill, Sequence, Video, Audio, useCurrentFrame, interpolate } from 'remotion'
|
||||
import { VideoClip, BgmClip, EditorConfig } from '../types/editor.types'
|
||||
import { computeClipPositions } from '../utils/time-utils'
|
||||
|
||||
interface VideoCompositionProps {
|
||||
clips: VideoClip[]
|
||||
bgmTrack: BgmClip[]
|
||||
config: EditorConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Remotion 主合成组件
|
||||
* 使用 Sequence 实现磁性时间轴布局,支持转场效果
|
||||
*/
|
||||
export const VideoComposition: React.FC<VideoCompositionProps> = ({
|
||||
clips,
|
||||
bgmTrack,
|
||||
config
|
||||
}) => {
|
||||
const computedClips = computeClipPositions(clips)
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: 'black' }}>
|
||||
{/* 视频轨道 - 带转场效果 */}
|
||||
{computedClips.map((clip, index) => {
|
||||
const transitionDuration = clip.transition?.durationInFrames || 0
|
||||
|
||||
return (
|
||||
<Sequence
|
||||
key={clip.id}
|
||||
from={clip.startFrame}
|
||||
durationInFrames={clip.durationInFrames}
|
||||
name={`Clip ${index + 1}`}
|
||||
>
|
||||
<ClipRenderer
|
||||
clip={clip}
|
||||
config={config}
|
||||
transitionType={clip.transition?.type}
|
||||
transitionDuration={transitionDuration}
|
||||
isLastClip={index === computedClips.length - 1}
|
||||
/>
|
||||
</Sequence>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* BGM 轨道 */}
|
||||
{bgmTrack.map((bgm) => (
|
||||
<Sequence
|
||||
key={bgm.id}
|
||||
from={bgm.startFrame}
|
||||
durationInFrames={bgm.durationInFrames}
|
||||
name={`BGM: ${bgm.id}`}
|
||||
>
|
||||
<BgmRenderer bgm={bgm} />
|
||||
</Sequence>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* BGM 渲染器 - 支持淡入淡出
|
||||
*/
|
||||
interface BgmRendererProps {
|
||||
bgm: BgmClip
|
||||
}
|
||||
|
||||
const BgmRenderer: React.FC<BgmRendererProps> = ({ bgm }) => {
|
||||
const frame = useCurrentFrame()
|
||||
const fadeIn = bgm.fadeIn || 0
|
||||
const fadeOut = bgm.fadeOut || 0
|
||||
|
||||
let volume = bgm.volume
|
||||
|
||||
// 淡入
|
||||
if (fadeIn > 0 && frame < fadeIn) {
|
||||
volume *= interpolate(frame, [0, fadeIn], [0, 1], { extrapolateRight: 'clamp' })
|
||||
}
|
||||
|
||||
// 淡出
|
||||
if (fadeOut > 0 && frame > bgm.durationInFrames - fadeOut) {
|
||||
volume *= interpolate(
|
||||
frame,
|
||||
[bgm.durationInFrames - fadeOut, bgm.durationInFrames],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: 'clamp' }
|
||||
)
|
||||
}
|
||||
|
||||
return <Audio src={bgm.src} volume={volume} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个片段渲染器 - 支持转场效果
|
||||
*/
|
||||
interface ClipRendererProps {
|
||||
clip: VideoClip & { startFrame: number; endFrame: number }
|
||||
config: EditorConfig
|
||||
transitionType?: 'none' | 'dissolve' | 'fade' | 'slide'
|
||||
transitionDuration: number
|
||||
isLastClip: boolean
|
||||
}
|
||||
|
||||
const ClipRenderer: React.FC<ClipRendererProps> = ({
|
||||
clip,
|
||||
config,
|
||||
transitionType = 'none',
|
||||
transitionDuration,
|
||||
isLastClip
|
||||
}) => {
|
||||
void config
|
||||
const frame = useCurrentFrame()
|
||||
const clipDuration = clip.durationInFrames
|
||||
|
||||
// 计算转场效果
|
||||
let opacity = 1
|
||||
let transform = 'none'
|
||||
|
||||
if (transitionType !== 'none' && transitionDuration > 0) {
|
||||
// 出场转场效果 (在片段末尾)
|
||||
if (!isLastClip && frame > clipDuration - transitionDuration) {
|
||||
const exitProgress = interpolate(
|
||||
frame,
|
||||
[clipDuration - transitionDuration, clipDuration],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
)
|
||||
|
||||
switch (transitionType) {
|
||||
case 'dissolve':
|
||||
case 'fade':
|
||||
opacity = 1 - exitProgress
|
||||
break
|
||||
case 'slide':
|
||||
transform = `translateX(${-exitProgress * 100}%)`
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 入场转场效果 (在片段开头)
|
||||
if (frame < transitionDuration) {
|
||||
const enterProgress = interpolate(
|
||||
frame,
|
||||
[0, transitionDuration],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
)
|
||||
|
||||
switch (transitionType) {
|
||||
case 'dissolve':
|
||||
case 'fade':
|
||||
opacity = enterProgress
|
||||
break
|
||||
case 'slide':
|
||||
transform = `translateX(${(1 - enterProgress) * 100}%)`
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity, transform }}>
|
||||
{/* 视频 */}
|
||||
<Video
|
||||
src={clip.src}
|
||||
startFrom={clip.trim?.from || 0}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 附属配音 */}
|
||||
{clip.attachment?.audio && (
|
||||
<Audio
|
||||
src={clip.attachment.audio.src}
|
||||
volume={clip.attachment.audio.volume}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 附属字幕 */}
|
||||
{clip.attachment?.subtitle && (
|
||||
<SubtitleOverlay
|
||||
text={clip.attachment.subtitle.text}
|
||||
style={clip.attachment.subtitle.style}
|
||||
/>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 字幕叠加层
|
||||
*/
|
||||
interface SubtitleOverlayProps {
|
||||
text: string
|
||||
style: 'default' | 'cinematic'
|
||||
}
|
||||
|
||||
const SubtitleOverlay: React.FC<SubtitleOverlayProps> = ({ text, style }) => {
|
||||
const styles = {
|
||||
default: {
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '24px',
|
||||
color: 'white'
|
||||
},
|
||||
cinematic: {
|
||||
background: 'transparent',
|
||||
padding: '12px 24px',
|
||||
fontSize: '28px',
|
||||
color: 'white',
|
||||
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.8)',
|
||||
fontWeight: 'bold' as const
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
paddingBottom: '60px'
|
||||
}}
|
||||
>
|
||||
<div style={styles[style]}>
|
||||
{text}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoComposition
|
||||
115
src/features/video-editor/remotion/transitions/index.tsx
Normal file
115
src/features/video-editor/remotion/transitions/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react'
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion'
|
||||
|
||||
interface TransitionWrapperProps {
|
||||
type: 'dissolve' | 'fade' | 'slide' | 'none'
|
||||
durationInFrames: number
|
||||
isEntering: boolean // true = entering transition, false = exiting
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 转场效果包装器
|
||||
* 为片段添加进入/退出动画
|
||||
*/
|
||||
export const TransitionWrapper: React.FC<TransitionWrapperProps> = ({
|
||||
type,
|
||||
durationInFrames,
|
||||
isEntering,
|
||||
children
|
||||
}) => {
|
||||
const frame = useCurrentFrame()
|
||||
|
||||
if (type === 'none') {
|
||||
return <AbsoluteFill>{children}</AbsoluteFill>
|
||||
}
|
||||
|
||||
const progress = isEntering
|
||||
? interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: 'clamp' })
|
||||
: interpolate(frame, [0, durationInFrames], [1, 0], { extrapolateRight: 'clamp' })
|
||||
|
||||
const getTransitionStyle = (): React.CSSProperties => {
|
||||
switch (type) {
|
||||
case 'dissolve':
|
||||
case 'fade':
|
||||
return { opacity: progress }
|
||||
|
||||
case 'slide':
|
||||
const translateX = isEntering
|
||||
? interpolate(progress, [0, 1], [100, 0])
|
||||
: interpolate(progress, [1, 0], [0, -100])
|
||||
return {
|
||||
transform: `translateX(${translateX}%)`,
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={getTransitionStyle()}>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 淡入淡出转场
|
||||
*/
|
||||
export const CrossDissolve: React.FC<{
|
||||
durationInFrames: number
|
||||
children: React.ReactNode
|
||||
}> = ({ durationInFrames, children }) => {
|
||||
const frame = useCurrentFrame()
|
||||
const opacity = interpolate(
|
||||
frame,
|
||||
[0, durationInFrames],
|
||||
[0, 1],
|
||||
{ extrapolateRight: 'clamp' }
|
||||
)
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity }}>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 滑动转场
|
||||
*/
|
||||
export const SlideTransition: React.FC<{
|
||||
direction: 'left' | 'right' | 'up' | 'down'
|
||||
durationInFrames: number
|
||||
children: React.ReactNode
|
||||
}> = ({ direction, durationInFrames, children }) => {
|
||||
const frame = useCurrentFrame()
|
||||
|
||||
const getTransform = () => {
|
||||
const progress = interpolate(
|
||||
frame,
|
||||
[0, durationInFrames],
|
||||
[100, 0],
|
||||
{ extrapolateRight: 'clamp' }
|
||||
)
|
||||
|
||||
switch (direction) {
|
||||
case 'left': return `translateX(${progress}%)`
|
||||
case 'right': return `translateX(-${progress}%)`
|
||||
case 'up': return `translateY(${progress}%)`
|
||||
case 'down': return `translateY(-${progress}%)`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ transform: getTransform() }}>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
|
||||
const transitions = { TransitionWrapper, CrossDissolve, SlideTransition }
|
||||
|
||||
export default transitions
|
||||
140
src/features/video-editor/types/editor.types.ts
Normal file
140
src/features/video-editor/types/editor.types.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// ========================================
|
||||
// Video Editor Core Types
|
||||
// Schema Version: 1.0
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 剪辑项目 - 顶层结构
|
||||
*/
|
||||
export interface VideoEditorProject {
|
||||
id: string
|
||||
episodeId: string
|
||||
schemaVersion: '1.0'
|
||||
|
||||
config: EditorConfig
|
||||
|
||||
// 主时间轴 (磁性轨道) - 顺序即时间
|
||||
timeline: VideoClip[]
|
||||
|
||||
// BGM 轨道 (绝对定位)
|
||||
bgmTrack: BgmClip[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器配置
|
||||
*/
|
||||
export interface EditorConfig {
|
||||
fps: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频片段 - 时间轴核心单元
|
||||
*/
|
||||
export interface VideoClip {
|
||||
id: string
|
||||
src: string // COS URL
|
||||
durationInFrames: number // 播放时长
|
||||
|
||||
// 素材内裁剪 (可选)
|
||||
trim?: {
|
||||
from: number // 素材起始帧
|
||||
to: number // 素材结束帧
|
||||
}
|
||||
|
||||
// 附属内容 - 跟随视频移动
|
||||
attachment?: ClipAttachment
|
||||
|
||||
// 转场 (与下一个片段的过渡)
|
||||
transition?: ClipTransition
|
||||
|
||||
// AI 元数据 (用于回溯)
|
||||
metadata: ClipMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* 片段附属内容 (配音 + 字幕)
|
||||
*/
|
||||
export interface ClipAttachment {
|
||||
audio?: {
|
||||
src: string
|
||||
volume: number
|
||||
voiceLineId?: string
|
||||
}
|
||||
subtitle?: {
|
||||
text: string
|
||||
style: 'default' | 'cinematic'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转场效果
|
||||
*/
|
||||
export interface ClipTransition {
|
||||
type: 'none' | 'dissolve' | 'fade' | 'slide'
|
||||
durationInFrames: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 片段元数据
|
||||
*/
|
||||
export interface ClipMetadata {
|
||||
panelId: string
|
||||
storyboardId: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* BGM 片段 - 独立轨道
|
||||
*/
|
||||
export interface BgmClip {
|
||||
id: string
|
||||
src: string
|
||||
startFrame: number // 绝对定位
|
||||
durationInFrames: number
|
||||
volume: number
|
||||
fadeIn?: number
|
||||
fadeOut?: number
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 时间轴 UI 状态
|
||||
// ========================================
|
||||
|
||||
export interface TimelineState {
|
||||
currentFrame: number
|
||||
playing: boolean
|
||||
selectedClipId: string | null
|
||||
zoom: number // 缩放级别 (1 = 100%)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 计算工具类型
|
||||
// ========================================
|
||||
|
||||
export interface ComputedClip extends VideoClip {
|
||||
startFrame: number // 计算得出的起始帧
|
||||
endFrame: number // 计算得出的结束帧
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 相关类型
|
||||
// ========================================
|
||||
|
||||
export interface SaveEditorProjectRequest {
|
||||
projectData: VideoEditorProject
|
||||
}
|
||||
|
||||
export interface RenderRequest {
|
||||
editorProjectId: string
|
||||
format: 'mp4' | 'webm'
|
||||
quality: 'draft' | 'high'
|
||||
}
|
||||
|
||||
export interface RenderStatus {
|
||||
status: 'pending' | 'rendering' | 'completed' | 'failed'
|
||||
progress?: number
|
||||
outputUrl?: string
|
||||
error?: string
|
||||
}
|
||||
47
src/features/video-editor/utils/migration.ts
Normal file
47
src/features/video-editor/utils/migration.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { logWarn as _ulogWarn } from '@/lib/logging/core'
|
||||
import { VideoEditorProject } from '../types/editor.types'
|
||||
|
||||
/**
|
||||
* 版本迁移函数
|
||||
* 将旧版本数据升级到最新版本
|
||||
*/
|
||||
export function migrateProjectData(data: unknown): VideoEditorProject {
|
||||
const project = data as Record<string, unknown>
|
||||
|
||||
// 检查 schema 版本
|
||||
const version = project.schemaVersion as string
|
||||
|
||||
switch (version) {
|
||||
case '1.0':
|
||||
// 当前最新版本,无需迁移
|
||||
return project as unknown as VideoEditorProject
|
||||
|
||||
default:
|
||||
// 未知版本或无版本,尝试作为 1.0 处理
|
||||
_ulogWarn(`Unknown schema version: ${version}, treating as 1.0`)
|
||||
return {
|
||||
...project,
|
||||
schemaVersion: '1.0'
|
||||
} as VideoEditorProject
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证项目数据完整性
|
||||
*/
|
||||
export function validateProjectData(data: unknown): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
const project = data as Record<string, unknown>
|
||||
|
||||
if (!project.id) errors.push('Missing project id')
|
||||
if (!project.episodeId) errors.push('Missing episodeId')
|
||||
if (!project.schemaVersion) errors.push('Missing schemaVersion')
|
||||
if (!project.config) errors.push('Missing config')
|
||||
if (!Array.isArray(project.timeline)) errors.push('Invalid timeline')
|
||||
if (!Array.isArray(project.bgmTrack)) errors.push('Invalid bgmTrack')
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
}
|
||||
94
src/features/video-editor/utils/time-utils.ts
Normal file
94
src/features/video-editor/utils/time-utils.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { VideoClip, ComputedClip, VideoEditorProject } from '../types/editor.types'
|
||||
|
||||
/**
|
||||
* 计算时间轴总时长 (帧数)
|
||||
* 考虑转场重叠
|
||||
*/
|
||||
export function calculateTimelineDuration(clips: VideoClip[]): number {
|
||||
if (clips.length === 0) return 0
|
||||
|
||||
return clips.reduce((total, clip, index) => {
|
||||
let duration = clip.durationInFrames
|
||||
|
||||
// 最后一个片段不减去转场时间
|
||||
if (index < clips.length - 1 && clip.transition) {
|
||||
// 转场会让总时长减少(重叠部分)
|
||||
duration -= Math.floor(clip.transition.durationInFrames / 2)
|
||||
}
|
||||
|
||||
return total + duration
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每个片段的起始帧位置
|
||||
* 用于渲染和 UI 显示
|
||||
*/
|
||||
export function computeClipPositions(clips: VideoClip[]): ComputedClip[] {
|
||||
let currentFrame = 0
|
||||
|
||||
return clips.map((clip, index) => {
|
||||
const startFrame = currentFrame
|
||||
const endFrame = startFrame + clip.durationInFrames
|
||||
|
||||
// 计算下一个片段的起始位置(考虑转场重叠)
|
||||
if (clip.transition && index < clips.length - 1) {
|
||||
currentFrame = endFrame - Math.floor(clip.transition.durationInFrames / 2)
|
||||
} else {
|
||||
currentFrame = endFrame
|
||||
}
|
||||
|
||||
return {
|
||||
...clip,
|
||||
startFrame,
|
||||
endFrame
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 帧数转时间字符串
|
||||
*/
|
||||
export function framesToTime(frames: number, fps: number): string {
|
||||
const totalSeconds = frames / fps
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
const milliseconds = Math.floor((totalSeconds % 1) * 100)
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间字符串转帧数
|
||||
*/
|
||||
export function timeToFrames(time: string, fps: number): number {
|
||||
const [minSec, ms] = time.split('.')
|
||||
const [minutes, seconds] = minSec.split(':').map(Number)
|
||||
const totalSeconds = minutes * 60 + seconds + (parseInt(ms || '0') / 100)
|
||||
return Math.round(totalSeconds * fps)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
export function generateClipId(): string {
|
||||
return `clip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认编辑器项目
|
||||
*/
|
||||
export function createDefaultProject(episodeId: string): VideoEditorProject {
|
||||
return {
|
||||
id: `editor_${Date.now()}`,
|
||||
episodeId,
|
||||
schemaVersion: '1.0',
|
||||
config: {
|
||||
fps: 30,
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
timeline: [],
|
||||
bgmTrack: []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user