feat: Add editor enhancements - export video, audio preview, publish panel, configurable ports

This commit is contained in:
empty
2026-01-06 17:29:43 +08:00
parent 79a6c2ef3e
commit 96eacf06ba
18 changed files with 2946 additions and 1701 deletions

View File

@@ -1,12 +1,13 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { useSearchParams } from 'next/navigation'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { Save, Download, Settings, ArrowLeft, Loader2 } from 'lucide-react'
import { Save, Download, Settings, ArrowLeft, Loader2, Check, X } from 'lucide-react'
import { Timeline } from '@/components/timeline'
import { PreviewPlayer } from '@/components/preview'
import { PublishPanel } from '@/components/publish'
import { useEditorStore, Storyboard } from '@/stores/editor-store'
import { editorApi } from '@/services/editor-api'
@@ -65,6 +66,13 @@ export default function EditorPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Export state
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState(0)
const [exportStatus, setExportStatus] = useState<'idle' | 'pending' | 'processing' | 'completed' | 'failed'>('idle')
const [exportDownloadUrl, setExportDownloadUrl] = useState<string | null>(null)
const [exportError, setExportError] = useState<string | null>(null)
useEffect(() => {
async function loadStoryboard() {
// Get storyboard_id from URL, default to demo-1
@@ -159,10 +167,17 @@ export default function EditorPage() {
<Save className="h-4 w-4 mr-2" />
</Button>
<Button size="sm">
<Download className="h-4 w-4 mr-2" />
</Button>
<ExportButton
storyboardId={storyboard?.id}
isExporting={isExporting}
setIsExporting={setIsExporting}
exportProgress={exportProgress}
setExportProgress={setExportProgress}
exportStatus={exportStatus}
setExportStatus={setExportStatus}
setExportDownloadUrl={setExportDownloadUrl}
setExportError={setExportError}
/>
</div>
</header>
@@ -174,9 +189,17 @@ export default function EditorPage() {
</div>
{/* Right sidebar (optional, for frame details) */}
<div className="w-80 border-l bg-muted/30 p-4 hidden lg:block">
<div className="w-80 border-l bg-muted/30 p-4 hidden lg:block overflow-y-auto">
<h3 className="font-semibold mb-4"></h3>
<SelectedFrameDetails />
{/* Publish Panel */}
<div className="mt-6">
<PublishPanel
storyboardId={storyboard?.id || ''}
videoPath={exportDownloadUrl || undefined}
/>
</div>
</div>
</div>
@@ -407,3 +430,115 @@ function SelectedFrameDetails() {
)
}
// Export Button Component
interface ExportButtonProps {
storyboardId?: string
isExporting: boolean
setIsExporting: (v: boolean) => void
exportProgress: number
setExportProgress: (v: number) => void
exportStatus: 'idle' | 'pending' | 'processing' | 'completed' | 'failed'
setExportStatus: (v: 'idle' | 'pending' | 'processing' | 'completed' | 'failed') => void
setExportDownloadUrl: (v: string | null) => void
setExportError: (v: string | null) => void
}
function ExportButton({
storyboardId,
isExporting,
setIsExporting,
exportProgress,
setExportProgress,
exportStatus,
setExportStatus,
setExportDownloadUrl,
setExportError,
}: ExportButtonProps) {
const [downloadUrl, setDownloadUrl] = useState<string | null>(null)
const handleExport = async () => {
if (!storyboardId || isExporting) return
setIsExporting(true)
setExportStatus('pending')
setExportProgress(0)
setExportError(null)
setDownloadUrl(null)
try {
// Start export
const { task_id } = await editorApi.exportVideo(storyboardId)
// Poll for status
const pollInterval = setInterval(async () => {
try {
const status = await editorApi.getExportStatus(task_id)
setExportProgress(status.progress)
setExportStatus(status.status as any)
if (status.status === 'completed') {
clearInterval(pollInterval)
setIsExporting(false)
setDownloadUrl(status.download_url || null)
setExportDownloadUrl(status.download_url || null)
} else if (status.status === 'failed') {
clearInterval(pollInterval)
setIsExporting(false)
setExportError(status.error || '导出失败')
}
} catch (e) {
clearInterval(pollInterval)
setIsExporting(false)
setExportStatus('failed')
setExportError('获取导出状态失败')
}
}, 1000)
} catch (e: any) {
setIsExporting(false)
setExportStatus('failed')
setExportError(e.message || '导出失败')
}
}
// Render based on status
if (exportStatus === 'completed' && downloadUrl) {
return (
<div className="flex gap-2">
<Button size="sm" variant="outline" asChild>
<a href={downloadUrl} download>
<Download className="h-4 w-4 mr-2" />
</a>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setExportStatus('idle')
setDownloadUrl(null)
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)
}
if (isExporting) {
return (
<Button size="sm" disabled>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{Math.round(exportProgress * 100)}%
</Button>
)
}
return (
<Button size="sm" onClick={handleExport} disabled={!storyboardId}>
<Download className="h-4 w-4 mr-2" />
</Button>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, useCallback, useState } from 'react'
import { Play, Pause, SkipBack, SkipForward, Maximize, Paintbrush } from 'lucide-react'
import { Play, Pause, SkipBack, SkipForward, Maximize, Paintbrush, Volume2, VolumeX } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { useEditorStore } from '@/stores/editor-store'
@@ -31,10 +31,12 @@ export function PreviewPlayer() {
const timerRef = useRef<NodeJS.Timeout | null>(null)
const canvasContainerRef = useRef<HTMLDivElement>(null)
const inpaintingCanvasRef = useRef<InpaintingCanvasRef>(null)
const audioRef = useRef<HTMLAudioElement>(null)
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
const [isApplying, setIsApplying] = useState(false)
const [canUndo, setCanUndo] = useState(false)
const [canRedo, setCanRedo] = useState(false)
const [isMuted, setIsMuted] = useState(false)
// Get current frame based on currentTime
const getCurrentFrameIndex = useCallback(() => {
@@ -84,6 +86,32 @@ export function PreviewPlayer() {
}
}, [isPlaying, storyboard, setCurrentTime, setPlaying])
// Audio playback sync
useEffect(() => {
if (!audioRef.current) return
if (isPlaying && selectedFrame?.audioPath) {
audioRef.current.src = selectedFrame.audioPath
audioRef.current.currentTime = 0
audioRef.current.muted = isMuted
audioRef.current.play().catch(() => {
// Ignore autoplay errors
})
} else {
audioRef.current.pause()
}
}, [isPlaying, selectedFrame?.audioPath, isMuted])
// Update audio when frame changes
useEffect(() => {
if (audioRef.current && selectedFrame?.audioPath && isPlaying) {
audioRef.current.src = selectedFrame.audioPath
audioRef.current.currentTime = 0
audioRef.current.muted = isMuted
audioRef.current.play().catch(() => { })
}
}, [selectedFrameId])
const selectedFrame = storyboard?.frames.find((f) => f.id === selectedFrameId)
// Calculate canvas size when entering inpainting mode
@@ -298,6 +326,18 @@ export function PreviewPlayer() {
<SkipForward className="h-4 w-4" />
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="icon"
onClick={() => setIsMuted(!isMuted)}
title={isMuted ? "取消静音" : "静音"}
>
{isMuted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
@@ -311,6 +351,9 @@ export function PreviewPlayer() {
<Maximize className="h-4 w-4" />
</Button>
</div>
{/* Hidden audio element */}
<audio ref={audioRef} preload="auto" />
</div>
</div>
)

View File

@@ -0,0 +1 @@
export { PublishPanel } from './publish-panel'

View File

@@ -0,0 +1,202 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Upload, Loader2, ExternalLink, Check, AlertCircle } from 'lucide-react'
interface PublishPanelProps {
storyboardId: string
videoPath?: string
}
// API base URL
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'
interface VideoMetadata {
title: string
description: string
tags: string[]
}
export function PublishPanel({ storyboardId, videoPath }: PublishPanelProps) {
const [platform, setPlatform] = useState<'bilibili' | 'youtube' | 'export'>('export')
const [isPublishing, setIsPublishing] = useState(false)
const [publishStatus, setPublishStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [statusMessage, setStatusMessage] = useState('')
const [metadata, setMetadata] = useState<VideoMetadata>({
title: '',
description: '',
tags: [],
})
const [tagsInput, setTagsInput] = useState('')
const handlePublish = async () => {
if (!videoPath) {
setPublishStatus('error')
setStatusMessage('请先导出视频')
return
}
setIsPublishing(true)
setPublishStatus('idle')
setStatusMessage('')
try {
let endpoint = ''
switch (platform) {
case 'bilibili':
endpoint = `${API_BASE}/publish/bilibili`
break
case 'youtube':
endpoint = `${API_BASE}/publish/youtube`
break
case 'export':
endpoint = `${API_BASE}/publish/export`
break
}
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_path: videoPath,
metadata: {
title: metadata.title || '未命名视频',
description: metadata.description || '',
tags: tagsInput.split(',').map(t => t.trim()).filter(Boolean),
},
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || 'Failed to publish')
}
const result = await response.json()
setPublishStatus('success')
if (platform === 'export') {
setStatusMessage(`导出成功: ${result.export_path || result.download_url}`)
} else {
setStatusMessage(`发布成功!`)
}
} catch (e: any) {
setPublishStatus('error')
setStatusMessage(e.message || '发布失败')
} finally {
setIsPublishing(false)
}
}
return (
<div className="space-y-4 p-4 border rounded-lg bg-card">
<h3 className="font-semibold"></h3>
{/* Platform Selection */}
<div>
<label className="text-sm text-muted-foreground mb-2 block"></label>
<div className="flex gap-2">
<Button
variant={platform === 'export' ? 'default' : 'outline'}
size="sm"
onClick={() => setPlatform('export')}
>
</Button>
<Button
variant={platform === 'bilibili' ? 'default' : 'outline'}
size="sm"
onClick={() => setPlatform('bilibili')}
>
B站
</Button>
<Button
variant={platform === 'youtube' ? 'default' : 'outline'}
size="sm"
onClick={() => setPlatform('youtube')}
>
YouTube
</Button>
</div>
</div>
{/* Metadata Form (for Bilibili/YouTube) */}
{platform !== 'export' && (
<div className="space-y-3">
<div>
<label className="text-sm text-muted-foreground mb-1 block"></label>
<input
type="text"
value={metadata.title}
onChange={(e) => setMetadata({ ...metadata, title: e.target.value })}
className="w-full p-2 text-sm border rounded bg-background"
placeholder="视频标题"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1 block"></label>
<textarea
value={metadata.description}
onChange={(e) => setMetadata({ ...metadata, description: e.target.value })}
className="w-full p-2 text-sm border rounded bg-background resize-none"
rows={3}
placeholder="视频描述"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1 block"> ()</label>
<input
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
className="w-full p-2 text-sm border rounded bg-background"
placeholder="标签1, 标签2, 标签3"
/>
</div>
</div>
)}
{/* Status Message */}
{publishStatus !== 'idle' && (
<div className={`flex items-center gap-2 p-2 rounded text-sm ${publishStatus === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
}`}>
{publishStatus === 'success' ? (
<Check className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
{statusMessage}
</div>
)}
{/* Publish Button */}
<Button
className="w-full"
onClick={handlePublish}
disabled={isPublishing || !videoPath}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{platform === 'export' ? '导出中...' : '发布中...'}
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
{platform === 'export' ? '导出优化视频' : `发布到${platform === 'bilibili' ? 'B站' : 'YouTube'}`}
</>
)}
</Button>
{!videoPath && (
<p className="text-xs text-muted-foreground text-center">
</p>
)}
</div>
)
}

View File

@@ -227,8 +227,55 @@ class EditorApiClient {
return response.json()
}
/**
* Export edited video
*/
async exportVideo(
storyboardId: string,
bgmPath?: string,
bgmVolume?: number
): Promise<{ task_id: string; status: string }> {
const response = await fetch(
`${this.baseUrl}/editor/storyboard/${storyboardId}/export`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bgm_path: bgmPath, bgm_volume: bgmVolume }),
}
)
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }))
throw new Error(error.detail || `Failed to start export: ${response.statusText}`)
}
return response.json()
}
/**
* Get export task status
*/
async getExportStatus(taskId: string): Promise<{
task_id: string
status: string
progress: number
video_path?: string
download_url?: string
error?: string
}> {
const response = await fetch(`${this.baseUrl}/editor/export/${taskId}/status`)
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }))
throw new Error(error.detail || `Failed to get export status: ${response.statusText}`)
}
return response.json()
}
}
// Export singleton instance
export const editorApi = new EditorApiClient()