chore: sync code and project files

This commit is contained in:
Tony Zhang
2026-01-09 14:09:16 +08:00
parent 3d1fb37769
commit 30d7eb4b35
94 changed files with 12706 additions and 255 deletions

47
web/Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# Video Flow - React 前端 Dockerfile
# 多阶段构建
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
# 复制依赖文件
COPY package.json package-lock.json* pnpm-lock.yaml* ./
# 安装依赖
RUN npm install
# 复制源代码
COPY . .
# 构建
RUN npm run build
# 生产阶段
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制 nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

30
web/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Video Flow Editor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

59
web/nginx.conf Normal file
View File

@@ -0,0 +1,59 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
# SPA 路由支持
location / {
try_files $uri $uri/ /index.html;
}
# API 代理
location /api/ {
proxy_pass http://api:8000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
# 静态资源代理(必须用 ^~ 防止被后面的 regex 缓存规则抢走,导致 /static/* 返回 404
location ^~ /static/ {
proxy_pass http://api:8000/static/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# 缓存前端构建产物(仅 /assets/,避免影响 /static/ 代理)
location ~* ^/assets/.*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

38
web/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "video-flow-editor",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.0",
"zustand": "^4.4.0",
"immer": "^10.0.0",
"lucide-react": "^0.303.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"remotion": "^4.0.0",
"@remotion/player": "^4.0.0",
"@remotion/cli": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

20
web/postcss.config.js Normal file
View File

@@ -0,0 +1,20 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

31
web/src/App.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { EditorPage } from './pages/EditorPage'
import { ProjectsPage } from './pages/ProjectsPage'
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/projects" replace />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/editor/:projectId" element={<EditorPage />} />
</Routes>
</BrowserRouter>
)
}
export default App

6
web/src/ClipItem.tsx Normal file
View File

@@ -0,0 +1,6 @@
// Back-compat re-export
export { ClipItem } from './components/Timeline/ClipItem'
export { default } from './components/Timeline/ClipItem'

6
web/src/EditorPage.tsx Normal file
View File

@@ -0,0 +1,6 @@
// Back-compat re-export (some deployments may reference src/EditorPage.tsx)
export { EditorPage } from './pages/EditorPage'
export { default } from './pages/EditorPage'

6
web/src/Timeline.tsx Normal file
View File

@@ -0,0 +1,6 @@
// Back-compat re-export
export { Timeline } from './components/Timeline/Timeline'
export { default } from './components/Timeline/Timeline'

6
web/src/TrackPanel.tsx Normal file
View File

@@ -0,0 +1,6 @@
// Back-compat re-export
export { TrackPanel } from './components/Timeline/TrackPanel'
export { default } from './components/Timeline/TrackPanel'

6
web/src/TrackRow.tsx Normal file
View File

@@ -0,0 +1,6 @@
// Back-compat re-export
export { TrackRow } from './components/Timeline/TrackRow'
export { default } from './components/Timeline/TrackRow'

View File

@@ -0,0 +1,6 @@
// Back-compat re-export
export { VideoComposition } from './remotion/VideoComposition'
export { default } from './remotion/VideoComposition'

View File

@@ -0,0 +1,366 @@
/**
* 时间轴片段组件
*/
import React, { useRef, useCallback, useEffect, useState } from 'react'
import { type TimelineClip } from '@/store/editorStore'
import { cn, timeToPixels, pixelsToTime } from '@/lib/utils'
interface ClipItemProps {
clip: TimelineClip
trackType: string
pixelsPerSecond: number
trackHeight: number
isSelected: boolean
isLocked: boolean
onSelect: (e: React.MouseEvent) => void
onDrag: (deltaX: number, deltaTime: number) => void
onResize: (edge: 'start' | 'end', deltaTime: number) => void
onCommit?: () => void
}
export const ClipItem: React.FC<ClipItemProps> = ({
clip,
trackType,
pixelsPerSecond,
trackHeight,
isSelected,
isLocked,
onSelect,
onDrag,
onResize,
onCommit,
}) => {
const clipRef = useRef<HTMLDivElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [isResizing, setIsResizing] = useState<'start' | 'end' | null>(null)
const [thumbUrl, setThumbUrl] = useState<string | null>(null)
const [audioPeaks, setAudioPeaks] = useState<number[] | null>(null)
const left = timeToPixels(clip.start, pixelsPerSecond)
const width = timeToPixels(clip.duration, pixelsPerSecond)
// 胶片条对视频片段生成多帧缩略图MVP提升辨识度
useEffect(() => {
if (trackType !== 'video') return
if (!clip.sourceUrl) return
if (thumbUrl) return
// 宽度太小就不抽帧,避免性能压力
if (width < 90) return
let cancelled = false
const src = clip.sourceUrl
const minTile = 110
const frameCount = Math.max(3, Math.min(8, Math.floor(width / minTile)))
const key = `${src}@@${Math.round((clip.trimStart || 0) * 1000)}@@${Math.round((clip.duration || 0) * 1000)}@@${frameCount}`
const cache = (globalThis as any).__vfThumbCache as Map<string, string> | undefined
if (cache?.has(key)) {
setThumbUrl(cache.get(key) || null)
return
}
const run = async () => {
try {
const v = document.createElement('video')
v.muted = true
v.playsInline = true
;(v as any).crossOrigin = 'anonymous'
v.preload = 'metadata'
v.src = src
await new Promise<void>((resolve, reject) => {
const onMeta = () => resolve()
const onErr = () => reject(new Error('video load error'))
v.addEventListener('loadedmetadata', onMeta, { once: true })
v.addEventListener('error', onErr, { once: true })
})
const dur = Number.isFinite(v.duration) ? v.duration : 0
const t0 = clip.trimStart || 0
const clipDur = clip.duration || 0
const canvas = document.createElement('canvas')
const tileW = 120
canvas.width = tileW * frameCount
canvas.height = 90
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, canvas.width, canvas.height)
for (let i = 0; i < frameCount; i++) {
if (cancelled) return
const ratio = frameCount === 1 ? 0 : i / (frameCount - 1)
const rawT = t0 + ratio * Math.max(0, clipDur - 0.1)
const seekT = dur > 0 ? Math.min(Math.max(0, rawT + 0.03), Math.max(0, dur - 0.08)) : Math.max(0, rawT + 0.03)
v.currentTime = seekT
await new Promise<void>((resolve, reject) => {
const onSeek = () => resolve()
const onErr = () => reject(new Error('video seek error'))
v.addEventListener('seeked', onSeek, { once: true })
v.addEventListener('error', onErr, { once: true })
})
ctx.drawImage(v, i * tileW, 0, tileW, canvas.height)
// film-strip separators
ctx.fillStyle = 'rgba(0,0,0,0.22)'
ctx.fillRect(i * tileW, 0, 1, canvas.height)
}
const dataUrl = canvas.toDataURL('image/jpeg', 0.72)
if (cancelled) return
setThumbUrl(dataUrl)
const c = (globalThis as any).__vfThumbCache || new Map<string, string>()
c.set(key, dataUrl)
;(globalThis as any).__vfThumbCache = c
} catch {
// ignore thumbnail failures
}
}
// 尽量把重活放到空闲时间
const ric = (globalThis as any).requestIdleCallback as ((cb: () => void) => number) | undefined
ric ? ric(() => run()) : setTimeout(() => run(), 0)
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trackType, clip.sourceUrl, clip.trimStart, width])
// 音频波形MVP对 audio/bgm 片段抽样波形decodeAudioData -> peaks
useEffect(() => {
if (trackType !== 'audio' && trackType !== 'bgm') return
if (!clip.sourceUrl) return
if (audioPeaks) return
// 旁白通常片段较短,阈值太大则很难看到波形
if (width < 50) return
let cancelled = false
const src = clip.sourceUrl
const points = Math.max(80, Math.min(320, Math.floor(width)))
const trimStart = clip.trimStart ?? 0
const durReq = clip.duration ?? 0
const key = `${src}@@${points}@@${Math.round(trimStart * 1000)}@@${Math.round(durReq * 1000)}`
const cache = (globalThis as any).__vfWaveCache as Map<string, number[]> | undefined
if (cache?.has(key)) {
setAudioPeaks(cache.get(key) || null)
return
}
const run = async () => {
try {
const res = await fetch(src)
const buf = await res.arrayBuffer()
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)()
const audio = await ctx.decodeAudioData(buf.slice(0))
const ch = audio.numberOfChannels ? audio.getChannelData(0) : new Float32Array()
const sr = audio.sampleRate || 44100
const fullLen = ch.length
const startS = Math.max(0, trimStart)
const endS = Math.max(startS, startS + Math.max(0.01, durReq || audio.duration || 0))
const startIdx = Math.min(fullLen, Math.floor(startS * sr))
const endIdx = Math.min(fullLen, Math.floor(endS * sr))
const len = Math.max(1, endIdx - startIdx)
const block = Math.max(1, Math.floor(len / points))
const peaks: number[] = new Array(points).fill(0)
for (let i = 0; i < points; i++) {
const start = startIdx + i * block
const end = Math.min(endIdx, start + block)
let max = 0
for (let j = start; j < end; j++) {
const v = Math.abs(ch[j])
if (v > max) max = v
}
peaks[i] = max
}
// normalize
const m = Math.max(...peaks, 1e-6)
const norm = peaks.map(p => p / m)
if (cancelled) return
setAudioPeaks(norm)
const c = (globalThis as any).__vfWaveCache || new Map<string, number[]>()
c.set(key, norm)
;(globalThis as any).__vfWaveCache = c
} catch {
// ignore
}
}
const ric = (globalThis as any).requestIdleCallback as ((cb: () => void) => number) | undefined
ric ? ric(() => run()) : setTimeout(() => run(), 0)
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trackType, clip.sourceUrl, width])
// 获取轨道颜色
const getTrackColor = () => {
switch (trackType) {
case 'video': return 'bg-track-video'
case 'audio': return 'bg-track-voiceover'
case 'subtitle': return 'bg-track-subtitle'
case 'fancy_text': return 'bg-track-fancy'
case 'bgm': return 'bg-track-bgm'
default: return 'bg-gray-500'
}
}
// 处理拖拽开始
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (isResizing) return
e.stopPropagation()
// 锁定轨道:仍允许选中查看属性,但禁止拖拽移动
onSelect(e)
if (isLocked) return
setIsDragging(true)
const startX = e.clientX
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX
const deltaTime = pixelsToTime(deltaX, pixelsPerSecond)
onDrag(deltaX, deltaTime)
}
const handleMouseUp = () => {
setIsDragging(false)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
onCommit?.()
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [clip.start, pixelsPerSecond, isLocked, isResizing, onSelect, onDrag])
// 处理调整大小开始
const handleResizeStart = useCallback((
e: React.MouseEvent,
edge: 'start' | 'end'
) => {
if (isLocked) return
e.stopPropagation()
setIsResizing(edge)
const startX = e.clientX
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX
const deltaTime = pixelsToTime(edge === 'start' ? deltaX : deltaX, pixelsPerSecond)
onResize(edge, deltaTime)
}
const handleMouseUp = () => {
setIsResizing(null)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
onCommit?.()
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [pixelsPerSecond, isLocked, onResize])
// 获取显示内容
const getClipContent = () => {
if (clip.text) {
return clip.text.slice(0, 20) + (clip.text.length > 20 ? '...' : '')
}
if (clip.sourceUrl) {
return (clip.sourceUrl || '').split('/').pop()?.slice(0, 15) || 'Video'
}
return clip.type
}
const transitionBadge = (() => {
if (trackType !== 'video') return null
const s: any = clip.style || {}
const t = String(s.vTransitionType ?? s.v_transition_type ?? '')
if (!t) return null
const map: Record<string, string> = {
fade: '淡出',
fadeWhite: '淡到白',
flash: '闪白',
zoomOut: '缩小',
zoomIn: '推进',
slideLeft: '左滑',
slideRight: '右滑',
slideUp: '上滑',
slideDown: '下滑',
rotateOut: '旋转',
blurOut: '模糊',
blurFade: '模糊淡出',
desaturate: '去饱和',
colorPop: '色彩增强',
hueShift: '色相偏移',
darken: '变暗',
}
const name = map[t] || t
return (
<div className="absolute left-1 top-1 z-10 px-1.5 py-0.5 rounded bg-black/45 text-[10px] text-white/90 pointer-events-none">
{name}
</div>
)
})()
return (
<div
ref={clipRef}
className={cn(
'vf-clip',
'absolute rounded overflow-hidden cursor-grab select-none',
'transition-shadow',
getTrackColor(),
isSelected && 'ring-2 ring-white ring-offset-1 ring-offset-editor-bg',
isDragging && 'cursor-grabbing opacity-90 shadow-lg',
isLocked && 'cursor-not-allowed'
)}
style={{
left,
width: Math.max(width, 20),
top: 4,
height: trackHeight - 8,
backgroundImage: thumbUrl ? `linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), url(${thumbUrl})` : undefined,
backgroundSize: thumbUrl ? 'auto 100%' : undefined,
backgroundRepeat: thumbUrl ? 'repeat-x' : undefined,
backgroundPosition: 'center',
}}
onMouseDown={handleMouseDown}
>
{transitionBadge}
{/* 左边调整手柄 */}
<div
className={cn(
'absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize',
'hover:bg-white/20'
)}
onMouseDown={(e) => handleResizeStart(e, 'start')}
/>
{/* 内容 */}
<div className="absolute inset-0 px-2 flex items-center overflow-hidden pointer-events-none">
<span className="text-xs text-white/90 font-medium truncate">
{getClipContent()}
</span>
</div>
{/* 音频波形MVP */}
{(trackType === 'audio' || trackType === 'bgm') && audioPeaks && (
<div className="absolute left-0 right-0 bottom-1 h-6 px-1 opacity-90 pointer-events-none">
<svg width="100%" height="100%" viewBox={`0 0 ${audioPeaks.length} 20`} preserveAspectRatio="none">
{audioPeaks.map((p, i) => {
const h = Math.max(1, Math.round(p * 18))
const y = 10 - h / 2
return <rect key={i} x={i} y={y} width={0.9} height={h} fill="rgba(255,255,255,0.72)" />
})}
</svg>
</div>
)}
{/* 右边调整手柄 */}
<div
className={cn(
'absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize',
'hover:bg-white/20'
)}
onMouseDown={(e) => handleResizeStart(e, 'end')}
/>
</div>
)
}
export default ClipItem

View File

@@ -0,0 +1,44 @@
/**
* 播放头组件
*/
import React from 'react'
import { timeToPixels } from '@/lib/utils'
interface PlayheadProps {
currentTime: number
pixelsPerSecond: number
height: number
onPointerDown?: (e: React.PointerEvent<HTMLDivElement>) => void
}
export const Playhead: React.FC<PlayheadProps> = ({
currentTime,
pixelsPerSecond,
height,
onPointerDown,
}) => {
const left = timeToPixels(currentTime, pixelsPerSecond)
return (
<div className="playhead" style={{ left, height }}>
{/* 增大命中区域,提升“拖拽跟手” */}
<div className="absolute -left-2 top-0 bottom-0 w-4 cursor-ew-resize" onPointerDown={onPointerDown} />
</div>
)
}
export default Playhead

View File

@@ -0,0 +1,72 @@
/**
* 时间标尺组件
*/
import React, { useMemo } from 'react'
import { formatTimeShort } from '@/lib/utils'
interface TimeRulerProps {
duration: number
pixelsPerSecond: number
scrollX?: number
}
export const TimeRuler: React.FC<TimeRulerProps> = ({
duration,
pixelsPerSecond,
}) => {
// 计算刻度间隔
const { majorInterval, minorInterval } = useMemo(() => {
if (pixelsPerSecond >= 100) {
return { majorInterval: 1, minorInterval: 0.5 }
} else if (pixelsPerSecond >= 50) {
return { majorInterval: 2, minorInterval: 1 }
} else if (pixelsPerSecond >= 25) {
return { majorInterval: 5, minorInterval: 1 }
} else {
return { majorInterval: 10, minorInterval: 5 }
}
}, [pixelsPerSecond])
// 生成刻度
const ticks = useMemo(() => {
const result: { time: number; isMajor: boolean }[] = []
for (let t = 0; t <= duration; t += minorInterval) {
const isMajor = t % majorInterval === 0
result.push({ time: t, isMajor })
}
return result
}, [duration, majorInterval, minorInterval])
return (
<div className="relative h-full bg-editor-panel">
{ticks.map(({ time, isMajor }) => {
const x = time * pixelsPerSecond
return (
<div
key={time}
className="absolute top-0 flex flex-col items-center"
style={{ left: x }}
>
{/* 刻度线 */}
<div
className={isMajor ? 'w-px h-3 bg-editor-text-muted' : 'w-px h-2 bg-editor-border'}
/>
{/* 时间标签 */}
{isMajor && (
<span className="text-[10px] text-editor-text-muted mt-0.5">
{formatTimeShort(time)}
</span>
)}
</div>
)
})}
</div>
)
}
export default TimeRuler

View File

