chore: sync code and project files
This commit is contained in:
47
web/Dockerfile
Normal file
47
web/Dockerfile
Normal 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
30
web/index.html
Normal 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
59
web/nginx.conf
Normal 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
38
web/package.json
Normal 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
20
web/postcss.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
31
web/src/App.tsx
Normal file
31
web/src/App.tsx
Normal 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
6
web/src/ClipItem.tsx
Normal 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
6
web/src/EditorPage.tsx
Normal 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
6
web/src/Timeline.tsx
Normal 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
6
web/src/TrackPanel.tsx
Normal 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
6
web/src/TrackRow.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// Back-compat re-export
|
||||
export { TrackRow } from './components/Timeline/TrackRow'
|
||||
export { default } from './components/Timeline/TrackRow'
|
||||
|
||||
|
||||
|
||||
6
web/src/VideoComposition.tsx
Normal file
6
web/src/VideoComposition.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// Back-compat re-export
|
||||
export { VideoComposition } from './remotion/VideoComposition'
|
||||
export { default } from './remotion/VideoComposition'
|
||||
|
||||
|
||||
|
||||
366
web/src/components/Timeline/ClipItem.tsx
Normal file
366
web/src/components/Timeline/ClipItem.tsx
Normal 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
|
||||
|
||||
44
web/src/components/Timeline/Playhead.tsx
Normal file
44
web/src/components/Timeline/Playhead.tsx
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
72
web/src/components/Timeline/TimeRuler.tsx
Normal file
72
web/src/components/Timeline/TimeRuler.tsx
Normal 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
|
||||
|
||||
408
web/src/components/Timeline/Timeline.tsx
Normal file
408
web/src/components/Timeline/Timeline.tsx
Normal 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
|
||||
|
||||
872
web/src/components/Timeline/TrackPanel.tsx
Normal file
872
web/src/components/Timeline/TrackPanel.tsx
Normal 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">X(0~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">Y(0~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_family(renderer 可解析,预览也能 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
|
||||
|
||||
171
web/src/components/Timeline/TrackRow.tsx
Normal file
171
web/src/components/Timeline/TrackRow.tsx
Normal 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
|
||||
|
||||
23
web/src/components/Timeline/index.ts
Normal file
23
web/src/components/Timeline/index.ts
Normal 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
5
web/src/editorStore.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Back-compat re-export
|
||||
export * from './store/editorStore'
|
||||
|
||||
|
||||
|
||||
118
web/src/index.css
Normal file
118
web/src/index.css
Normal 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
283
web/src/lib/api.ts
Normal 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/track(camelCase)转换为后端接口期望的 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
86
web/src/lib/templates.ts
Normal 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
|
||||
}
|
||||
|
||||
|
||||
|
||||
210
web/src/lib/timelineRules.ts
Normal file
210
web/src/lib/timelineRules.ts
Normal 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 }
|
||||
}
|
||||
|
||||
|
||||
69
web/src/lib/transitions.ts
Normal file
69
web/src/lib/transitions.ts
Normal 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
160
web/src/lib/utils.ts
Normal 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
36
web/src/main.tsx
Normal 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
1525
web/src/pages/EditorPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
172
web/src/pages/ProjectsPage.tsx
Normal file
172
web/src/pages/ProjectsPage.tsx
Normal 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
19
web/src/pages/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Pages 导出
|
||||
*/
|
||||
export { ProjectsPage } from './ProjectsPage'
|
||||
export { EditorPage } from './EditorPage'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
866
web/src/remotion/VideoComposition.tsx
Normal file
866
web/src/remotion/VideoComposition.tsx
Normal 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)`)
|
||||
|
||||
// flash:brightness 峰值在 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
18
web/src/remotion/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Remotion 组件导出
|
||||
*/
|
||||
export { VideoComposition } from './VideoComposition'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
734
web/src/store/editorStore.ts
Normal file
734
web/src/store/editorStore.ts
Normal 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
5
web/src/templates.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Back-compat re-export
|
||||
export * from './lib/templates'
|
||||
|
||||
|
||||
|
||||
55
web/tailwind.config.js
Normal file
55
web/tailwind.config.js
Normal 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
39
web/tsconfig.json
Normal 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
24
web/tsconfig.node.json
Normal 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
39
web/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user