feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View 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

View File

@@ -0,0 +1 @@
export { RemotionPreview } from './RemotionPreview'

View 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

View File

@@ -0,0 +1 @@
export { Timeline } from './Timeline'

View 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

View 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

View 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
}
}

View 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
}
}

View 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'

View 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

View 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

View 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
}

View 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
}
}

View 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: []
}
}