@@ -0,0 +1,408 @@
/**
* 时间轴编辑器组件
*/
import React, { useEffect, useMemo, useRef, useCallback, useState } from 'react'
import { useEditorStore } from '@/store/editorStore'
import { TrackRow } from './TrackRow'
import { TimeRuler } from './TimeRuler'
import { Playhead } from './Playhead'
import { cn, timeToPixels, pixelsToTime } from '@/lib/utils'
import { Scissors, Trash2 } from 'lucide-react'
const PIXELS_PER_SECOND_BASE = 50
const TRACK_HEIGHT = 48
const RULER_HEIGHT = 28
export const Timeline: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const {
tracks,
totalDuration,
currentTime,
zoom,
scrollX,
snapGuideTime,
setSelectedClips,
clearSelection,
setCurrentTime,
setScrollX,
setZoom,
pushHistory,
splitAtTime,
deleteAtPlayhead,
addClip,
} = useEditorStore()
const visibleTracks = useMemo(() => tracks.filter(t => !t.collapsed), [tracks])
const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false)
const [viewportWidth, setViewportWidth] = useState(800)
const [boxSel, setBoxSel] = useState<null | { x1: number; y1: number; x2: number; y2: number }>(null)
const dragRafRef = useRef<number | null>(null)
const pixelsPerSecond = PIXELS_PER_SECOND_BASE * zoom
const timelineWidth = Math.max(totalDuration * pixelsPerSecond, 800)
// 可视窗口(用于虚拟化渲染,减少大量 clip 节点导致的重排/卡顿)
const viewWindow = useMemo(() => {
const startPx = scrollX
const endPx = scrollX + viewportWidth
const bufferPx = Math.max(200, viewportWidth * 0.5)
const viewStartTime = pixelsToTime(Math.max(0, startPx - bufferPx), pixelsPerSecond)
const viewEndTime = pixelsToTime(endPx + bufferPx, pixelsPerSecond)
return { viewStartTime, viewEndTime }
}, [scrollX, viewportWidth, pixelsPerSecond])
// 监听容器宽度变化ResizeObserver
useEffect(() => {
const el = scrollContainerRef.current
if (!el) return
const update = () => setViewportWidth(el.clientWidth || 800)
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
// 处理时间轴点击 - 移动播放头
const handleTimelineClick = useCallback((e: React.MouseEvent) => {
if (isDraggingPlayhead) return
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left + scrollX
const time = pixelsToTime(x, pixelsPerSecond)
setCurrentTime(Math.max(0, Math.min(time, totalDuration)))
}, [pixelsPerSecond, scrollX, totalDuration, isDraggingPlayhead])
const updatePlayheadFromClientX = useCallback((clientX: number) => {
const scrollEl = scrollContainerRef.current
const rulerEl = containerRef.current
if (!scrollEl || !rulerEl) return
// 以“标尺区域的可视窗口”为基准计算 x并叠加实时 scrollLeft不要用滞后的 store.scrollX
const rulerRect = rulerEl.getBoundingClientRect()
const x = clientX - rulerRect.left - 128 /* track label width */ + (scrollEl.scrollLeft || 0)
const time = pixelsToTime(x, pixelsPerSecond)
const next = Math.max(0, Math.min(time, totalDuration))
// raf 合并高频更新,避免拖拽卡顿
if (dragRafRef.current) cancelAnimationFrame(dragRafRef.current)
dragRafRef.current = requestAnimationFrame(() => {
setCurrentTime(next)
dragRafRef.current = null
})
}, [pixelsPerSecond, totalDuration, setCurrentTime])
// 处理播放头拖拽Pointer capture跟手、不会丢事件
const handlePlayheadPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDraggingPlayhead(true)
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
updatePlayheadFromClientX(e.clientX)
}, [updatePlayheadFromClientX])
const handlePlayheadPointerMove = useCallback((e: React.PointerEvent) => {
if (!isDraggingPlayhead) return
e.preventDefault()
updatePlayheadFromClientX(e.clientX)
}, [isDraggingPlayhead, updatePlayheadFromClientX])
const handlePlayheadPointerUp = useCallback((e: React.PointerEvent) => {
if (!isDraggingPlayhead) return
e.preventDefault()
setIsDraggingPlayhead(false)
try { (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId) } catch {}
}, [isDraggingPlayhead])
// 滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
setZoom(zoom * delta)
} else {
// 水平滚动
setScrollX(scrollX + e.deltaX)
}
}, [zoom, scrollX])
// 同步滚动
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
setScrollX(e.currentTarget.scrollLeft)
}, [])
// 框选(只在空白区域拖拽触发)
const handleContentMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return
if (isDraggingPlayhead) return
const target = e.target as HTMLElement
if (target?.closest?.('.vf-clip')) return
if (!scrollContainerRef.current || !contentRef.current) return
const scrollEl = scrollContainerRef.current
const rect = contentRef.current.getBoundingClientRect()
const startX = e.clientX - rect.left + scrollEl.scrollLeft
const startY = e.clientY - rect.top + scrollEl.scrollTop
setBoxSel({ x1: startX, y1: startY, x2: startX, y2: startY })
const onMove = (me: MouseEvent) => {
const x = me.clientX - rect.left + scrollEl.scrollLeft
const y = me.clientY - rect.top + scrollEl.scrollTop
setBoxSel((cur) => cur ? ({ ...cur, x2: x, y2: y }) : null)
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
setBoxSel((cur) => {
if (!cur) return null
const xMin = Math.min(cur.x1, cur.x2)
const xMax = Math.max(cur.x1, cur.x2)
const yMin = Math.min(cur.y1, cur.y2)
const yMax = Math.max(cur.y1, cur.y2)
const timeMin = pixelsToTime(xMin, pixelsPerSecond)
const timeMax = pixelsToTime(xMax, pixelsPerSecond)
// 小拖拽视为“点空白取消选择”
if (Math.abs(xMax - xMin) < 4 && Math.abs(yMax - yMin) < 4) {
clearSelection()
return null
}
const picked: string[] = []
visibleTracks.forEach((t, idx) => {
const top = idx * TRACK_HEIGHT
const bottom = top + TRACK_HEIGHT
if (bottom < yMin || top > yMax) return
for (const c of t.clips) {
const start = c.start ?? 0
const end = start + (c.duration ?? 0)
if (end < timeMin || start > timeMax) continue
picked.push(c.id)
}
})
setSelectedClips(picked)
return null
})
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}, [visibleTracks, pixelsPerSecond, isDraggingPlayhead, clearSelection, setSelectedClips])
const handleDropAsset = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
const raw = e.dataTransfer.getData('application/json') || ''
if (!raw) return
let payload: any = null
try { payload = JSON.parse(raw) } catch { payload = null }
if (!payload) return
const vTrack = tracks.find(t => t.type === 'video')
const stickerTrack = tracks.find(t => t.type === 'sticker')
const scrollEl = scrollContainerRef.current
const rect = contentRef.current?.getBoundingClientRect()
const x = rect ? (e.clientX - rect.left + (scrollEl?.scrollLeft || 0)) : (scrollEl?.scrollLeft || 0)
const t = Math.max(0, pixelsToTime(x, pixelsPerSecond))
if (payload.kind === 'asset' && payload.assetType === 'video') {
if (!vTrack) return
const url = String(payload.url || '')
if (!url) return
pushHistory({ label: '添加素材', icon: 'media' })
addClip(vTrack.id, {
type: 'video',
start: t,
duration: 3,
trimStart: 0,
trimEnd: 3,
sourceUrl: url,
sourcePath: payload.localPath ? String(payload.localPath) : undefined,
})
return
}
if (payload.kind === 'sticker') {
if (!stickerTrack) return
const url = String(payload.url || '')
if (!url) return
pushHistory({ label: '添加贴纸', icon: 'sticker' })
addClip(stickerTrack.id, {
type: 'sticker',
start: t,
duration: 2,
sourceUrl: url,
text: String(payload.name || ''),
position: { x: 0.8, y: 0.2 },
style: { scale: 1.0, rotate: 0 },
})
return
}
}, [tracks, pixelsPerSecond, pushHistory, addClip])
return (
<div
ref={containerRef}
className="h-full flex flex-col bg-editor-bg select-none"
onWheel={handleWheel}
>
{/* 时间标尺 */}
<div
className="shrink-0 bg-editor-panel border-b border-editor-border overflow-hidden"
style={{ height: RULER_HEIGHT }}
>
<div className="flex">
{/* 轨道标签占位 */}
<div className="w-32 shrink-0 bg-editor-panel" />
{/* 标尺 */}
<div
className="relative cursor-pointer"
style={{ width: timelineWidth }}
onClick={handleTimelineClick}
onPointerMove={handlePlayheadPointerMove}
onPointerUp={handlePlayheadPointerUp}
onPointerCancel={handlePlayheadPointerUp}
>
<TimeRuler
duration={totalDuration}
pixelsPerSecond={pixelsPerSecond}
scrollX={scrollX}
/>
{/* 播放头顶部标记 */}
<div
className="absolute top-0 w-4 h-4 -ml-2 cursor-ew-resize"
style={{ left: timeToPixels(currentTime, pixelsPerSecond) - scrollX }}
onPointerDown={handlePlayheadPointerDown}
>
<div className="w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[8px] border-t-red-500" />
</div>
{/* 红线附近快捷操作:剪切/删除(不跟着素材 hover 走) */}
<div
className="absolute top-1 -ml-2 flex gap-1 z-20"
style={{ left: timeToPixels(currentTime, pixelsPerSecond) - scrollX }}
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
className="w-7 h-7 rounded bg-black/55 hover:bg-black/70 flex items-center justify-center"
title="剪切(以红线为准)"
onClick={() => {
pushHistory({ label: '剪切', icon: 'scissors' })
splitAtTime(currentTime)
}}
>
<Scissors className="w-4 h-4 text-white/90" />
</button>
<button
type="button"
className="w-7 h-7 rounded bg-black/55 hover:bg-black/70 flex items-center justify-center"
title="删除(红线所在片段)"
onClick={() => {
pushHistory({ label: '删除片段', icon: 'trash' })
deleteAtPlayhead()
}}
>
<Trash2 className="w-4 h-4 text-white/90" />
</button>
</div>
</div>
</div>
</div>
{/* 轨道区域 */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-auto"
onScroll={handleScroll}
>
<div className="flex min-h-full">
{/* 轨道标签 */}
<div className="w-32 shrink-0 bg-editor-panel border-r border-editor-border sticky left-0 z-10">
{visibleTracks.map(track => (
<div
key={track.id}
className={cn(
'flex items-center px-3 border-b border-editor-border',
'text-sm text-editor-text-muted'
)}
style={{ height: TRACK_HEIGHT }}
>
<div
className={cn(
'w-1.5 h-4 rounded-sm mr-2',
track.type === 'video' && 'bg-track-video',
track.type === 'audio' && 'bg-track-voiceover',
track.type === 'subtitle' && 'bg-track-subtitle',
track.type === 'fancy_text' && 'bg-track-fancy',
track.type === 'bgm' && 'bg-track-bgm',
)}
/>
<span className="truncate">{track.name}</span>
</div>
))}
</div>
{/* 轨道内容 */}
<div
className="relative"
style={{ width: timelineWidth, minHeight: visibleTracks.length * TRACK_HEIGHT }}
ref={contentRef}
onMouseDown={handleContentMouseDown}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }}
onDrop={handleDropAsset}
>
{visibleTracks.map((track, index) => (
<TrackRow
key={track.id}
track={track}
pixelsPerSecond={pixelsPerSecond}
trackHeight={TRACK_HEIGHT}
top={index * TRACK_HEIGHT}
viewStartTime={viewWindow.viewStartTime}
viewEndTime={viewWindow.viewEndTime}
onClipChange={() => pushHistory()}
/>
))}
{/* 播放头线 */}
<Playhead
currentTime={currentTime}
pixelsPerSecond={pixelsPerSecond}
height={visibleTracks.length * TRACK_HEIGHT}
onPointerDown={handlePlayheadPointerDown}
/>
{/* 吸附/对齐辅助线 */}
{typeof snapGuideTime === 'number' && (
<div
className="absolute top-0 bottom-0 w-px bg-yellow-400/80 pointer-events-none"
style={{ left: timeToPixels(snapGuideTime, pixelsPerSecond) }}
/>
)}
{/* 框选矩形 */}
{boxSel && (
<div
className="absolute border border-editor-accent bg-editor-accent/10 pointer-events-none"
style={{
left: Math.min(boxSel.x1, boxSel.x2),
top: Math.min(boxSel.y1, boxSel.y2),
width: Math.abs(boxSel.x2 - boxSel.x1),
height: Math.abs(boxSel.y2 - boxSel.y1),
}}
/>
)}
</div>
</div>
</div>
</div>
)
}
export default Timeline

View File

