feat: Add editor enhancements - export video, audio preview, publish panel, configurable ports
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
1
frontend/src/components/publish/index.ts
Normal file
1
frontend/src/components/publish/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PublishPanel } from './publish-panel'
|
||||
202
frontend/src/components/publish/publish-panel.tsx
Normal file
202
frontend/src/components/publish/publish-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user