@@ -0,0 +1,872 @@
/**
* 轨道属性面板
*/
import React, { useEffect, useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import {
Volume2,
VolumeX,
Lock,
Unlock,
Trash2,
RefreshCw,
Headphones,
ChevronRight,
ChevronDown,
ChevronUp,
AlertTriangle,
} from 'lucide-react'
import { useEditorStore } from '@/store/editorStore'
import { editorApi, assetsApi } from '@/lib/api'
import { cn, formatTimeShort, generateId, parseTimeInput } from '@/lib/utils'
export const TrackPanel: React.FC = () => {
const {
tracks,
selectedClipId,
selectedClipIds,
soloTrackIds,
toggleTrackSolo,
toggleTrackMute,
toggleTrackLock,
toggleTrackCollapse,
updateClip,
deleteClip,
groupSelected,
ungroupSelected,
pushHistory,
} = useEditorStore()
const { data: fontList } = useQuery({
queryKey: ['font-list'],
queryFn: assetsApi.getFonts,
})
// 找到选中的片段
const selectedClip = tracks
.flatMap(t => t.clips.map(c => ({ ...c, trackId: t.id, trackType: t.type })))
.find(c => c.id === selectedClipId)
// TTS 重新生成
const ttsMutation = useMutation({
mutationFn: (params: { text: string; targetDuration?: number }) =>
editorApi.generateVoiceover(params.text, undefined, params.targetDuration),
onSuccess: (data) => {
if (selectedClip && data.url) {
updateClip(selectedClip.trackId, selectedClip.id, {
sourceUrl: data.url,
sourcePath: data.path,
sourceDuration: typeof data.duration === 'number' && Number.isFinite(data.duration) ? data.duration : (selectedClip as any).sourceDuration,
needsVoiceoverRegenerate: false,
})
pushHistory()
}
},
})
// 花字重新生成
const fancyTextMutation = useMutation({
mutationFn: (params: { text: string; style?: Record<string, unknown> }) =>
editorApi.generateFancyText(params.text, params.style),
onSuccess: (data) => {
if (selectedClip && data.url) {
updateClip(selectedClip.trackId, selectedClip.id, {
sourceUrl: data.url,
sourcePath: data.path,
})
pushHistory()
}
},
})
const [editingText, setEditingText] = useState('')
const [isEditing, setIsEditing] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
// 基础更友好的时间输入M:SS
const [basicTime, setBasicTime] = useState({ start: '', end: '' })
// 高级:精确输入(秒)
const [timing, setTiming] = useState({ start: '', duration: '', trimStart: '', trimEnd: '' })
useEffect(() => {
if (!selectedClip) return
const s = Number(selectedClip.start ?? 0)
const d = Number(selectedClip.duration ?? 0)
setBasicTime({
start: formatTimeShort(Math.max(0, s)),
end: formatTimeShort(Math.max(0, s + d)),
})
setTiming({
start: String(selectedClip.start ?? 0),
duration: String(selectedClip.duration ?? 0),
trimStart: String(selectedClip.trimStart ?? 0),
trimEnd: String(selectedClip.trimEnd ?? ((selectedClip.trimStart ?? 0) + (selectedClip.duration ?? 0))),
})
}, [selectedClipId, selectedClip?.start, selectedClip?.duration, selectedClip?.trimStart, selectedClip?.trimEnd])
// 开始编辑文本
const startEditing = () => {
if (selectedClip?.text) {
setEditingText(selectedClip.text)
setIsEditing(true)
}
}
// 保存文本
const saveText = () => {
if (!selectedClip) return
updateClip(selectedClip.trackId, selectedClip.id, { text: editingText })
setIsEditing(false)
pushHistory()
}
const saveStyle = (patch: Record<string, unknown>) => {
if (!selectedClip) return
updateClip(selectedClip.trackId, selectedClip.id, { style: { ...(selectedClip.style || {}), ...patch } })
pushHistory()
}
const saveTiming = () => {
if (!selectedClip) return
const toNum = (v: string) => {
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
const minDur = 0.5
let start = Math.max(0, toNum(timing.start))
let duration = Math.max(minDur, toNum(timing.duration))
let trimStart = Math.max(0, toNum(timing.trimStart))
let trimEnd = toNum(timing.trimEnd)
if (!Number.isFinite(trimEnd) || trimEnd <= 0) trimEnd = trimStart + duration
// normalize trimEnd to duration
trimEnd = trimStart + duration
updateClip(selectedClip.trackId, selectedClip.id, { start, duration, trimStart, trimEnd })
pushHistory()
}
const saveBasicTime = () => {
if (!selectedClip) return
const minDur = 0.5
const start = parseTimeInput(basicTime.start)
const end = parseTimeInput(basicTime.end)
if (start == null || end == null) return
const s = Math.max(0, start)
const d = Math.max(minDur, end - s)
updateClip(selectedClip.trackId, selectedClip.id, { start: s, duration: d })
pushHistory()
}
const saveAudioParams = (params: { volume?: number; fadeIn?: number; fadeOut?: number; ducking?: boolean; duckVolume?: number; playbackRate?: number }) => {
if (!selectedClip) return
updateClip(selectedClip.trackId, selectedClip.id, {
...(typeof params.volume === 'number' && Number.isFinite(params.volume) ? { volume: Math.max(0, Math.min(1, params.volume)) } : {}),
...(typeof params.fadeIn === 'number' && Number.isFinite(params.fadeIn) ? { fadeIn: Math.max(0, params.fadeIn) } : {}),
...(typeof params.fadeOut === 'number' && Number.isFinite(params.fadeOut) ? { fadeOut: Math.max(0, params.fadeOut) } : {}),
...(typeof params.ducking === 'boolean' ? { ducking: params.ducking } : {}),
...(typeof params.duckVolume === 'number' && Number.isFinite(params.duckVolume) ? { duckVolume: Math.max(0.05, Math.min(1, params.duckVolume)) } : {}),
...(typeof params.playbackRate === 'number' && Number.isFinite(params.playbackRate) ? { playbackRate: Math.max(0.5, Math.min(2.0, params.playbackRate)) } : {}),
})
pushHistory()
}
// 重新生成 TTS
const regenerateTTS = () => {
if (!selectedClip?.text) return
ttsMutation.mutate({
text: selectedClip.text,
targetDuration: selectedClip.duration,
})
}
// 重新生成花字
const regenerateFancyText = () => {
if (!selectedClip?.text) return
fancyTextMutation.mutate({
text: selectedClip.text,
style: selectedClip.style,
})
}
// 删除片段
const handleDeleteClip = () => {
if (!selectedClip) return
deleteClip(selectedClip.trackId, selectedClip.id)
pushHistory()
}
// 绑定到视频片段(用于字幕/旁白联动)
const bindToVideo = () => {
if (!selectedClip) return
// 只对字幕/旁白有意义
const isSubtitle = selectedClip.trackType === 'subtitle'
const isVoice = selectedClip.trackId === 'audio-voiceover'
if (!isSubtitle && !isVoice) return
const videoTrack = tracks.find(t => t.type === 'video')
if (!videoTrack) return
const s = selectedClip.start ?? 0
const e = s + (selectedClip.duration ?? 0)
// 选 overlap 最大的视频片段
let best: any = null
let bestOverlap = 0
for (const v of videoTrack.clips) {
const vs = v.start ?? 0
const ve = vs + (v.duration ?? 0)
const overlap = Math.max(0, Math.min(e, ve) - Math.max(s, vs))
if (overlap > bestOverlap) {
bestOverlap = overlap
best = v
}
}
if (!best) return
const gid = best.groupId || `grp_${generateId()}`
if (!best.groupId) {
updateClip(videoTrack.id, best.id, { groupId: gid })
}
updateClip(selectedClip.trackId, selectedClip.id, { groupId: gid })
pushHistory()
}
return (
<div className="h-full flex flex-col">
{/* 轨道列表 */}
<div className="p-4 border-b border-editor-border">
<h3 className="text-sm font-medium text-editor-text mb-2"></h3>
<p className="text-xs text-editor-text-muted mb-3">
线 <span className="font-mono">S</span>
</p>
<div className="space-y-2">
{tracks.map(track => (
<div
key={track.id}
className={cn(
'flex items-center justify-between p-2 rounded-lg',
'bg-editor-surface hover:bg-editor-hover transition-colors'
)}
>
<div className="flex items-center gap-2 min-w-0">
<button
onClick={() => toggleTrackCollapse(track.id)}
className="p-1 rounded text-editor-text-muted hover:text-editor-text"
title={track.collapsed ? '展开轨道' : '折叠轨道'}
>
{track.collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<span className="text-sm text-editor-text truncate">{track.name}</span>
</div>
<div className="flex items-center gap-1">
{showAdvanced && (
<button
onClick={() => toggleTrackSolo(track.id)}
className={cn(
'p-1 rounded',
(soloTrackIds || []).includes(track.id) ? 'text-green-400' : 'text-editor-text-muted hover:text-editor-text'
)}
title="只听这一条轨道(高级)"
>
<Headphones className="w-4 h-4" />
</button>
)}
<button
onClick={() => toggleTrackMute(track.id)}
className={cn(
'p-1 rounded',
track.muted ? 'text-red-400' : 'text-editor-text-muted hover:text-editor-text'
)}
title={track.muted ? '取消静音' : '静音'}
>
{track.muted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
</button>
<button
onClick={() => toggleTrackLock(track.id)}
className={cn(
'p-1 rounded',
track.locked ? 'text-yellow-400' : 'text-editor-text-muted hover:text-editor-text'
)}
title={track.locked ? '已锁定:不可拖动/修改' : '锁定:防误触'}
>
{track.locked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
</button>
</div>
</div>
))}
</div>
<button
onClick={() => setShowAdvanced(v => !v)}
className="mt-3 w-full py-1.5 rounded bg-editor-surface text-editor-text text-sm hover:bg-editor-hover flex items-center justify-center gap-1"
>
{showAdvanced ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{showAdvanced ? '收起高级选项' : '显示高级选项'}
</button>
</div>
{/* 选中片段属性 */}
{selectedClip && (
<div className="p-4 border-b border-editor-border">
<h3 className="text-sm font-medium text-editor-text mb-3"></h3>
<div className="space-y-3">
{/* 基础时间(新手友好) */}
<div className="space-y-2">
<div className="text-xs text-editor-text-muted">M:SS</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-editor-text-muted text-xs"></label>
<input
value={basicTime.start}
onChange={(e) => setBasicTime({ ...basicTime, start: e.target.value })}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
placeholder="0:00"
/>
</div>
<div>
<label className="text-editor-text-muted text-xs"></label>
<input
value={basicTime.end}
onChange={(e) => setBasicTime({ ...basicTime, end: e.target.value })}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
placeholder="0:02"
/>
</div>
</div>
<button
onClick={saveBasicTime}
className="w-full py-1.5 rounded bg-editor-surface text-editor-text text-sm hover:bg-editor-hover"
>
</button>
<div className="text-xs text-editor-text-muted">
</div>
</div>
{/* 高级:精确时间/裁剪(秒) */}
{showAdvanced && (
<div className="space-y-2 pt-2 border-t border-editor-border">
<div className="text-xs text-editor-text-muted"></div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-editor-text-muted text-xs">(s)</label>
<input
value={timing.start}
onChange={(e) => setTiming({ ...timing, start: e.target.value })}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
/>
</div>
<div>
<label className="text-editor-text-muted text-xs">(s)</label>
<input
value={timing.duration}
onChange={(e) => setTiming({ ...timing, duration: e.target.value })}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
/>
</div>
</div>
{selectedClip.trackType === 'video' && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-editor-text-muted text-xs">trimStart(s)</label>
<input
value={timing.trimStart}
onChange={(e) => setTiming({ ...timing, trimStart: e.target.value })}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
/>
</div>
<div>
<label className="text-editor-text-muted text-xs">trimEnd(s)</label>
<input
value={timing.trimEnd}
onChange={(e) => setTiming({ ...timing, trimEnd: e.target.value })}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
/>
</div>
</div>
)}
<button
onClick={saveTiming}
className="w-full py-1.5 rounded bg-editor-surface text-editor-text text-sm hover:bg-editor-hover"
>
</button>
</div>
)}
{/* 文本编辑 */}
{selectedClip.text !== undefined && (
<div>
<label className="text-editor-text-muted text-xs block mb-1"></label>
{(selectedClip.trackType === 'subtitle' || selectedClip.trackType === 'fancy_text') && !isEditing && (
<div className="mb-2 text-[11px] text-editor-text-muted">
<strong></strong>/
</div>
)}
{isEditing ? (
<div className="space-y-2">
<textarea
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
className={cn(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-editor-bg border border-editor-border',
'text-editor-text focus:outline-none focus:border-editor-accent'
)}
rows={3}
/>
<div className="flex gap-2">
<button
onClick={saveText}
className="flex-1 py-1.5 rounded bg-editor-accent text-white text-sm font-medium"
>
</button>
<button
onClick={() => setIsEditing(false)}
className="flex-1 py-1.5 rounded bg-editor-surface text-editor-text text-sm"
>
</button>
</div>
</div>
) : (
<div className="space-y-2">
<div className={cn('px-3 py-2 rounded-lg text-sm', 'bg-editor-bg border border-editor-border', 'text-editor-text')}>
{selectedClip.text || '(空)'}
</div>
<button
onClick={startEditing}
className="w-full py-1.5 rounded bg-editor-surface text-editor-text text-sm hover:bg-editor-hover"
>
</button>
</div>
)}
</div>
)}
{/* 字幕/花字/贴纸 */}
{(selectedClip.trackType === 'subtitle' || selectedClip.trackType === 'fancy_text' || selectedClip.trackType === 'sticker') && (
<div className="space-y-2 pt-2 border-t border-editor-border">
<div className="text-xs text-editor-text-muted">
{selectedClip.trackType === 'sticker' ? '贴纸(位置 / 大小)' : '样式(字体 / 颜色 / B I U'}
</div>
{selectedClip.trackType === 'sticker' && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-editor-text-muted text-xs block mb-1"></label>
<input
type="range"
min={50}
max={200}
step={5}
value={Math.round((Number((selectedClip.style as any)?.scale ?? 1) || 1) * 100)}
onChange={(e) => saveStyle({ scale: Math.max(0.5, Math.min(2.0, Number(e.target.value) / 100)) })}
className="w-full"
/>
<div className="text-xs text-editor-text-muted text-right">
{(Number((selectedClip.style as any)?.scale ?? 1) || 1).toFixed(2)}x
</div>
</div>
<div>
<label className="text-editor-text-muted text-xs block mb-1"></label>
<input
type="range"
min={-180}
max={180}
step={5}
value={Math.round(Number((selectedClip.style as any)?.rotate ?? 0) || 0)}
onChange={(e) => saveStyle({ rotate: Number(e.target.value) || 0 })}
className="w-full"
/>
<div className="text-xs text-editor-text-muted text-right">
{Math.round(Number((selectedClip.style as any)?.rotate ?? 0) || 0)}°
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-editor-text-muted text-xs block mb-1">X0~1</label>
<input
type="number"
step={0.01}
value={String(Number((selectedClip.position as any)?.x ?? 0.8))}
onChange={(e) => {
const base = (selectedClip.position || { x: 0.8, y: 0.2 }) as any
updateClip(selectedClip.trackId, selectedClip.id, { position: { x: Math.max(0, Math.min(1, Number(e.target.value) || 0)), y: base.y ?? 0.2 } })
}}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
/>
</div>
<div>
<label className="text-editor-text-muted text-xs block mb-1">Y0~1</label>
<input
type="number"
step={0.01}
value={String(Number((selectedClip.position as any)?.y ?? 0.2))}
onChange={(e) => {
const base = (selectedClip.position || { x: 0.8, y: 0.2 }) as any
updateClip(selectedClip.trackId, selectedClip.id, { position: { x: base.x ?? 0.8, y: Math.max(0, Math.min(1, Number(e.target.value) || 0)) } })
}}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
/>
</div>
</div>
<div className="text-[11px] text-editor-text-muted">
</div>
</div>
)}
{selectedClip.trackType !== 'sticker' && (
<>
<div className="grid grid-cols-2 gap-2">
<div className="col-span-2">
<label className="text-editor-text-muted text-xs block mb-1"></label>
<select
value={String(((selectedClip.style as any)?.font_family ?? '') as any)}
onChange={(e) => {
const v = e.target.value
saveStyle({ font_family: v || undefined })
}}
className={cn('w-full px-2 py-1 rounded text-sm', 'bg-editor-bg border border-editor-border text-editor-text')}
>
<option value=""></option>
{(Array.isArray(fontList) ? fontList : []).map((f: any) => {
// custom font用 filename 作为 font_familyrenderer 可解析,预览也能 FontFace 加载)
// system font用绝对 path 便于导出;预览侧会自动回退为 PingFang
const val = f.url ? (f.css_family || f.id) : (f.path || f.id)
return (
<option key={String(f.id)} value={String(val)}>
{String(f.name || f.id)}
</option>
)
})}
</select>
</div>
<div>
<label className="text-editor-text-muted text-xs block mb-1"></label>
<input
type="number"
min={10}
max={160}
step={1}
value={String(Number((selectedClip.style as any)?.font_size ?? (selectedClip.trackType === 'subtitle' ? 60 : 72)))}
onChange={(e) => saveStyle({ font_size: Math.max(10, Math.min(160, Number(e.target.value) || 60)) })}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
/>
</div>
<div>
<label className="text-editor-text-muted text-xs block mb-1"></label>
<input
type="color"
value={String((selectedClip.style as any)?.font_color ?? '#ffffff')}
onChange={(e) => saveStyle({ font_color: e.target.value })}
className="w-full h-9 rounded bg-editor-bg border border-editor-border"
/>
</div>
<div className="col-span-2">
<label className="text-editor-text-muted text-xs block mb-1"></label>
<input
type="range"
min={20}
max={100}
step={1}
value={Math.round((Number((selectedClip.style as any)?.box_w ?? (selectedClip.trackType === 'subtitle' ? 0.8 : 0.7)) || 0.8) * 100)}
onChange={(e) => saveStyle({ box_w: Math.max(0.2, Math.min(1, Number(e.target.value) / 100)) })}
className="w-full"
/>
<div className="text-xs text-editor-text-muted text-right">
{Math.round((Number((selectedClip.style as any)?.box_w ?? (selectedClip.trackType === 'subtitle' ? 0.8 : 0.7)) || 0.8) * 100)}%
</div>
<div className="text-[11px] text-editor-text-muted">
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
className={cn('px-2 py-1 rounded text-sm', (selectedClip.style as any)?.bold ? 'bg-editor-accent text-white' : 'bg-editor-surface text-editor-text')}
onClick={() => saveStyle({ bold: !((selectedClip.style as any)?.bold) })}
title="加粗"
>
B
</button>
<button
className={cn('px-2 py-1 rounded text-sm italic', (selectedClip.style as any)?.italic ? 'bg-editor-accent text-white' : 'bg-editor-surface text-editor-text')}
onClick={() => saveStyle({ italic: !((selectedClip.style as any)?.italic) })}
title="斜体"
>
I
</button>
<button
className={cn('px-2 py-1 rounded text-sm underline', (selectedClip.style as any)?.underline ? 'bg-editor-accent text-white' : 'bg-editor-surface text-editor-text')}
onClick={() => saveStyle({ underline: !((selectedClip.style as any)?.underline) })}
title="下划线"
>
U
</button>
<div className="flex-1" />
<div className="text-xs text-editor-text-muted"></div>
</div>
</>
)}
</div>
)}
{/* 视频转场(基础:淡入/淡出到黑,不需要重叠) */}
{showAdvanced && selectedClip.trackType === 'video' && (
<div className="space-y-2 pt-2 border-t border-editor-border">
<div className="text-xs text-editor-text-muted"></div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-editor-text-muted text-xs">(s)</label>
<input
type="number"
step="0.05"
value={String(Number((selectedClip.style as any)?.vFadeIn ?? (selectedClip.style as any)?.v_fade_in ?? 0))}
onChange={(e) => saveStyle({ vFadeIn: Math.max(0, Number(e.target.value) || 0) })}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
/>
</div>
<div>
<label className="text-editor-text-muted text-xs">(s)</label>
<input
type="number"
step="0.05"
value={String(Number((selectedClip.style as any)?.vFadeOut ?? (selectedClip.style as any)?.v_fade_out ?? 0))}
onChange={(e) => saveStyle({ vFadeOut: Math.max(0, Number(e.target.value) || 0) })}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
/>
</div>
</div>
<div className="text-xs text-editor-text-muted">
/
</div>
</div>
)}
{/* 音频参数(旁白/BGM/视频原声) */}
{(selectedClip.trackType === 'audio' || selectedClip.trackType === 'bgm' || selectedClip.trackType === 'video') && (
<div className="space-y-2">
<div className="text-xs text-editor-text-muted"></div>
{selectedClip.trackId === 'audio-voiceover' && (
<div>
<label className="text-editor-text-muted text-xs"></label>
<div className="flex items-center gap-2">
<input
type="range"
min={50}
max={200}
step={5}
value={Math.round((Number((selectedClip as any).playbackRate ?? 1) || 1) * 100)}
onChange={(e) => saveAudioParams({ playbackRate: Math.max(0.5, Math.min(2.0, Number(e.target.value) / 100)) })}
className="flex-1"
/>
<span className="text-xs text-editor-text-muted w-12 text-right">
{(Number((selectedClip as any).playbackRate ?? 1) || 1).toFixed(2)}x
</span>
</div>
<div className="text-[11px] text-editor-text-muted">
/
</div>
</div>
)}
<div>
<label className="text-editor-text-muted text-xs"></label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={100}
value={Math.round((((selectedClip as any).volume ?? (selectedClip.trackType === 'bgm' ? 0.15 : selectedClip.trackType === 'video' ? 0.2 : 1)) as number) * 100)}
onChange={(e) => saveAudioParams({ volume: Number(e.target.value) / 100 })}
className="flex-1"
/>
<span className="text-xs text-editor-text-muted w-10 text-right">
{Math.round((((selectedClip as any).volume ?? (selectedClip.trackType === 'bgm' ? 0.15 : selectedClip.trackType === 'video' ? 0.2 : 1)) as number) * 100)}%
</span>
</div>
</div>
{showAdvanced && (
<div className="grid grid-cols-2 gap-2 pt-1">
<div>
<label className="text-editor-text-muted text-xs">(s)</label>
<input
type="number"
step="0.05"
value={String((selectedClip as any).fadeIn ?? (selectedClip.trackType === 'bgm' ? 0.8 : 0.05))}
onChange={(e) => saveAudioParams({ fadeIn: Number(e.target.value) })}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
/>
</div>
<div>
<label className="text-editor-text-muted text-xs">(s)</label>
<input
type="number"
step="0.05"
value={String((selectedClip as any).fadeOut ?? (selectedClip.trackType === 'bgm' ? 0.8 : 0.05))}
onChange={(e) => saveAudioParams({ fadeOut: Number(e.target.value) })}
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
/>
</div>
</div>
)}
{/* BGM 自动压低(高级) */}
{showAdvanced && selectedClip.trackType === 'bgm' && (
<div className="space-y-2 pt-1">
<label className="flex items-center justify-between text-sm text-editor-text">
<span></span>
<input
type="checkbox"
checked={typeof (selectedClip as any).ducking === 'boolean' ? (selectedClip as any).ducking : true}
onChange={(e) => saveAudioParams({ ducking: e.target.checked })}
/>
</label>
<div>
<label className="text-editor-text-muted text-xs"></label>
<input
type="range"
min={5}
max={100}
value={Math.round((((selectedClip as any).duckVolume ?? 0.25) as number) * 100)}
onChange={(e) => saveAudioParams({ duckVolume: Number(e.target.value) / 100 })}
className="w-full"
/>
<div className="text-xs text-editor-text-muted text-right">
{Math.round((((selectedClip as any).duckVolume ?? 0.25) as number) * 100)}%
</div>
</div>
</div>
)}
</div>
)}
{/* 操作按钮 */}
<div className="flex gap-2">
{selectedClip.trackId === 'audio-voiceover' && (
<button
onClick={regenerateTTS}
disabled={ttsMutation.isPending}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm',
'bg-editor-surface text-editor-text hover:bg-editor-hover transition-colors',
ttsMutation.isPending && 'opacity-50'
)}
>
<RefreshCw className={cn('w-4 h-4', ttsMutation.isPending && 'animate-spin')} />
{(selectedClip as any).needsVoiceoverRegenerate ? '重新生成(适配时长)' : '生成配音'}
</button>
)}
{selectedClip.trackType === 'fancy_text' && (
<button
onClick={regenerateFancyText}
disabled={fancyTextMutation.isPending}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm',
'bg-editor-surface text-editor-text hover:bg-editor-hover transition-colors',
fancyTextMutation.isPending && 'opacity-50'
)}
>
<RefreshCw className={cn('w-4 h-4', fancyTextMutation.isPending && 'animate-spin')} />
</button>
)}
<button
onClick={handleDeleteClip}
className={cn(
'p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors'
)}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* 分组/联动 */}
{showAdvanced && (
<div className="pt-2 border-t border-editor-border">
<div className="flex items-center justify-between">
<span className="text-xs text-editor-text-muted"></span>
<span className="text-xs text-editor-text">
{(selectedClip as any).groupId ? String((selectedClip as any).groupId) : '—'}
</span>
</div>
<div className="flex gap-2 mt-2">
<button
onClick={() => { groupSelected(); pushHistory() }}
disabled={(selectedClipIds || []).length < 2}
className={cn(
'flex-1 py-1.5 rounded text-sm',
'bg-editor-surface text-editor-text hover:bg-editor-hover',
(selectedClipIds || []).length < 2 && 'opacity-50'
)}
>
</button>
<button
onClick={() => { ungroupSelected(); pushHistory() }}
className={cn('flex-1 py-1.5 rounded text-sm', 'bg-editor-surface text-editor-text hover:bg-editor-hover')}
>
</button>
</div>
<button
onClick={bindToVideo}
className={cn(
'w-full mt-2 py-1.5 rounded text-sm',
'bg-editor-surface text-editor-text hover:bg-editor-hover'
)}
>
/
</button>
<p className="text-xs text-editor-text-muted mt-1">
/
</p>
</div>
)}
</div>
</div>
)}
{/* 空状态 */}
{!selectedClip && (
<div className="p-4">
<h3 className="text-sm font-medium text-editor-text mb-2"></h3>
<p className="text-sm text-editor-text-muted">
/ /
</p>
</div>
)}
{/* 旁白时长/文本变更提示(新手避免误解“拖拉=变速”) */}
{selectedClip && selectedClip.trackId === 'audio-voiceover' && (selectedClip as any).needsVoiceoverRegenerate && (
<div className="p-4 border-t border-editor-border">
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5" />
<div className="min-w-0">
<div className="text-sm text-editor-text font-medium"></div>
<div className="text-xs text-editor-text-muted mt-1">
//
</div>
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default TrackPanel

View File

@@ -0,0 +1,171 @@
/**
* 轨道行组件
*/
import React, { useCallback } from 'react'
import { useEditorStore, type Track } from '@/store/editorStore'
import { ClipItem } from './ClipItem'
import { cn } from '@/lib/utils'
import { applyDragRules, applyResizeEndRules, applyResizeStartRules } from '@/lib/timelineRules'
interface TrackRowProps {
track: Track
pixelsPerSecond: number
trackHeight: number
top: number
viewStartTime?: number
viewEndTime?: number
onClipChange?: () => void
}
export const TrackRow: React.FC<TrackRowProps> = ({
track,
pixelsPerSecond,
trackHeight,
top,
viewStartTime,
viewEndTime,
onClipChange,
}) => {
const {
selectedClipIds,
toggleClipSelection,
updateClip,
currentTime,
rippleMode,
rippleShiftFrom,
setSnapGuideTime,
shiftGroup,
} = useEditorStore()
// 处理片段拖拽
const handleClipDrag = useCallback((
clipId: string,
_deltaX: number,
deltaTime: number
) => {
const clip = track.clips.find(c => c.id === clipId)
if (!clip || track.locked) return
const desiredStart = Math.max(0, clip.start + deltaTime)
// 分组联动:同 groupId 的片段跨轨一起移动(不参与 ripple 规则)
if (clip.groupId) {
const nextStart = applyDragRules(track.clips, clipId, desiredStart, currentTime)
const delta = nextStart - clip.start
if (Math.abs(delta) > 1e-6) {
setSnapGuideTime(nextStart)
shiftGroup(clip.groupId, delta)
}
return
}
if (track.type === 'video' && rippleMode) {
// ripple: move this clip and all following clips together (preserve spacing)
const ordered = track.clips.slice().sort((a, b) => a.start - b.start)
const idx = ordered.findIndex(c => c.id === clipId)
if (idx === -1) return
const prev = idx > 0 ? ordered[idx - 1] : null
const minStart = prev ? (prev.start + prev.duration) : 0
const nextStart = Math.max(minStart, desiredStart)
const delta = nextStart - clip.start
if (Math.abs(delta) > 1e-6) {
rippleShiftFrom(track.id, clipId, delta)
}
return
}
const nextStart = applyDragRules(track.clips, clipId, desiredStart, currentTime)
// 若发生吸附/修正,显示辅助线
if (Math.abs(nextStart - desiredStart) > 1e-4) setSnapGuideTime(nextStart)
updateClip(track.id, clipId, { start: nextStart })
}, [track, updateClip, currentTime, rippleMode, rippleShiftFrom, setSnapGuideTime, shiftGroup])
// 处理片段调整大小
const handleClipResize = useCallback((
clipId: string,
edge: 'start' | 'end',
deltaTime: number
) => {
const clip = track.clips.find(c => c.id === clipId)
if (!clip || track.locked) return
if (edge === 'start') {
const desiredStart = clip.start + deltaTime
const next = applyResizeStartRules(track.clips, clipId, desiredStart, currentTime)
updateClip(track.id, clipId, next)
} else {
const desiredEnd = clip.start + clip.duration + deltaTime
const next = applyResizeEndRules(track.clips, clipId, desiredEnd, currentTime)
// sourceDuration clamp (if known)
const sd = clip.sourceDuration
const trimStart = clip.trimStart ?? 0
let duration = next.duration
if (typeof sd === 'number' && sd > 0) {
duration = Math.min(duration, Math.max(0.5, sd - trimStart))
}
const trimEnd = trimStart + duration
// ripple push following clips when extending
if (track.type === 'video' && rippleMode) {
const ordered = track.clips.slice().sort((a, b) => a.start - b.start)
const idx = ordered.findIndex(c => c.id === clipId)
const oldEnd = clip.start + clip.duration
const newEnd = clip.start + duration
if (idx !== -1 && newEnd > oldEnd + 1e-6 && idx + 1 < ordered.length) {
const nextClip = ordered[idx + 1]
const overlap = newEnd - nextClip.start
if (overlap > 1e-6) {
rippleShiftFrom(track.id, nextClip.id, overlap)
}
}
}
updateClip(track.id, clipId, { duration, trimEnd })
}
}, [track, updateClip, currentTime, rippleMode, rippleShiftFrom])
return (
<div
className={cn(
'absolute left-0 right-0 border-b border-editor-border',
track.locked && 'opacity-50',
track.muted && 'opacity-60'
)}
style={{ top, height: trackHeight }}
>
{/* 背景网格 */}
<div className="absolute inset-0 bg-editor-bg" />
{/* 片段 */}
{track.clips
.filter((clip) => {
if (typeof viewStartTime !== 'number' || typeof viewEndTime !== 'number') return true
const start = clip.start ?? 0
const end = start + (clip.duration ?? 0)
return end >= viewStartTime && start <= viewEndTime
})
.map(clip => (
<ClipItem
key={clip.id}
clip={clip}
trackType={track.type}
pixelsPerSecond={pixelsPerSecond}
trackHeight={trackHeight}
isSelected={selectedClipIds.includes(clip.id)}
isLocked={track.locked}
onSelect={(e) => {
const isToggle = e.shiftKey || e.metaKey || e.ctrlKey
toggleClipSelection(clip.id, isToggle ? 'toggle' : 'replace')
}}
onDrag={(deltaX, deltaTime) => handleClipDrag(clip.id, deltaX, deltaTime)}
onResize={(edge, deltaTime) => handleClipResize(clip.id, edge, deltaTime)}
onCommit={() => {
setSnapGuideTime(null)
onClipChange?.()
}}
/>
))}
</div>
)
}
export default TrackRow

View File

@@ -0,0 +1,23 @@
/**
* Timeline 组件导出
*/
export { Timeline } from './Timeline'
export { TrackRow } from './TrackRow'
export { ClipItem } from './ClipItem'
export { TimeRuler } from './TimeRuler'
export { Playhead } from './Playhead'
export { TrackPanel } from './TrackPanel'

5
web/src/editorStore.ts Normal file
View File

@@ -0,0 +1,5 @@
// Back-compat re-export
export * from './store/editorStore'

118
web/src/index.css Normal file
View File

@@ -0,0 +1,118 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: #1a1a1a;
color: #e5e5e5;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #242424;
}
::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #525252;
}
/* 时间轴拖拽样式 */
.timeline-clip {
cursor: grab;
transition: transform 0.1s ease;
}
.timeline-clip:active {
cursor: grabbing;
}
.timeline-clip.dragging {
opacity: 0.8;
transform: scale(1.02);
z-index: 100;
}
/* 轨道颜色条 */
.track-indicator {
width: 4px;
border-radius: 2px;
}
/* 播放头 */
.playhead {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: #ef4444;
z-index: 50;
pointer-events: none;
}
.playhead::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid #ef4444;
}
/* 波形容器 */
.waveform-container {
height: 48px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
overflow: hidden;
}
/* 动画 */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse-slow {
animation: pulse 2s ease-in-out infinite;
}

283
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,283 @@
import axios from 'axios'
// API 客户端配置
const api = axios.create({
baseURL: '/api',
timeout: 60000,
headers: {
'Content-Type': 'application/json',
},
})
// 类型定义
export interface Project {
id: string
name: string
status: string
product_info: Record<string, unknown>
script_data?: ScriptData
created_at: number
updated_at: number
}
export interface ProjectListItem {
id: string
name: string
status: string
updated_at: number
}
export interface ScriptData {
scenes: Scene[]
voiceover_timeline: VoiceoverItem[]
selling_points?: string[]
target_audience?: string
bgm_style?: string
visual_anchor?: string
}
export interface Scene {
id: number
visual_prompt?: string
video_prompt?: string
fancy_text?: {
text: string
start_time?: number
duration?: number
}
}
export interface VoiceoverItem {
text: string
subtitle?: string
start_time: number
duration: number
}
export interface TimelineClip {
id: string
type: 'video' | 'audio' | 'subtitle' | 'fancy_text' | 'bgm' | 'sticker'
start: number
duration: number
source_path?: string
source_url?: string
trim_start?: number
trim_end?: number
source_duration?: number
text?: string
style?: Record<string, unknown>
position?: { x: string | number; y: string | number }
volume?: number
fade_in?: number
fade_out?: number
ducking?: boolean
duck_volume?: number
playback_rate?: number
}
export interface Track {
id: string
name: string
type: string
clips: TimelineClip[]
locked: boolean
visible: boolean
muted: boolean
}
export interface EditorState {
project_id: string
total_duration: number
tracks: Track[]
current_time: number
zoom: number
ripple_mode?: boolean
subtitle_style?: Record<string, unknown>
}
export interface BGMItem {
id: string
name: string
path: string
url: string
}
export interface StickerItem {
id: string
name: string
url: string
kind?: 'builtin' | 'custom'
tags?: string[]
category?: string
license?: string | null
attribution?: string | null
}
export interface VoiceOption {
id: string
name: string
}
// API 方法
// 将前端 store 的 clip/trackcamelCase转换为后端接口期望的 snake_case
const toApiClip = (clip: any) => ({
...clip,
source_path: clip.source_path ?? clip.sourcePath ?? clip.source_path,
source_url: clip.source_url ?? clip.sourceUrl ?? clip.source_url,
trim_start: clip.trim_start ?? clip.trimStart ?? clip.trim_start ?? 0,
trim_end: clip.trim_end ?? clip.trimEnd ?? clip.trim_end ?? null,
source_duration: clip.source_duration ?? clip.sourceDuration ?? clip.source_duration ?? null,
fade_in: clip.fade_in ?? clip.fadeIn ?? null,
fade_out: clip.fade_out ?? clip.fadeOut ?? null,
ducking: typeof clip.ducking === 'boolean' ? clip.ducking : null,
duck_volume: clip.duck_volume ?? clip.duckVolume ?? null,
playback_rate: clip.playback_rate ?? clip.playbackRate ?? null,
})
const toApiTrack = (track: any) => ({
...track,
clips: Array.isArray(track.clips) ? track.clips.map(toApiClip) : [],
})
// Projects
export const projectsApi = {
list: () => api.get<ProjectListItem[]>('/projects').then(r => r.data),
get: (id: string) => api.get<Project>(`/projects/${id}`).then(r => r.data),
create: (name: string, productInfo: Record<string, string>) =>
api.post('/projects', { name, product_info: productInfo }).then(r => r.data),
getAssets: (id: string) =>
api.get(`/projects/${id}/assets`).then(r => r.data),
generateScript: (id: string, modelProvider = 'shubiaobiao') =>
api.post(`/projects/${id}/generate-script`, null, { params: { model_provider: modelProvider } }).then(r => r.data),
generateImages: (id: string, modelProvider = 'shubiaobiao') =>
api.post(`/projects/${id}/generate-images`, null, { params: { model_provider: modelProvider } }).then(r => r.data),
generateVideos: (id: string) =>
api.post(`/projects/${id}/generate-videos`).then(r => r.data),
}
// Editor
export const editorApi = {
getState: (projectId: string) =>
api.get<EditorState>(`/editor/${projectId}/state`).then(r => r.data),
saveState: (projectId: string, state: EditorState) =>
api.post(`/editor/${projectId}/state`, {
...state,
tracks: (state as any).tracks?.map(toApiTrack) ?? [],
}).then(r => r.data),
generateVoiceover: (text: string, voiceType?: string, targetDuration?: number) =>
api.post('/editor/generate-voiceover', {
text,
voice_type: voiceType,
target_duration: targetDuration,
}).then(r => r.data),
generateFancyText: (text: string, style?: Record<string, unknown>) =>
api.post('/editor/generate-fancy-text', { text, style }).then(r => r.data),
trimVideo: (sourcePath: string, startTime: number, endTime: number) =>
api.post('/editor/trim-video', {
source_path: sourcePath,
start_time: startTime,
end_time: endTime,
}).then(r => r.data),
deleteClip: (projectId: string, clipId: string) =>
api.delete(`/editor/${projectId}/clip/${clipId}`).then(r => r.data),
}
// Compose
export const composeApi = {
render: (projectId: string, tracks: Track[], options?: {
voiceType?: string
bgmVolume?: number
outputName?: string
}) => {
const videoClips = (tracks.find(t => t.type === 'video')?.clips || []).map(toApiClip)
const voiceoverClips = (tracks.find(t => t.id === 'audio-voiceover')?.clips || []).map(toApiClip)
const subtitleClips = (tracks.find(t => t.type === 'subtitle')?.clips || []).map(toApiClip)
const fancyTextClips = (tracks.find(t => t.type === 'fancy_text')?.clips || []).map(toApiClip)
const stickerClips = (tracks.find(t => t.type === 'sticker')?.clips || []).map(toApiClip)
const bgmClipRaw = tracks.find(t => t.type === 'bgm')?.clips[0] || null
const bgmClip = bgmClipRaw ? toApiClip(bgmClipRaw) : null
return api.post('/compose/render', {
project_id: projectId,
video_clips: videoClips,
voiceover_clips: voiceoverClips,
subtitle_clips: subtitleClips,
fancy_text_clips: fancyTextClips,
sticker_clips: stickerClips,
bgm_clip: bgmClip,
voice_type: options?.voiceType || 'zh_female_santongyongns_saturn_bigtts',
bgm_volume: options?.bgmVolume || 0.15,
output_name: options?.outputName,
}).then(r => r.data)
},
quickCompose: (projectId: string, bgmId?: string) =>
api.post('/compose/quick', null, { params: { project_id: projectId, bgm_id: bgmId } }).then(r => r.data),
getStatus: (taskId: string) =>
api.get(`/compose/status/${taskId}`).then(r => r.data),
retry: (taskId: string) =>
api.post(`/compose/retry/${taskId}`).then(r => r.data),
}
// Assets
export const assetsApi = {
list: (projectId: string, assetType?: string) =>
api.get('/assets/list/' + projectId, { params: { asset_type: assetType } }).then(r => r.data),
upload: (file: File, assetType = 'custom') => {
const formData = new FormData()
formData.append('file', file)
formData.append('asset_type', assetType)
return api.post('/assets/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then(r => r.data)
},
getBGM: () => api.get<BGMItem[]>('/assets/bgm').then(r => r.data),
getFonts: () => api.get('/assets/fonts').then(r => r.data),
uploadFont: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return api.post('/assets/fonts/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then(r => r.data)
},
getStickers: () => api.get<StickerItem[]>('/assets/stickers').then(r => r.data),
uploadSticker: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return api.post('/assets/stickers/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then(r => r.data)
},
}
// Config
export const configApi = {
get: () => api.get('/config').then(r => r.data),
health: () => api.get('/health').then(r => r.data),
}
export default api

86
web/src/lib/templates.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* 轻量模板系统MVP
* - 目标:把“模板=一组默认样式+可选片段生成规则”固化,后续可演进为 zip 包
*/
import type { Track, TimelineClip } from '@/store/editorStore'
import { generateId } from '@/lib/utils'
export type TemplateApplyResult = {
subtitleStyle?: Record<string, unknown>
addClips?: Array<{ trackType: string; clip: Omit<TimelineClip, 'id'> }>
}
export interface EditorTemplate {
id: string
name: string
description: string
apply: (ctx: { tracks: Track[]; totalDuration: number }) => TemplateApplyResult
}
const ensureTrack = (tracks: Track[], type: string) => tracks.find(t => t.type === type)
export const templates: EditorTemplate[] = [
{
id: 'tpl_subtitle_box',
name: '字幕-底部半透明底',
description: '适合口播:底部字幕带半透明底,描边较轻。',
apply: () => ({
subtitleStyle: {
fontsize: 56,
fontcolor: 'white',
borderw: 3,
bordercolor: 'black',
box: 1,
boxcolor: 'black@0.45',
boxborderw: 18,
x: '(w-text_w)/2',
y: 'h-220',
},
}),
},
{
id: 'tpl_intro_outro_fancy',
name: '花字-片头片尾',
description: '自动加一个片头/片尾花字(可后续手动改文本/位置)。',
apply: ({ tracks, totalDuration }) => {
const fancy = ensureTrack(tracks, 'fancy_text')
if (!fancy) return {}
const d = Math.max(0.8, Math.min(1.6, totalDuration / 8))
const intro: Omit<TimelineClip, 'id'> = {
type: 'fancy_text',
start: 0,
duration: d,
text: '片头标题',
style: { font_size: 80, font_color: '#FFFFFF' },
position: { x: '50%', y: '160px' },
}
const outro: Omit<TimelineClip, 'id'> = {
type: 'fancy_text',
start: Math.max(0, totalDuration - d),
duration: d,
text: '片尾收束',
style: { font_size: 72, font_color: '#FFFFFF' },
position: { x: '50%', y: '160px' },
}
return {
addClips: [
{ trackType: 'fancy_text', clip: intro },
{ trackType: 'fancy_text', clip: outro },
],
}
},
},
]
export const applyTemplateToTracks = (tracks: Track[], res: TemplateApplyResult) => {
const ops: Array<{ trackId: string; clip: TimelineClip }> = []
for (const add of res.addClips || []) {
const t = tracks.find(x => x.type === add.trackType)
if (!t) continue
ops.push({ trackId: t.id, clip: { ...add.clip, id: generateId() } as TimelineClip })
}
return ops
}

View File

@@ -0,0 +1,210 @@
/**
* Timeline rules layer (MVP)
* - snapping: playhead, other clip edges, grid
* - collision: disallow overlap in same track
* - normalize: keep trimEnd = trimStart + duration
*/
import type { TimelineClip } from '@/store/editorStore'
export interface TimelineRuleOptions {
gridSeconds?: number
snapThresholdSeconds?: number
minDurationSeconds?: number
}
const DEFAULTS: Required<TimelineRuleOptions> = {
gridSeconds: 0.5,
snapThresholdSeconds: 0.12,
minDurationSeconds: 0.5,
}
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
export const normalizeTrim = (clip: TimelineClip): TimelineClip => {
const trimStart = clip.trimStart ?? 0
const duration = clip.duration
const trimEnd = trimStart + duration
return { ...clip, trimStart, trimEnd }
}
export const buildSnapAnchors = (
clips: TimelineClip[],
excludeClipId: string,
playheadTime: number,
opts?: TimelineRuleOptions
) => {
const o = { ...DEFAULTS, ...(opts || {}) }
const anchors = new Set<number>()
anchors.add(playheadTime)
// grid near playhead, and general
const grid = o.gridSeconds
anchors.add(Math.round(playheadTime / grid) * grid)
// other clip edges
for (const c of clips) {
if (c.id === excludeClipId) continue
anchors.add(c.start)
anchors.add(c.start + c.duration)
}
return { anchors: Array.from(anchors), opts: o }
}
export const snapTime = (time: number, anchors: number[], opts?: TimelineRuleOptions) => {
const o = { ...DEFAULTS, ...(opts || {}) }
let best = time
let bestDist = Infinity
for (const a of anchors) {
const d = Math.abs(a - time)
if (d < bestDist) {
bestDist = d
best = a
}
}
return bestDist <= o.snapThresholdSeconds ? best : time
}
export const resolveNoOverlapStart = (
clips: TimelineClip[],
clipId: string,
desiredStart: number,
duration: number
) => {
const others = clips.filter(c => c.id !== clipId).slice().sort((a, b) => a.start - b.start)
const desiredEnd = desiredStart + duration
// find nearest prev/next boundaries
let prevEnd = 0
let nextStart = Number.POSITIVE_INFINITY
for (const c of others) {
const cStart = c.start
const cEnd = c.start + c.duration
if (cEnd <= desiredStart) {
prevEnd = Math.max(prevEnd, cEnd)
continue
}
if (cStart >= desiredEnd) {
nextStart = Math.min(nextStart, cStart)
break
}
// overlap with c
if (cStart < desiredEnd && cEnd > desiredStart) {
// clamp to nearest edge (prefer not moving backward too much)
// If desiredStart is before cStart, clamp end to cStart; else clamp start to cEnd.
const clampToBefore = cStart - duration
const clampToAfter = cEnd
const distBefore = Math.abs(desiredStart - clampToBefore)
const distAfter = Math.abs(desiredStart - clampToAfter)
return distBefore <= distAfter ? Math.max(0, clampToBefore) : clampToAfter
}
}
const maxStart = nextStart === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : nextStart - duration
return clamp(desiredStart, prevEnd, maxStart)
}
export const applyDragRules = (
clips: TimelineClip[],
clipId: string,
desiredStart: number,
playheadTime: number,
opts?: TimelineRuleOptions
) => {
const clip = clips.find(c => c.id === clipId)
if (!clip) return desiredStart
const { anchors, opts: o } = buildSnapAnchors(clips, clipId, playheadTime, opts)
let nextStart = Math.max(0, desiredStart)
nextStart = snapTime(nextStart, anchors, o)
nextStart = resolveNoOverlapStart(clips, clipId, nextStart, clip.duration)
return nextStart
}
export const applyResizeStartRules = (
clips: TimelineClip[],
clipId: string,
desiredStart: number,
playheadTime: number,
opts?: TimelineRuleOptions
) => {
const o = { ...DEFAULTS, ...(opts || {}) }
const clip = clips.find(c => c.id === clipId)
if (!clip) {
const duration = o.minDurationSeconds
const trimStart = 0
const trimEnd = trimStart + duration
return { start: Math.max(0, desiredStart), duration, trimStart, trimEnd }
}
const oldEnd = clip.start + clip.duration
const minStart = Math.max(0, oldEnd - o.minDurationSeconds)
const { anchors } = buildSnapAnchors(clips, clipId, playheadTime, o)
let start = clamp(desiredStart, 0, minStart)
start = snapTime(start, anchors, o)
// collision: ensure new [start, oldEnd] doesn't overlap others
// For start-resize we clamp start to be >= prevEnd and <= nextStart - minDuration
const others = clips.filter(c => c.id !== clipId).slice().sort((a, b) => a.start - b.start)
let prevEnd = 0
let nextStart = Number.POSITIVE_INFINITY
for (const c of others) {
const cEnd = c.start + c.duration
if (cEnd <= start) {
prevEnd = Math.max(prevEnd, cEnd)
continue
}
if (c.start >= oldEnd) {
nextStart = Math.min(nextStart, c.start)
break
}
// overlapping other clip within [start, oldEnd]
if (c.start < oldEnd && cEnd > start) {
start = cEnd
}
}
const maxStart = Math.min(minStart, nextStart - o.minDurationSeconds)
start = clamp(start, prevEnd, maxStart)
// IMPORTANT: duration must be recalculated after collision adjustments,
// otherwise right edge would drift and can overlap next clip.
const duration = Math.max(o.minDurationSeconds, oldEnd - start)
const startDiff = start - clip.start
const trimStart = (clip.trimStart ?? 0) + startDiff
const trimEnd = trimStart + duration
return { start, duration, trimStart, trimEnd }
}
export const applyResizeEndRules = (
clips: TimelineClip[],
clipId: string,
desiredEnd: number,
playheadTime: number,
opts?: TimelineRuleOptions
) => {
const o = { ...DEFAULTS, ...(opts || {}) }
const clip = clips.find(c => c.id === clipId)
if (!clip) return { duration: o.minDurationSeconds, trimEnd: o.minDurationSeconds }
const { anchors } = buildSnapAnchors(clips, clipId, playheadTime, o)
let end = Math.max(clip.start + o.minDurationSeconds, desiredEnd)
end = snapTime(end, anchors, o)
// collision: ensure end <= nextStart
const others = clips.filter(c => c.id !== clipId).slice().sort((a, b) => a.start - b.start)
for (const c of others) {
if (c.start >= clip.start + o.minDurationSeconds) {
if (c.start >= clip.start && c.start < end) {
end = Math.min(end, c.start)
}
if (c.start >= end) break
}
}
const duration = Math.max(o.minDurationSeconds, end - clip.start)
const trimStart = clip.trimStart ?? 0
const trimEnd = trimStart + duration
return { duration, trimEnd }
}

View File

@@ -0,0 +1,69 @@
export type TransitionCategory =
| '基础'
| '缩放'
| '滑动'
| '旋转'
| '模糊'
| '光效'
| '颜色'
export type TransitionPreset = {
id: string
name: string
desc: string
category: TransitionCategory
tags?: string[]
// 映射到 clip.style
type: string
defaultDurationSec: number
// 导出是否支持MVP先只保证 fade 一致)
exportable: boolean
}
/**
* 转场库(火山式:不改片段时长、不插入片段)
* - 预览Remotion 末尾动效
* - 导出:逐步补齐(先保证 fade
*/
export const transitionCatalog: TransitionPreset[] = [
// 基础
{ id: 'fade', name: '淡出', desc: '片尾淡出到黑(导出支持)', category: '基础', type: 'fade', defaultDurationSec: 0.6, exportable: true, tags: ['通用', '稳'] },
{ id: 'dipToBlack', name: '快速淡黑', desc: '更短更利落的淡出', category: '基础', type: 'fade', defaultDurationSec: 0.25, exportable: true, tags: ['节奏', '快'] },
{ id: 'fadeWhite', name: '淡到白', desc: '片尾淡出到白(导出支持)', category: '基础', type: 'fadeWhite', defaultDurationSec: 0.5, exportable: true, tags: ['明亮'] },
{ id: 'flash', name: '闪白', desc: '片尾闪一下(导出支持)', category: '光效', type: 'flash', defaultDurationSec: 0.25, exportable: true, tags: ['节奏'] },
// 缩放
{ id: 'zoomOut', name: '缩小', desc: '片尾逐渐缩小(导出支持)', category: '缩放', type: 'zoomOut', defaultDurationSec: 0.6, exportable: true, tags: ['动感'] },
{ id: 'zoomIn', name: '推进', desc: '片尾逐渐推进(导出支持)', category: '缩放', type: 'zoomIn', defaultDurationSec: 0.6, exportable: true, tags: ['动感'] },
// 滑动
{ id: 'slideLeft', name: '左滑', desc: '片尾向左滑走(导出支持)', category: '滑动', type: 'slideLeft', defaultDurationSec: 0.6, exportable: true, tags: ['通用'] },
{ id: 'slideRight', name: '右滑', desc: '片尾向右滑走(导出支持)', category: '滑动', type: 'slideRight', defaultDurationSec: 0.6, exportable: true, tags: ['通用'] },
{ id: 'slideUp', name: '上滑', desc: '片尾向上滑走(导出支持)', category: '滑动', type: 'slideUp', defaultDurationSec: 0.6, exportable: true, tags: ['通用'] },
{ id: 'slideDown', name: '下滑', desc: '片尾向下滑走(导出支持)', category: '滑动', type: 'slideDown', defaultDurationSec: 0.6, exportable: true, tags: ['通用'] },
// 旋转
{ id: 'rotateOut', name: '旋转', desc: '片尾小角度旋转(导出支持)', category: '旋转', type: 'rotateOut', defaultDurationSec: 0.6, exportable: true, tags: ['动感'] },
// 模糊
{ id: 'blurOut', name: '模糊', desc: '片尾逐渐模糊(导出支持)', category: '模糊', type: 'blurOut', defaultDurationSec: 0.6, exportable: true, tags: ['质感'] },
{ id: 'blurFade', name: '模糊淡出', desc: '片尾模糊并淡出(导出支持)', category: '模糊', type: 'blurFade', defaultDurationSec: 0.6, exportable: true, tags: ['质感', '通用'] },
// 颜色
{ id: 'desaturate', name: '去饱和', desc: '片尾逐渐变灰(导出支持)', category: '颜色', type: 'desaturate', defaultDurationSec: 0.6, exportable: true, tags: ['质感'] },
{ id: 'colorPop', name: '色彩增强', desc: '片尾增强饱和/对比(导出支持)', category: '颜色', type: 'colorPop', defaultDurationSec: 0.6, exportable: true, tags: ['动感'] },
{ id: 'hueShift', name: '色相偏移', desc: '片尾轻微色相旋转(导出支持)', category: '颜色', type: 'hueShift', defaultDurationSec: 0.6, exportable: true, tags: ['氛围'] },
{ id: 'darken', name: '变暗', desc: '片尾逐渐变暗(导出支持)', category: '颜色', type: 'darken', defaultDurationSec: 0.6, exportable: true, tags: ['氛围'] },
]
export const transitionCategories: TransitionCategory[] = [
'基础',
'缩放',
'滑动',
'旋转',
'模糊',
'光效',
'颜色',
]

160
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,160 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* 格式化时间(秒)为 MM:SS.ms 格式
*/
export function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 100)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`
}
/**
* 格式化时间为简短格式 M:SS
*/
export function formatTimeShort(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
/**
* 解析用户输入时间到秒。
* 支持:
* - "M:SS" / "MM:SS"
* - "M:SS.ms" / "MM:SS.ms"
* - 纯数字秒(如 "12.5"
*/
export function parseTimeInput(input: string): number | null {
const raw = (input || '').trim()
if (!raw) return null
// pure number seconds
if (/^\d+(\.\d+)?$/.test(raw)) {
const n = Number(raw)
return Number.isFinite(n) ? n : null
}
// M:SS(.ms)
const m = raw.match(/^(\d+)\s*:\s*(\d{1,2})(?:\.(\d{1,3}))?$/)
if (!m) return null
const mins = Number(m[1])
const secs = Number(m[2])
const fracRaw = m[3]
if (!Number.isFinite(mins) || !Number.isFinite(secs) || secs >= 60) return null
let frac = 0
if (fracRaw) {
const fracN = Number(`0.${fracRaw.padEnd(3, '0')}`) // treat as ms-ish
frac = Number.isFinite(fracN) ? fracN : 0
}
return mins * 60 + secs + frac
}
/**
* 将像素转换为时间(基于缩放比例)
*/
export function pixelsToTime(pixels: number, pixelsPerSecond: number): number {
return pixels / pixelsPerSecond
}
/**
* 将时间转换为像素(基于缩放比例)
*/
export function timeToPixels(time: number, pixelsPerSecond: number): number {
return time * pixelsPerSecond
}
/**
* 限制数值在范围内
*/
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
/**
* 生成唯一 ID
*/
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
/**
* 节流函数
*/
export function throttle<T extends (...args: unknown[]) => void>(
func: T,
limit: number
): T {
let inThrottle: boolean
return function(this: unknown, ...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args)
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
}
} as T
}
/**
* 防抖函数
*/
export function debounce<T extends (...args: unknown[]) => void>(
func: T,
wait: number
): T {
let timeout: ReturnType<typeof setTimeout>
return function(this: unknown, ...args: Parameters<T>) {
clearTimeout(timeout)
timeout = setTimeout(() => func.apply(this, args), wait)
} as T
}
/**
* 获取轨道类型的颜色
*/
export function getTrackColor(type: string): string {
const colors: Record<string, string> = {
video: 'bg-track-video',
audio: 'bg-track-audio',
voiceover: 'bg-track-voiceover',
subtitle: 'bg-track-subtitle',
fancy_text: 'bg-track-fancy',
bgm: 'bg-track-bgm',
}
return colors[type] || 'bg-gray-500'
}
/**
* 获取轨道类型的边框颜色
*/
export function getTrackBorderColor(type: string): string {
const colors: Record<string, string> = {
video: 'border-track-video',
audio: 'border-track-audio',
voiceover: 'border-track-voiceover',
subtitle: 'border-track-subtitle',
fancy_text: 'border-track-fancy',
bgm: 'border-track-bgm',
}
return colors[type] || 'border-gray-500'
}

36
web/src/main.tsx Normal file
View File

@@ -0,0 +1,36 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
refetchOnWindowFocus: false,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)

1525
web/src/pages/EditorPage.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
/**
* 项目列表页面
*/
import React, { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Plus, Film, Clock, ChevronRight } from 'lucide-react'
import { projectsApi } from '@/lib/api'
import { cn } from '@/lib/utils'
export const ProjectsPage: React.FC = () => {
const navigate = useNavigate()
const workflowUrl = useMemo(() => {
// 生产环境部署在同一台机器:前端 3000控制台 8503
try {
const u = new URL(window.location.href)
return `${u.protocol}//${u.hostname}:8503`
} catch {
return 'http://localhost:8503'
}
}, [])
const { data: projects, isLoading, error } = useQuery({
queryKey: ['projects'],
queryFn: projectsApi.list,
})
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
created: 'bg-gray-500/20 text-gray-400',
script_generated: 'bg-blue-500/20 text-blue-400',
images_generated: 'bg-purple-500/20 text-purple-400',
videos_generated: 'bg-cyan-500/20 text-cyan-400',
completed: 'bg-green-500/20 text-green-400',
failed: 'bg-red-500/20 text-red-400',
}
const labels: Record<string, string> = {
created: '已创建',
script_generated: '脚本完成',
images_generated: '图片完成',
videos_generated: '视频完成',
completed: '已完成',
failed: '失败',
}
return (
<span className={cn(
'px-2 py-0.5 rounded text-xs font-medium',
styles[status] || styles.created
)}>
{labels[status] || status}
</span>
)
}
return (
<div className="min-h-screen bg-editor-bg">
{/* Header */}
<header className="bg-editor-panel border-b border-editor-border">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Film className="w-8 h-8 text-editor-accent" />
<h1 className="text-xl font-bold text-editor-text">Video Flow Editor</h1>
</div>
<a
href={workflowUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-editor-text-muted hover:text-editor-text transition-colors"
>
</a>
</div>
</header>
{/* Content */}
<main className="max-w-6xl mx-auto px-6 py-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-editor-text"></h2>
<a
href={workflowUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg',
'bg-editor-accent text-white font-medium',
'hover:bg-editor-accent-hover transition-colors'
)}
>
<Plus className="w-4 h-4" />
</a>
</div>
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-editor-accent border-t-transparent rounded-full mx-auto" />
<p className="mt-4 text-editor-text-muted">...</p>
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-red-400"></p>
<p className="text-sm text-editor-text-muted mt-2">
FastAPI http://localhost:8000
</p>
</div>
) : projects?.length === 0 ? (
<div className="text-center py-12 bg-editor-panel rounded-xl border border-editor-border">
<Film className="w-12 h-12 text-editor-text-muted mx-auto" />
<p className="mt-4 text-editor-text-muted"></p>
<p className="text-sm text-editor-text-muted mt-1">
</p>
</div>
) : (
<div className="grid gap-4">
{projects?.map(project => (
<div
key={project.id}
onClick={() => navigate(`/editor/${project.id}`)}
className={cn(
'flex items-center justify-between p-4 rounded-xl',
'bg-editor-panel border border-editor-border',
'hover:border-editor-accent/50 cursor-pointer transition-all',
'group'
)}
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-editor-surface flex items-center justify-center">
<Film className="w-6 h-6 text-editor-text-muted" />
</div>
<div>
<h3 className="font-medium text-editor-text group-hover:text-editor-accent transition-colors">
{project.name}
</h3>
<div className="flex items-center gap-3 mt-1">
{getStatusBadge(project.status)}
<span className="flex items-center gap-1 text-xs text-editor-text-muted">
<Clock className="w-3 h-3" />
{formatDate(project.updated_at)}
</span>
</div>
</div>
</div>
<ChevronRight className="w-5 h-5 text-editor-text-muted group-hover:text-editor-accent transition-colors" />
</div>
))}
</div>
)}
</main>
</div>
)
}
export default ProjectsPage

19
web/src/pages/index.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Pages 导出
*/
export { ProjectsPage } from './ProjectsPage'
export { EditorPage } from './EditorPage'

View File

@@ -0,0 +1,866 @@
/**
* Remotion 视频合成组件
* 用于浏览器端实时预览
*/
import React from 'react'
import {
AbsoluteFill,
Sequence,
Video,
Audio,
Img,
useCurrentFrame,
useVideoConfig,
interpolate,
} from 'remotion'
import type { Track, TimelineClip } from '@/store/editorStore'
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
const makeFade = (localFrame: number, durationFrames: number, fps: number, fadeIn?: number, fadeOut?: number) => {
const fi = Math.max(0, Number(fadeIn || 0))
const fo = Math.max(0, Number(fadeOut || 0))
const fiF = Math.round(fi * fps)
const foF = Math.round(fo * fps)
let f = 1
if (fiF > 0) f *= clamp01(localFrame / Math.max(1, fiF))
if (foF > 0) f *= clamp01((durationFrames - localFrame) / Math.max(1, foF))
return clamp01(f)
}
const resolvePos = (v: any, total: number, fallback: number) => {
if (typeof v === 'number' && Number.isFinite(v)) {
// 0~1 视为百分比;否则视为像素
if (v >= 0 && v <= 1) return v * total
return v
}
return fallback
}
const normPosPct = (v: any, total: number, fallbackPct: number) => {
const n = Number(v)
if (typeof n === 'number' && Number.isFinite(n)) {
if (n >= 0 && n <= 1) return n
if (total > 0) return n / total
}
return fallbackPct
}
type UiBridge = {
selectedClipId?: string | null
onSelectClip?: (clipId: string) => void
onUpdateClip?: (clipId: string, updates: Partial<TimelineClip>) => void
onPushHistory?: (meta?: { label?: string; icon?: string }) => void
}
const RenderSizeCtx = React.createContext<{ w: number; h: number }>({ w: 0, h: 0 })
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
const getBoxWPct = (style: any, width: number, fallback: number) => {
const bwRaw = style?.box_w ?? style?.boxW ?? style?.max_width ?? style?.maxWidth ?? 0
const bw = Number(bwRaw) || 0
if (bw > 0 && bw <= 1) return bw
if (bw > 1 && width > 0) return clamp(bw / width, 0.2, 1.0)
return clamp(fallback, 0.2, 1.0)
}
const toCssFontFamily = (v: any) => {
if (typeof v !== 'string' || !v) return undefined
// 若是绝对路径(用于导出 renderer/ffmpeg预览侧用系统字体名回退
if (v.includes('/')) return 'PingFang SC'
return v
}
const tailProgress01 = (frame: number, durationFrames: number, fps: number, tailSec: number) => {
const dF = Math.max(1, Math.round(Math.max(0.0, tailSec) * fps))
const start = Math.max(0, durationFrames - dF)
if (frame < start) return 0
return clamp01((frame - start) / Math.max(1, dF))
}
// 视频片段组件(含原声控制)
const VideoClip: React.FC<{
clip: TimelineClip
volume?: number
}> = ({ clip, volume }) => {
if (!clip.sourceUrl) return null
const { fps } = useVideoConfig()
const frame = useCurrentFrame()
const durationFrames = Math.max(1, Math.round((clip.duration || 0) * fps))
const s: any = clip.style || {}
const vFadeIn = Number(s.vFadeIn ?? s.v_fade_in ?? 0) || 0
const vFadeOut = Number(s.vFadeOut ?? s.v_fade_out ?? 0) || 0
const opacity = makeFade(frame, durationFrames, fps, vFadeIn, vFadeOut)
// “火山式”转场:不改片段时长,仅对片段末尾做动效(默认 out
const tType = String(s.vTransitionType ?? s.v_transition_type ?? '')
const tDur = Number(s.vTransitionDur ?? s.v_transition_dur ?? 0) || 0
const p = tailProgress01(frame, durationFrames, fps, tDur)
const outOpacity = (() => {
if (tType === 'fade' || tType === 'fadeWhite' || tType === 'blurFade') return 1 - p
return 1
})()
const bgColor = tType === 'fadeWhite' ? '#fff' : 'transparent'
// css filters按顺序叠加必须与后端 FFmpeg 的滤镜意图一致)
const filterParts: string[] = []
if (tType === 'blurOut') filterParts.push(`blur(${p * 10}px)`)
if (tType === 'blurFade') filterParts.push(`blur(${p * 8}px)`)
if (tType === 'desaturate') filterParts.push(`saturate(${Math.max(0, 1 - 0.9 * p)})`)
if (tType === 'colorPop') {
filterParts.push(`saturate(${1 + 0.8 * p})`)
filterParts.push(`contrast(${1 + 0.3 * p})`)
}
if (tType === 'darken') filterParts.push(`brightness(${Math.max(0.1, 1 - 0.4 * p)})`)
if (tType === 'hueShift') filterParts.push(`hue-rotate(${60 * p}deg)`)
// flashbrightness 峰值在 p=0.5
if (tType === 'flash') {
const k = 1 - Math.abs(0.5 - p) * 2
const b = 1 + 0.7 * clamp01(k)
filterParts.push(`brightness(${b})`)
}
const outFilter = filterParts.length ? filterParts.join(' ') : 'none'
const outTransform = (() => {
const parts: string[] = []
// slide像素位移与导出保持同量级
const slidePx = 80 * p
if (tType === 'slideLeft') parts.push(`translateX(${-slidePx}px)`)
if (tType === 'slideRight') parts.push(`translateX(${slidePx}px)`)
if (tType === 'slideUp') parts.push(`translateY(${-slidePx}px)`)
if (tType === 'slideDown') parts.push(`translateY(${slidePx}px)`)
if (tType === 'zoomOut') parts.push(`scale(${1 - 0.10 * p})`)
if (tType === 'zoomIn') parts.push(`scale(${1 + 0.10 * p})`)
if (tType === 'rotateOut') parts.push(`rotate(${0.12 * p}rad)`)
return parts.length ? parts.join(' ') : 'none'
})()
return (
<div
style={{
width: '100%',
height: '100%',
backgroundColor: bgColor,
opacity: opacity * outOpacity,
transform: outTransform,
filter: outFilter,
}}
>
<Video
src={clip.sourceUrl}
startFrom={Math.round((clip.trimStart || 0) * fps)}
volume={typeof volume === 'number' ? volume : 1}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</div>
)
}
// 字幕组件
const SubtitleClip: React.FC<{
clip: TimelineClip
ui?: UiBridge
editing?: { clipId: string | null; draft: string }
setEditing?: React.Dispatch<React.SetStateAction<{ clipId: string | null; draft: string }>>
}> = ({ clip, ui, editing, setEditing }) => {
if (!clip.text) return null
const frame = useCurrentFrame()
const { fps, width, height } = useVideoConfig()
// 淡入淡出效果
const opacity = interpolate(
frame,
[0, 10, clip.duration * fps - 10, clip.duration * fps],
[0, 1, 1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
)
const style: any = clip.style || {}
const isSelected = !!ui?.selectedClipId && ui.selectedClipId === clip.id
const isEditing = !!editing?.clipId && editing.clipId === clip.id
const boxW = (() => {
const bw = Number(style.box_w ?? style.boxW ?? style.max_width ?? style.maxWidth ?? 0) || 0
if (bw > 0 && bw <= 1) return Math.round(width * bw)
if (bw > 1) return Math.round(bw)
return Math.round(width * 0.8)
})()
const baseX = normPosPct(clip.position?.x, width, 0.5)
const baseY = normPosPct(clip.position?.y, height, (height - 220) / Math.max(1, height))
const renderSize = React.useContext(RenderSizeCtx)
const dragRef = React.useRef<null | { pointerId: number; sx: number; sy: number; bx: number; by: number; started: boolean }>(null)
const rafRef = React.useRef<number | null>(null)
const widthRef = React.useRef<null | { pointerId: number; sx: number; base: number; started: boolean }>(null)
const saveEdit = () => {
if (!isEditing) return
const txt = String(editing?.draft || '').replace(/\u00A0/g, ' ')
// 空文本:不保存,避免“看起来像被删掉”
if (txt.trim() === '') {
setEditing?.({ clipId: null, draft: '' })
return
}
ui?.onPushHistory?.({ label: '编辑文字', icon: 'text' })
ui?.onUpdateClip?.(clip.id, { text: txt })
setEditing?.({ clipId: null, draft: '' })
}
const cancelEdit = () => setEditing?.({ clipId: null, draft: '' })
const startDrag = (e: React.PointerEvent) => {
if (!ui?.onUpdateClip) return
if (isEditing) return
// 不要在 pointerdown 就 preventDefault会破坏 click/dblclick导致“无法双击编辑”
e.stopPropagation()
ui?.onSelectClip?.(clip.id)
dragRef.current = { pointerId: e.pointerId, sx: e.clientX, sy: e.clientY, bx: baseX, by: baseY, started: false }
const prevSel = document.body.style.userSelect
const prevCursor = document.body.style.cursor
const denomW = Math.max(1, Number(renderSize.w || 0) || 0) || width
const denomH = Math.max(1, Number(renderSize.h || 0) || 0) || height
const move = (ev: PointerEvent) => {
const d = dragRef.current
if (!d || d.pointerId !== ev.pointerId) return
const dxPx = ev.clientX - d.sx
const dyPx = ev.clientY - d.sy
if (!d.started) {
// 降低阈值:让拖拽更“跟手”(避免松开才明显位移的乏力感)
if (Math.abs(dxPx) + Math.abs(dyPx) < 1) return
d.started = true
ui?.onPushHistory?.({ label: '移动文字', icon: 'text' })
document.body.style.userSelect = 'none'
document.body.style.cursor = 'grabbing'
}
ev.preventDefault?.()
const nx = Math.max(0, Math.min(1, d.bx + dxPx / denomW))
const ny = Math.max(0, Math.min(1, d.by + dyPx / denomH))
if (rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(() => {
ui?.onUpdateClip?.(clip.id, { position: { x: nx, y: ny } })
rafRef.current = null
})
}
const up = (ev: PointerEvent) => {
const d = dragRef.current
if (!d || d.pointerId !== ev.pointerId) return
dragRef.current = null
try { window.removeEventListener('pointermove', move as any) } catch {}
try { window.removeEventListener('pointerup', up as any) } catch {}
try { window.removeEventListener('pointercancel', up as any) } catch {}
document.body.style.userSelect = prevSel
document.body.style.cursor = prevCursor
}
window.addEventListener('pointermove', move as any, { passive: false } as any)
window.addEventListener('pointerup', up as any)
window.addEventListener('pointercancel', up as any)
}
const startResizeW = (e: React.PointerEvent) => {
if (!ui?.onUpdateClip) return
if (isEditing) return
e.stopPropagation()
e.preventDefault()
ui?.onSelectClip?.(clip.id)
const base = getBoxWPct(style, width, 0.8)
widthRef.current = { pointerId: e.pointerId, sx: e.clientX, base, started: false }
const prevSel = document.body.style.userSelect
const prevCursor = document.body.style.cursor
const denomW = Math.max(1, Number(renderSize.w || 0) || 0) || width
const move = (ev: PointerEvent) => {
const d = widthRef.current
if (!d || d.pointerId !== ev.pointerId) return
const dxPx = ev.clientX - d.sx
if (!d.started) {
if (Math.abs(dxPx) < 1) return
d.started = true
ui?.onPushHistory?.({ label: '调整文字宽度', icon: 'text' })
document.body.style.userSelect = 'none'
document.body.style.cursor = 'ew-resize'
}
ev.preventDefault?.()
const next = clamp(d.base + dxPx / denomW, 0.2, 1.0)
ui?.onUpdateClip?.(clip.id, { style: { ...(style || {}), box_w: next } })
}
const up = (ev: PointerEvent) => {
const d = widthRef.current
if (!d || d.pointerId !== ev.pointerId) return
widthRef.current = null
try { window.removeEventListener('pointermove', move as any) } catch {}
try { window.removeEventListener('pointerup', up as any) } catch {}
try { window.removeEventListener('pointercancel', up as any) } catch {}
document.body.style.userSelect = prevSel
document.body.style.cursor = prevCursor
}
window.addEventListener('pointermove', move as any, { passive: false } as any)
window.addEventListener('pointerup', up as any)
window.addEventListener('pointercancel', up as any)
}
const enterEdit = () => {
ui?.onSelectClip?.(clip.id)
setEditing?.({ clipId: clip.id, draft: String(clip.text || '') })
}
return (
<div
style={{
position: 'absolute',
left: resolvePos(clip.position?.x, width, width * 0.5),
top: resolvePos(clip.position?.y, height, height - 220),
transform: 'translate(-50%, 0)',
width: boxW,
opacity,
pointerEvents: 'auto',
touchAction: 'none',
}}
onPointerDown={startDrag}
onClick={(e) => { e.stopPropagation(); ui?.onSelectClip?.(clip.id) }}
onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); enterEdit() }}
>
<span
style={{
fontSize: (style.font_size as number) || (style.fontsize as number) || 60,
fontWeight: style.bold ? 800 : 600,
fontStyle: style.italic ? 'italic' : 'normal',
textDecoration: style.underline ? 'underline' : 'none',
fontFamily: toCssFontFamily(style.font_family),
color: style.font_color || '#FFFFFF',
textShadow: style._preview_heavy === false
? 'none'
: '2px 2px 4px rgba(0,0,0,0.8), -2px -2px 4px rgba(0,0,0,0.8)',
padding: '8px 16px',
display: 'inline-block',
width: '100%',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
textAlign: 'center',
outline: isSelected ? '1px solid rgba(88, 166, 255, 0.9)' : 'none',
borderRadius: 8,
cursor: isEditing ? 'text' : 'move',
userSelect: isEditing ? 'text' : 'none',
}}
>
{isEditing ? (
<span
contentEditable
suppressContentEditableWarning
spellCheck={false}
onInput={(e) => {
const txt = (e.currentTarget.textContent ?? '').replace(/\u00A0/g, ' ')
setEditing?.({ clipId: clip.id, draft: txt })
}}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); return }
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); saveEdit(); return }
}}
style={{ outline: 'none', display: 'inline-block', width: '100%' }}
>
{editing?.draft ?? ''}
</span>
) : (
clip.text
)}
</span>
{/* 仅选中且非编辑:显示“拉宽”手柄(控制换行 box_w */}
{isSelected && !isEditing && (
<div
style={{
position: 'absolute',
right: -6,
top: '50%',
transform: 'translateY(-50%)',
width: 14,
height: 14,
borderRadius: 4,
background: 'rgba(88,166,255,0.95)',
border: '1px solid rgba(255,255,255,0.65)',
cursor: 'ew-resize',
}}
onPointerDown={startResizeW}
title="拖动拉宽(控制换行)"
/>
)}
</div>
)
}
// 花字组件
const FancyTextClip: React.FC<{
clip: TimelineClip
ui?: UiBridge
editing?: { clipId: string | null; draft: string }
setEditing?: React.Dispatch<React.SetStateAction<{ clipId: string | null; draft: string }>>
}> = ({ clip, ui, editing, setEditing }) => {
if (!clip.text) return null
const frame = useCurrentFrame()
const { width, height } = useVideoConfig()
// 弹入效果
const scale = interpolate(
frame,
[0, 15],
[0.5, 1],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
)
const style: any = clip.style || {}
const isSelected = !!ui?.selectedClipId && ui.selectedClipId === clip.id
const isEditing = !!editing?.clipId && editing.clipId === clip.id
const position = clip.position || { x: 0.5, y: 0.2 }
const x = resolvePos((position as any).x, width, width * 0.5)
const y = resolvePos((position as any).y, height, 180)
const baseX = normPosPct((position as any).x, width, 0.5)
const baseY = normPosPct((position as any).y, height, 180 / Math.max(1, height))
const renderSize = React.useContext(RenderSizeCtx)
const dragRef = React.useRef<null | { pointerId: number; sx: number; sy: number; bx: number; by: number; started: boolean }>(null)
const rafRef = React.useRef<number | null>(null)
const widthRef = React.useRef<null | { pointerId: number; sx: number; base: number; started: boolean }>(null)
const saveEdit = () => {
if (!isEditing) return
const txt = String(editing?.draft || '').replace(/\u00A0/g, ' ')
// 空文本:不保存,避免“看起来像被删掉”
if (txt.trim() === '') {
setEditing?.({ clipId: null, draft: '' })
return
}
ui?.onPushHistory?.({ label: '编辑文字', icon: 'text' })
ui?.onUpdateClip?.(clip.id, { text: txt })
setEditing?.({ clipId: null, draft: '' })
}
const cancelEdit = () => setEditing?.({ clipId: null, draft: '' })
const startDrag = (e: React.PointerEvent) => {
if (!ui?.onUpdateClip) return
if (isEditing) return
// 不要在 pointerdown 就 preventDefault会破坏 click/dblclick导致“无法双击编辑”
e.stopPropagation()
ui?.onSelectClip?.(clip.id)
dragRef.current = { pointerId: e.pointerId, sx: e.clientX, sy: e.clientY, bx: baseX, by: baseY, started: false }
const prevSel = document.body.style.userSelect
const prevCursor = document.body.style.cursor
const denomW = Math.max(1, Number(renderSize.w || 0) || 0) || width
const denomH = Math.max(1, Number(renderSize.h || 0) || 0) || height
const move = (ev: PointerEvent) => {
const d = dragRef.current
if (!d || d.pointerId !== ev.pointerId) return
const dxPx = ev.clientX - d.sx
const dyPx = ev.clientY - d.sy
if (!d.started) {
// 降低阈值:让拖拽更“跟手”(避免松开才明显位移的乏力感)
if (Math.abs(dxPx) + Math.abs(dyPx) < 1) return
d.started = true
ui?.onPushHistory?.({ label: '移动文字', icon: 'text' })
document.body.style.userSelect = 'none'
document.body.style.cursor = 'grabbing'
}
ev.preventDefault?.()
const nx = Math.max(0, Math.min(1, d.bx + dxPx / denomW))
const ny = Math.max(0, Math.min(1, d.by + dyPx / denomH))
if (rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(() => {
ui?.onUpdateClip?.(clip.id, { position: { x: nx, y: ny } })
rafRef.current = null
})
}
const up = (ev: PointerEvent) => {
const d = dragRef.current
if (!d || d.pointerId !== ev.pointerId) return
dragRef.current = null
try { window.removeEventListener('pointermove', move as any) } catch {}
try { window.removeEventListener('pointerup', up as any) } catch {}
try { window.removeEventListener('pointercancel', up as any) } catch {}
document.body.style.userSelect = prevSel
document.body.style.cursor = prevCursor
}
window.addEventListener('pointermove', move as any, { passive: false } as any)
window.addEventListener('pointerup', up as any)
window.addEventListener('pointercancel', up as any)
}
const startResizeW = (e: React.PointerEvent) => {
if (!ui?.onUpdateClip) return
if (isEditing) return
e.stopPropagation()
e.preventDefault()
ui?.onSelectClip?.(clip.id)
const base = getBoxWPct(style, width, 0.7)
widthRef.current = { pointerId: e.pointerId, sx: e.clientX, base, started: false }
const prevSel = document.body.style.userSelect
const prevCursor = document.body.style.cursor
const denomW = Math.max(1, Number(renderSize.w || 0) || 0) || width
const move = (ev: PointerEvent) => {
const d = widthRef.current
if (!d || d.pointerId !== ev.pointerId) return
const dxPx = ev.clientX - d.sx
if (!d.started) {
if (Math.abs(dxPx) < 1) return
d.started = true
ui?.onPushHistory?.({ label: '调整文字宽度', icon: 'text' })
document.body.style.userSelect = 'none'
document.body.style.cursor = 'ew-resize'
}
ev.preventDefault?.()
const next = clamp(d.base + dxPx / denomW, 0.2, 1.0)
ui?.onUpdateClip?.(clip.id, { style: { ...(style || {}), box_w: next } })
}
const up = (ev: PointerEvent) => {
const d = widthRef.current
if (!d || d.pointerId !== ev.pointerId) return
widthRef.current = null
try { window.removeEventListener('pointermove', move as any) } catch {}
try { window.removeEventListener('pointerup', up as any) } catch {}
try { window.removeEventListener('pointercancel', up as any) } catch {}
document.body.style.userSelect = prevSel
document.body.style.cursor = prevCursor
}
window.addEventListener('pointermove', move as any, { passive: false } as any)
window.addEventListener('pointerup', up as any)
window.addEventListener('pointercancel', up as any)
}
const enterEdit = () => {
ui?.onSelectClip?.(clip.id)
setEditing?.({ clipId: clip.id, draft: String(clip.text || '') })
}
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
transform: `translate(-50%, 0) scale(${scale})`,
pointerEvents: 'auto',
touchAction: 'none',
}}
onPointerDown={startDrag}
onClick={(e) => { e.stopPropagation(); ui?.onSelectClip?.(clip.id) }}
onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); enterEdit() }}
>
<span
style={{
fontSize: (style.font_size as number) || 72,
fontWeight: (style as any).bold ? 800 : 700,
fontStyle: (style as any).italic ? 'italic' : 'normal',
textDecoration: (style as any).underline ? 'underline' : 'none',
fontFamily: toCssFontFamily((style as any).font_family),
color: (style.font_color as string) || '#FFFFFF',
textShadow: '3px 3px 6px rgba(0,0,0,0.9), -3px -3px 6px rgba(0,0,0,0.9)',
display: 'inline-block',
maxWidth: (() => {
const bw = Number((style as any).box_w ?? (style as any).boxW ?? (style as any).max_width ?? (style as any).maxWidth ?? 0) || 0
if (bw > 0 && bw <= 1) return Math.round(width * bw)
if (bw > 1) return Math.round(bw)
return Math.round(width * 0.7)
})(),
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
textAlign: 'center',
outline: isSelected ? '1px solid rgba(88, 166, 255, 0.9)' : 'none',
borderRadius: 10,
cursor: isEditing ? 'text' : 'move',
userSelect: isEditing ? 'text' : 'none',
}}
>
{isEditing ? (
<span
contentEditable
suppressContentEditableWarning
spellCheck={false}
onInput={(e) => {
const txt = (e.currentTarget.textContent ?? '').replace(/\u00A0/g, ' ')
setEditing?.({ clipId: clip.id, draft: txt })
}}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); return }
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); saveEdit(); return }
}}
style={{ outline: 'none', display: 'inline-block' }}
>
{editing?.draft ?? ''}
</span>
) : (
clip.text
)}
</span>
{/* 仅选中且非编辑:显示“拉宽”手柄(控制换行 box_w */}
{isSelected && !isEditing && (
<div
style={{
position: 'absolute',
right: -6,
top: '50%',
transform: 'translateY(-50%)',
width: 14,
height: 14,
borderRadius: 4,
background: 'rgba(88,166,255,0.95)',
border: '1px solid rgba(255,255,255,0.65)',
cursor: 'ew-resize',
}}
onPointerDown={startResizeW}
title="拖动拉宽(控制换行)"
/>
)}
</div>
)
}
// 贴纸组件
const StickerClip: React.FC<{
clip: TimelineClip
}> = ({ clip }) => {
if (!clip.sourceUrl) return null
const { width, height } = useVideoConfig()
const style: any = clip.style || {}
const position = clip.position || { x: 0.8, y: 0.2 }
const x = resolvePos((position as any).x, width, width * 0.8)
const y = resolvePos((position as any).y, height, height * 0.2)
const scale = Math.max(0.3, Math.min(3.0, Number(style.scale ?? 1) || 1))
const rotate = Number(style.rotate ?? 0) || 0
const base = Math.round(Math.min(width, height) * 0.22)
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
transform: `translate(-50%, -50%) scale(${scale}) rotate(${rotate}deg)`,
transformOrigin: 'center',
width: base,
height: base,
pointerEvents: 'none',
}}
>
<Img src={clip.sourceUrl} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
</div>
)
}
// 音频组件
const AudioClip: React.FC<{
clip: TimelineClip
volume?: (frame: number) => number
}> = ({ clip, volume }) => {
if (!clip.sourceUrl) return null
return (
<Audio
src={clip.sourceUrl}
volume={volume ?? (clip.volume || 1)}
playbackRate={Number((clip as any).playbackRate ?? 1) || 1}
loop={Boolean((clip as any).loop ?? (clip as any).style?.loop)}
/>
)
}
// Props
export interface VideoCompositionProps {
tracks: Track[]
fps?: number
subtitleStyle?: Record<string, unknown>
preview?: {
heavyOverlays?: boolean
}
audio?: {
soloTrackIds?: string[]
}
ui?: UiBridge
}
// 主合成组件
export const VideoComposition: React.FC<VideoCompositionProps> = ({
tracks,
subtitleStyle,
preview,
audio,
ui,
}) => {
const { fps } = useVideoConfig()
const heavy = preview?.heavyOverlays !== false
const soloSet = new Set((audio?.soloTrackIds || []).filter(Boolean))
const hasSolo = soloSet.size > 0
const [editing, setEditing] = React.useState<{ clipId: string | null; draft: string }>({ clipId: null, draft: '' })
const [renderSize, setRenderSize] = React.useState<{ w: number; h: number }>({ w: 0, h: 0 })
const sizeProbeRef = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
const el = sizeProbeRef.current
if (!el) return
const update = () => {
const r = el.getBoundingClientRect()
const w = Math.round(r.width || 0)
const h = Math.round(r.height || 0)
if (w > 0 && h > 0) setRenderSize({ w, h })
}
update()
const ro = new ResizeObserver(() => update())
try { ro.observe(el) } catch {}
const id = window.setInterval(update, 500) // 兜底:部分浏览器 ResizeObserver 偶发不触发
return () => {
try { ro.disconnect() } catch {}
clearInterval(id)
}
}, [])
// 分离不同类型的轨道
const videoTrack = tracks.find(t => t.type === 'video')
const subtitleTrack = tracks.find(t => t.type === 'subtitle')
const fancyTextTrack = tracks.find(t => t.type === 'fancy_text')
const stickerTrack = tracks.find(t => t.type === 'sticker')
const voiceoverTrack = tracks.find(t => t.id === 'audio-voiceover')
const bgmTrack = tracks.find(t => t.type === 'bgm')
// 预计算旁白区间(用于 BGM ducking
const voRanges = (voiceoverTrack?.clips || []).map(c => {
const s = c.start ?? 0
const e = s + (c.duration ?? 0)
return { s, e }
})
const isVoiceAt = (t: number) => voRanges.some(r => t >= r.s && t <= r.e)
return (
<RenderSizeCtx.Provider value={renderSize}>
<AbsoluteFill style={{ backgroundColor: '#000' }}>
{/* 仅用于测量“实际渲染像素尺寸”(用于拖拽严格跟随鼠标) */}
<div ref={sizeProbeRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
{/* 视频层 */}
{videoTrack?.clips.map(clip => (
<Sequence
key={clip.id}
from={(() => {
// 用 floor/ceil 避免浮点取整导致的“1帧黑屏缝隙”
const s = Math.max(0, clip.start ?? 0)
return Math.floor(s * fps)
})()}
durationInFrames={(() => {
const s = Math.max(0, clip.start ?? 0)
const e = s + Math.max(0, clip.duration ?? 0)
const startF = Math.floor(s * fps)
const endF = Math.max(startF + 1, Math.ceil(e * fps))
return endF - startF
})()}
>
<VideoClip
clip={clip}
volume={
(videoTrack && (!hasSolo || soloSet.has(videoTrack.id)) && videoTrack.muted !== true)
? ((clip.volume ?? 0.2) as number)
: 0
}
/>
</Sequence>
))}
{/* 贴纸层(在文字下方,避免挡字幕;可后续支持层级调整) */}
{stickerTrack?.visible !== false && stickerTrack?.clips?.map(clip => (
<Sequence
key={clip.id}
from={Math.round((clip.start ?? 0) * fps)}
durationInFrames={Math.round((clip.duration ?? 0) * fps)}
>
<StickerClip clip={clip} />
</Sequence>
))}
{/* 花字层 */}
{fancyTextTrack?.visible !== false && fancyTextTrack?.clips.map(clip => (
<Sequence
key={clip.id}
from={Math.round(clip.start * fps)}
durationInFrames={Math.round(clip.duration * fps)}
>
<FancyTextClip
clip={{ ...clip, style: { ...(clip.style || {}), _preview_heavy: heavy } }}
ui={ui}
editing={editing}
setEditing={setEditing}
/>
</Sequence>
))}
{/* 字幕层 */}
{subtitleTrack?.visible !== false && subtitleTrack?.clips.map(clip => (
<Sequence
key={clip.id}
from={Math.round(clip.start * fps)}
durationInFrames={Math.round(clip.duration * fps)}
>
<SubtitleClip
clip={{ ...clip, style: { ...(subtitleStyle || {}), ...(clip.style || {}), _preview_heavy: heavy } }}
ui={ui}
editing={editing}
setEditing={setEditing}
/>
</Sequence>
))}
{/* 旁白音频 */}
{(voiceoverTrack && (!hasSolo || soloSet.has(voiceoverTrack.id)) && voiceoverTrack?.muted !== true) && voiceoverTrack.clips.map(clip => (
<Sequence
key={clip.id}
from={Math.round(clip.start * fps)}
durationInFrames={Math.round(clip.duration * fps)}
>
<AudioClip
clip={clip}
volume={(frame) => {
const from = Math.round((clip.start ?? 0) * fps)
const durF = Math.max(1, Math.round((clip.duration ?? 0) * fps))
const local = frame - from
const fade = makeFade(local, durF, fps, clip.fadeIn, clip.fadeOut)
return clamp01((clip.volume ?? 1) * fade)
}}
/>
</Sequence>
))}
{/* BGM */}
{(bgmTrack && (!hasSolo || soloSet.has(bgmTrack.id)) && bgmTrack?.muted !== true) && bgmTrack.clips.map(clip => (
<Sequence
key={clip.id}
from={Math.round(clip.start * fps)}
durationInFrames={Math.round(clip.duration * fps)}
>
<AudioClip
clip={clip}
volume={(frame) => {
const from = Math.round((clip.start ?? 0) * fps)
const durF = Math.max(1, Math.round((clip.duration ?? 0) * fps))
const local = frame - from
const t = frame / fps
const fade = makeFade(local, durF, fps, clip.fadeIn ?? 0.8, clip.fadeOut ?? 0.8)
const base = (clip.volume ?? 0.15) as number
const duckEnabled = typeof clip.ducking === 'boolean' ? clip.ducking : true
const duckVol = (clip.duckVolume ?? 0.25) as number
const duck = duckEnabled && isVoiceAt(t) ? duckVol : 1
return clamp01(base * fade * duck)
}}
/>
</Sequence>
))}
</AbsoluteFill>
</RenderSizeCtx.Provider>
)
}
export default VideoComposition

18
web/src/remotion/index.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* Remotion 组件导出
*/
export { VideoComposition } from './VideoComposition'

View File

@@ -0,0 +1,734 @@
/**
* 编辑器状态管理 (Zustand)
* 管理时间轴、轨道、播放状态
*/
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { generateId } from '@/lib/utils'
// 类型定义
export interface TimelineClip {
id: string
type: 'video' | 'audio' | 'subtitle' | 'fancy_text' | 'bgm' | 'sticker'
start: number
duration: number
sourcePath?: string
sourceUrl?: string
trimStart?: number
trimEnd?: number
sourceDuration?: number
text?: string
style?: Record<string, unknown>
position?: { x: string | number; y: string | number }
volume?: number
fadeIn?: number
fadeOut?: number
// 仅 BGM自动闪避有人声时降低到 duckVolume 倍)
ducking?: boolean
duckVolume?: number
// 旁白:纯播放倍速(预览=导出。1=正常0.5=慢2=快
playbackRate?: number
groupId?: string
/**
* 仅旁白:当用户修改了文本/时长后,现有配音可能不再匹配。
* 用于 UX 提示(不参与导出逻辑、也不需要持久化)。
*/
needsVoiceoverRegenerate?: boolean
}
export interface Track {
id: string
name: string
type: string
clips: TimelineClip[]
locked: boolean
visible: boolean
muted: boolean
collapsed?: boolean
}
export interface HistoryEntry {
tracks: Track[]
label: string
icon?: string
ts: number
}
interface EditorState {
// 项目信息
projectId: string | null
// 时间轴状态
tracks: Track[]
totalDuration: number
currentTime: number
isPlaying: boolean
// 编辑策略
rippleMode: boolean
// 音频:轨道 Solo任意 Solo 时,仅 Solo 轨道参与预览播放)
soloTrackIds: string[]
// 字幕全局样式MVP
subtitleStyle: Record<string, unknown>
// 预览性能档位(不影响最终导出)
previewFps: number
previewScale: number
previewHeavyOverlays: boolean
// 缩放和滚动
zoom: number
scrollX: number
// 选中状态
selectedClipId: string | null
selectedTrackId: string | null
selectedClipIds: string[]
// 辅助线(吸附/对齐提示)
snapGuideTime: number | null
// 撤销/重做栈(最多 30 步)
history: HistoryEntry[]
historyIndex: number
// 加载状态
isLoading: boolean
isSaving: boolean
// Actions
setProjectId: (id: string) => void
setTracks: (tracks: Track[]) => void
setTotalDuration: (duration: number) => void
setCurrentTime: (time: number) => void
setIsPlaying: (playing: boolean) => void
setRippleMode: (enabled: boolean) => void
toggleTrackSolo: (trackId: string) => void
setSubtitleStyle: (style: Record<string, unknown>) => void
setPreviewQuality: (q: Partial<Pick<EditorState, 'previewFps' | 'previewScale' | 'previewHeavyOverlays'>>) => void
setZoom: (zoom: number) => void
setScrollX: (x: number) => void
// 选择
selectClip: (clipId: string | null) => void
selectTrack: (trackId: string | null) => void
setSelectedClips: (clipIds: string[]) => void
toggleClipSelection: (clipId: string, mode?: 'replace' | 'toggle' | 'add') => void
clearSelection: () => void
setSnapGuideTime: (t: number | null) => void
// 分组(跨轨联动)
groupSelected: (groupId?: string) => void
ungroupSelected: () => void
shiftGroup: (groupId: string, delta: number) => void
// 片段操作
addClip: (trackId: string, clip: Omit<TimelineClip, 'id'>) => void
updateClip: (trackId: string, clipId: string, updates: Partial<TimelineClip>) => void
deleteClip: (trackId: string, clipId: string) => void
deleteSelectedClips: () => void
deleteAtPlayhead: () => void
moveClip: (fromTrackId: string, toTrackId: string, clipId: string, newStart: number) => void
splitClip: (trackId: string, clipId: string, time: number) => void
splitAtTime: (time: number) => void
rippleShiftFrom: (trackId: string, clipId: string, delta: number) => void
// 轨道操作
addTrack: (track: Omit<Track, 'id'>) => void
updateTrack: (trackId: string, updates: Partial<Track>) => void
deleteTrack: (trackId: string) => void
toggleTrackMute: (trackId: string) => void
toggleTrackLock: (trackId: string) => void
toggleTrackCollapse: (trackId: string) => void
// 历史操作
pushHistory: (meta?: { label?: string; icon?: string }) => void
undo: () => void
redo: () => void
jumpToHistory: (index: number) => void
// 重置
reset: () => void
}
const initialState = {
projectId: null,
tracks: [],
totalDuration: 0,
currentTime: 0,
isPlaying: false,
rippleMode: true,
soloTrackIds: [],
subtitleStyle: {
fontsize: 60,
fontcolor: 'white',
borderw: 5,
bordercolor: 'black',
box: 1,
boxcolor: 'black@0.5',
boxborderw: 10,
x: '(w-text_w)/2',
y: 'h-200',
},
previewFps: 30,
previewScale: 1,
previewHeavyOverlays: true,
zoom: 1,
scrollX: 0,
selectedClipId: null,
selectedTrackId: null,
selectedClipIds: [],
snapGuideTime: null,
history: [],
historyIndex: -1,
isLoading: false,
isSaving: false,
}
export const useEditorStore = create<EditorState>()(
immer((set) => {
const recalcTotalDuration = (tracks: Track[]) => {
let maxEnd = 0
for (const t of tracks || []) {
for (const c of t.clips || []) {
const end = (c.start || 0) + (c.duration || 0)
if (end > maxEnd) maxEnd = end
}
}
return maxEnd
}
const clampVideoDur = (c: TimelineClip, minDur: number) => {
let trimStart = c.trimStart ?? 0
let duration = Math.max(minDur, c.duration ?? minDur)
if (typeof c.sourceDuration === 'number' && c.sourceDuration > 0) {
// trimStart 不能超过 sourceDuration-minDur否则会出现 trimEnd 超界导致预览卡住
trimStart = Math.max(0, Math.min(trimStart, Math.max(0, c.sourceDuration - minDur)))
duration = Math.min(duration, Math.max(minDur, c.sourceDuration - trimStart))
}
c.trimStart = trimStart
c.duration = duration
c.trimEnd = trimStart + duration
}
// 兜底:强制同轨道视频永不重叠(默认 Ripple发生碰撞时推挤后续片段
// 规则:
// - 不允许与前一个片段重叠:当前片段会被 clamp 到 prevEnd
// - 不允许与后一个片段重叠:后续片段会被整体 push 到不重叠Ripple
const enforceVideoNoOverlap = (track: Track) => {
if (!track || track.type !== 'video') return
const minDur = 0.5
track.clips.sort((a, b) => (a.start ?? 0) - (b.start ?? 0))
// 基础数据修正 + sourceDuration 保护
for (const c of track.clips) {
if (typeof c.start !== 'number' || !Number.isFinite(c.start)) c.start = 0
if (typeof c.duration !== 'number' || !Number.isFinite(c.duration)) c.duration = minDur
c.start = Math.max(0, c.start)
clampVideoDur(c, minDur)
}
// Ripple 推挤:确保每个 clip.start >= prevEnd
let prevEnd = 0
for (let i = 0; i < track.clips.length; i++) {
const c = track.clips[i]
const s = c.start ?? 0
if (s < prevEnd - 1e-6) {
c.start = prevEnd
}
prevEnd = (c.start ?? 0) + (c.duration ?? 0)
}
}
return ({
...initialState,
setProjectId: (id) => set({ projectId: id }),
setTracks: (tracks) => set({
tracks,
totalDuration: recalcTotalDuration(tracks),
}),
setTotalDuration: (duration) => set({ totalDuration: duration }),
setCurrentTime: (time) => set({ currentTime: time }),
setIsPlaying: (playing) => set({ isPlaying: playing }),
// Ripple 默认强制开启(去掉开关,避免用户困惑)
setRippleMode: (_enabled) => set({ rippleMode: true }),
toggleTrackSolo: (trackId) => set((state) => {
const cur = new Set(state.soloTrackIds || [])
if (cur.has(trackId)) cur.delete(trackId)
else cur.add(trackId)
state.soloTrackIds = Array.from(cur)
}),
setSubtitleStyle: (style) => set((state) => {
state.subtitleStyle = { ...(state.subtitleStyle || {}), ...(style || {}) }
}),
setPreviewQuality: (q) => set((state) => {
if (typeof q.previewFps === 'number' && q.previewFps > 0) state.previewFps = q.previewFps
if (typeof q.previewScale === 'number' && q.previewScale > 0) state.previewScale = q.previewScale
if (typeof q.previewHeavyOverlays === 'boolean') state.previewHeavyOverlays = q.previewHeavyOverlays
}),
setZoom: (zoom) => set({ zoom: Math.max(0.1, Math.min(10, zoom)) }),
setScrollX: (x) => set({ scrollX: Math.max(0, x) }),
selectClip: (clipId) => set((state) => {
state.selectedClipId = clipId
state.selectedClipIds = clipId ? [clipId] : []
// 自动推断 trackId方便快捷键/属性面板
if (!clipId) {
state.selectedTrackId = null
return
}
for (const t of state.tracks) {
if (t.clips.some(c => c.id === clipId)) {
state.selectedTrackId = t.id
return
}
}
}),
selectTrack: (trackId) => set({ selectedTrackId: trackId }),
setSelectedClips: (clipIds) => set((state) => {
const uniq = Array.from(new Set((clipIds || []).filter(Boolean)))
state.selectedClipIds = uniq
state.selectedClipId = uniq.length ? uniq[uniq.length - 1] : null
// 尝试推断 selectedTrackId
state.selectedTrackId = null
if (state.selectedClipId) {
for (const t of state.tracks) {
if (t.clips.some(c => c.id === state.selectedClipId)) {
state.selectedTrackId = t.id
break
}
}
}
}),
toggleClipSelection: (clipId, mode = 'replace') => set((state) => {
const cur = new Set(state.selectedClipIds || [])
if (mode === 'replace') {
state.selectedClipIds = clipId ? [clipId] : []
state.selectedClipId = clipId || null
} else if (mode === 'toggle') {
if (cur.has(clipId)) cur.delete(clipId); else cur.add(clipId)
state.selectedClipIds = Array.from(cur)
state.selectedClipId = state.selectedClipIds.length ? state.selectedClipIds[state.selectedClipIds.length - 1] : null
} else {
cur.add(clipId)
state.selectedClipIds = Array.from(cur)
state.selectedClipId = clipId
}
// 推断 trackId
state.selectedTrackId = null
if (state.selectedClipId) {
for (const t of state.tracks) {
if (t.clips.some(c => c.id === state.selectedClipId)) {
state.selectedTrackId = t.id
break
}
}
}
}),
clearSelection: () => set((state) => {
state.selectedClipId = null
state.selectedTrackId = null
state.selectedClipIds = []
}),
setSnapGuideTime: (t) => set((state) => {
state.snapGuideTime = t
}),
addClip: (trackId, clip) => set((state) => {
const track = state.tracks.find(t => t.id === trackId)
if (track && !track.locked) {
const newClip = { ...clip, id: generateId() }
track.clips.push(newClip as TimelineClip)
track.clips.sort((a, b) => a.start - b.start)
if (track.type === 'video') enforceVideoNoOverlap(track)
state.totalDuration = recalcTotalDuration(state.tracks)
}
}),
updateClip: (trackId, clipId, updates) => set((state) => {
const track = state.tracks.find(t => t.id === trackId)
if (!track) return
// 轨道“锁定:防误触”主要防止误拖时间轴上的 start/duration。
// 但字幕/花字/贴纸的画面内位置/样式/文本属于“有意操作”,锁定也应允许,否则会出现“选中但拖不动”。
if (track.locked) {
const allowTrack = track.type === 'subtitle' || track.type === 'fancy_text' || track.type === 'sticker'
const keys = Object.keys(updates || {})
const allowKeys = keys.every(k => k === 'position' || k === 'style' || k === 'text')
if (!allowTrack || !allowKeys) return
}
{
const clip = track.clips.find(c => c.id === clipId)
if (clip) {
const prevDur = clip.duration
const prevText = clip.text
Object.assign(clip, updates)
// UX旁白片段被拉伸/改文案后,提醒用户“需要重新生成以适配时长/文本”
if (trackId === 'audio-voiceover') {
const textChanged = typeof updates.text === 'string' && updates.text !== prevText
// 如果用户更新了音频来源(重新生成成功),就清除提示
const hasNewAudio = typeof updates.sourceUrl === 'string' || typeof updates.sourcePath === 'string'
if (hasNewAudio) {
clip.needsVoiceoverRegenerate = false
} else if (textChanged && (clip.text || '').trim().length > 0) {
clip.needsVoiceoverRegenerate = true
}
// 纯播放倍速:当用户改动旁白片段时长,自动计算倍速以“贴合当前时长”
if (typeof updates.duration === 'number' && Number.isFinite(updates.duration) && updates.duration > 0) {
const srcDur = typeof clip.sourceDuration === 'number' && clip.sourceDuration > 0
? clip.sourceDuration
: (typeof prevDur === 'number' && prevDur > 0 ? prevDur : undefined)
if (srcDur) {
const r = srcDur / Math.max(0.01, clip.duration)
clip.playbackRate = Math.max(0.5, Math.min(2.0, Number(r) || 1))
// 拉长/缩短已由倍速适配,不需要“重新生成”提示
clip.needsVoiceoverRegenerate = false
}
}
}
if (track.type === 'video') {
enforceVideoNoOverlap(track)
}
state.totalDuration = recalcTotalDuration(state.tracks)
}
}
}),
deleteClip: (trackId, clipId) => set((state) => {
const track = state.tracks.find(t => t.id === trackId)
if (track && !track.locked) {
track.clips = track.clips.filter(c => c.id !== clipId)
state.totalDuration = recalcTotalDuration(state.tracks)
}
if (state.selectedClipId === clipId) {
state.selectedClipId = null
}
if (state.selectedClipIds?.includes(clipId)) {
state.selectedClipIds = state.selectedClipIds.filter(id => id !== clipId)
}
}),
deleteSelectedClips: () => set((state) => {
const ids = new Set((state.selectedClipIds || []).filter(Boolean))
if (!ids.size) return
for (const t of state.tracks) {
if (t.locked) continue
const before = t.clips.length
t.clips = t.clips.filter(c => !ids.has(c.id))
if (t.type === 'video' && t.clips.length !== before) {
enforceVideoNoOverlap(t)
}
}
state.selectedClipIds = []
state.selectedClipId = null
state.selectedTrackId = null
state.totalDuration = recalcTotalDuration(state.tracks)
}),
deleteAtPlayhead: () => set((state) => {
const t = Math.max(0, state.currentTime ?? 0)
// 优先删“当前选中”
if (state.selectedClipId && state.selectedTrackId) {
const tr = state.tracks.find(x => x.id === state.selectedTrackId)
if (tr && !tr.locked) {
const exists = tr.clips.find(c => c.id === state.selectedClipId)
if (exists) {
tr.clips = tr.clips.filter(c => c.id !== state.selectedClipId)
if (tr.type === 'video') enforceVideoNoOverlap(tr)
state.selectedClipId = null
state.selectedTrackId = null
state.selectedClipIds = (state.selectedClipIds || []).filter(id => id !== exists.id)
state.totalDuration = recalcTotalDuration(state.tracks)
return
}
}
}
// 否则删红线所在的“视频片段”
const vTrack = state.tracks.find(x => x.type === 'video')
if (!vTrack || vTrack.locked) return
const clip = (vTrack.clips || []).find(c => t >= (c.start ?? 0) && t < ((c.start ?? 0) + (c.duration ?? 0)))
if (!clip) return
vTrack.clips = vTrack.clips.filter(c => c.id !== clip.id)
enforceVideoNoOverlap(vTrack)
state.totalDuration = recalcTotalDuration(state.tracks)
if (state.selectedClipIds?.includes(clip.id)) state.selectedClipIds = state.selectedClipIds.filter(id => id !== clip.id)
if (state.selectedClipId === clip.id) state.selectedClipId = null
if (state.selectedTrackId === vTrack.id) state.selectedTrackId = null
}),
moveClip: (fromTrackId, toTrackId, clipId, newStart) => set((state) => {
const fromTrack = state.tracks.find(t => t.id === fromTrackId)
const toTrack = state.tracks.find(t => t.id === toTrackId)
if (fromTrack && toTrack && !toTrack.locked) {
const clipIndex = fromTrack.clips.findIndex(c => c.id === clipId)
if (clipIndex !== -1) {
const [clip] = fromTrack.clips.splice(clipIndex, 1)
clip.start = Math.max(0, newStart)
toTrack.clips.push(clip)
toTrack.clips.sort((a, b) => a.start - b.start)
if (fromTrack.type === 'video') enforceVideoNoOverlap(fromTrack)
if (toTrack.type === 'video') enforceVideoNoOverlap(toTrack)
state.totalDuration = recalcTotalDuration(state.tracks)
}
}
}),
groupSelected: (groupId) => set((state) => {
const ids = state.selectedClipIds || []
if (!ids.length) return
const gid = groupId || `grp_${generateId()}`
for (const t of state.tracks) {
for (const c of t.clips) {
if (ids.includes(c.id)) c.groupId = gid
}
}
}),
ungroupSelected: () => set((state) => {
const ids = state.selectedClipIds || []
if (!ids.length) return
for (const t of state.tracks) {
for (const c of t.clips) {
if (ids.includes(c.id)) delete c.groupId
}
}
}),
shiftGroup: (groupId, delta) => set((state) => {
if (!groupId || !Number.isFinite(delta) || Math.abs(delta) < 1e-9) return
for (const t of state.tracks) {
for (const c of t.clips) {
if (c.groupId === groupId) {
c.start = Math.max(0, (c.start ?? 0) + delta)
}
}
t.clips.sort((a, b) => a.start - b.start)
}
state.totalDuration = recalcTotalDuration(state.tracks)
}),
splitClip: (trackId, clipId, time) => set((state) => {
const track = state.tracks.find(t => t.id === trackId)
if (!track || track.locked) return
const idx = track.clips.findIndex(c => c.id === clipId)
if (idx === -1) return
const clip = track.clips[idx]
const splitOffset = time - clip.start
const minDur = 0.5
if (splitOffset <= minDur || splitOffset >= clip.duration - minDur) return
const aId = generateId()
const bId = generateId()
const trimStart = clip.trimStart ?? 0
const clipA: TimelineClip = {
...clip,
id: aId,
duration: splitOffset,
trimStart,
trimEnd: trimStart + splitOffset,
}
const clipBTrimStart = trimStart + splitOffset
const clipB: TimelineClip = {
...clip,
id: bId,
start: clip.start + splitOffset,
duration: clip.duration - splitOffset,
trimStart: clipBTrimStart,
trimEnd: clipBTrimStart + (clip.duration - splitOffset),
}
// replace in place
track.clips.splice(idx, 1, clipA, clipB)
track.clips.sort((x, y) => x.start - y.start)
if (track.type === 'video') enforceVideoNoOverlap(track)
state.selectedClipId = clipB.id
state.totalDuration = recalcTotalDuration(state.tracks)
}),
// 以红色播放头为准切分:不要求选中片段;会对所有未锁定轨道中“覆盖该时间点”的片段生效
splitAtTime: (time) => set((state) => {
const t = Number(time)
if (!Number.isFinite(t) || t <= 0) return
const minDur = 0.5
for (const track of state.tracks) {
if (!track || track.locked) continue
const idx = track.clips.findIndex(c => {
const s = c.start ?? 0
const e = s + (c.duration ?? 0)
return s + minDur < t && t < e - minDur
})
if (idx === -1) continue
const clip = track.clips[idx]
const splitOffset = t - (clip.start ?? 0)
if (splitOffset <= minDur || splitOffset >= (clip.duration ?? 0) - minDur) continue
const aId = generateId()
const bId = generateId()
const trimStart0 = clip.trimStart ?? 0
const clipA: TimelineClip = {
...clip,
id: aId,
duration: splitOffset,
trimStart: track.type === 'video' ? trimStart0 : clip.trimStart,
trimEnd: track.type === 'video' ? (trimStart0 + splitOffset) : clip.trimEnd,
}
const clipBTrimStart = trimStart0 + splitOffset
const clipB: TimelineClip = {
...clip,
id: bId,
start: (clip.start ?? 0) + splitOffset,
duration: (clip.duration ?? 0) - splitOffset,
trimStart: track.type === 'video' ? clipBTrimStart : clip.trimStart,
trimEnd: track.type === 'video' ? (clipBTrimStart + ((clip.duration ?? 0) - splitOffset)) : clip.trimEnd,
}
if (track.type === 'video') {
clampVideoDur(clipA, minDur)
clampVideoDur(clipB, minDur)
}
track.clips.splice(idx, 1, clipA, clipB)
track.clips.sort((x, y) => (x.start ?? 0) - (y.start ?? 0))
if (track.type === 'video') enforceVideoNoOverlap(track)
state.selectedClipId = clipB.id
state.selectedTrackId = track.id
}
state.totalDuration = recalcTotalDuration(state.tracks)
}),
rippleShiftFrom: (trackId, clipId, delta) => set((state) => {
const track = state.tracks.find(t => t.id === trackId)
if (!track || track.locked) return
const ordered = track.clips.slice().sort((a, b) => a.start - b.start)
const idx = ordered.findIndex(c => c.id === clipId)
if (idx === -1) return
const ids = new Set(ordered.slice(idx).map(c => c.id))
for (const c of track.clips) {
if (ids.has(c.id)) {
c.start = Math.max(0, (c.start ?? 0) + delta)
}
}
track.clips.sort((a, b) => a.start - b.start)
if (track.type === 'video') enforceVideoNoOverlap(track)
state.totalDuration = recalcTotalDuration(state.tracks)
}),
addTrack: (track) => set((state) => {
state.tracks.push({
...track,
id: generateId(),
} as Track)
}),
updateTrack: (trackId, updates) => set((state) => {
const track = state.tracks.find(t => t.id === trackId)
if (track) {
Object.assign(track, updates)
}
}),
deleteTrack: (trackId) => set((state) => {
state.tracks = state.tracks.filter(t => t.id !== trackId)
state.totalDuration = recalcTotalDuration(state.tracks)
if (state.selectedTrackId === trackId) {
state.selectedTrackId = null
}
}),
toggleTrackMute: (trackId) => set((state) => {
const track = state.tracks.find(t => t.id === trackId)
if (track) {
track.muted = !track.muted
}
}),
toggleTrackLock: (trackId) => set((state) => {
const track = state.tracks.find(t => t.id === trackId)
if (track) {
track.locked = !track.locked
}
}),
toggleTrackCollapse: (trackId) => set((state) => {
const track = state.tracks.find(t => t.id === trackId)
if (track) {
track.collapsed = !track.collapsed
}
}),
pushHistory: (meta) => set((state) => {
const newHistory = state.history.slice(0, state.historyIndex + 1)
newHistory.push({
tracks: JSON.parse(JSON.stringify(state.tracks)),
label: meta?.label || '编辑',
icon: meta?.icon,
ts: Date.now(),
})
state.history = newHistory.slice(-30) // 最多保留30步
state.historyIndex = state.history.length - 1
}),
undo: () => set((state) => {
if (state.historyIndex > 0) {
state.historyIndex--
state.tracks = JSON.parse(JSON.stringify(state.history[state.historyIndex].tracks))
state.totalDuration = recalcTotalDuration(state.tracks)
}
}),
redo: () => set((state) => {
if (state.historyIndex < state.history.length - 1) {
state.historyIndex++
state.tracks = JSON.parse(JSON.stringify(state.history[state.historyIndex].tracks))
state.totalDuration = recalcTotalDuration(state.tracks)
}
}),
jumpToHistory: (index) => set((state) => {
const i = Math.max(0, Math.min(index, state.history.length - 1))
if (i < 0 || i >= state.history.length) return
state.historyIndex = i
state.tracks = JSON.parse(JSON.stringify(state.history[i].tracks))
state.totalDuration = recalcTotalDuration(state.tracks)
}),
reset: () => set(initialState),
})
})
)

5
web/src/templates.ts Normal file
View File

@@ -0,0 +1,5 @@
// Back-compat re-export
export * from './lib/templates'

55
web/tailwind.config.js Normal file
View File

@@ -0,0 +1,55 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// 深色主题 - 专业视频编辑器风格
editor: {
bg: '#1a1a1a',
panel: '#242424',
surface: '#2d2d2d',
border: '#3d3d3d',
hover: '#404040',
accent: '#3b82f6',
'accent-hover': '#2563eb',
text: '#e5e5e5',
'text-muted': '#a3a3a3',
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
},
track: {
video: '#3b82f6',
audio: '#22c55e',
voiceover: '#8b5cf6',
subtitle: '#f59e0b',
fancy: '#ec4899',
bgm: '#06b6d4',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'Menlo', 'Monaco', 'monospace'],
},
},
},
plugins: [],
}

39
web/tsconfig.json Normal file
View File

@@ -0,0 +1,39 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

24
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

39
web/vite.config.ts Normal file
View File

@@ -0,0 +1,39 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/static': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})