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

1
.pids/api.pid Normal file
View File

@@ -0,0 +1 @@
12303

1
.pids/editor.pid Normal file
View File

@@ -0,0 +1 @@
12312

1
.pids/web.pid Normal file
View File

@@ -0,0 +1 @@
12347

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

84
.serena/project.yml Normal file
View File

@@ -0,0 +1,84 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran go
# haskell java julia kotlin lua markdown
# nix perl php python python_jedi r
# rego ruby ruby_solargraph rust scala swift
# terraform typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- python
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "Pixelle-Video"
included_optional_tools: []

View File

@@ -38,10 +38,19 @@ from api.schemas.editor import (
RegenerateAudioResponse,
InpaintRequest,
InpaintResponse,
ExportRequest,
ExportResponse,
ExportStatusResponse,
)
from fastapi import BackgroundTasks
import asyncio
import uuid as uuid_module
router = APIRouter(prefix="/editor", tags=["Editor"])
# Export task storage
_export_tasks: dict = {}
def _path_to_url(file_path: str, base_url: str = "http://localhost:8000") -> str:
"""Convert local file path to URL accessible through API"""
@@ -683,3 +692,138 @@ async def inpaint_frame_image(
except Exception as e:
logger.error(f"Inpainting failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Video Export Endpoints
# ============================================================
@router.post(
"/storyboard/{storyboard_id}/export",
response_model=ExportResponse
)
async def export_video(
storyboard_id: str = Path(..., description="Storyboard/task ID"),
request: ExportRequest = None,
background_tasks: BackgroundTasks = None
):
"""
Export edited video
Concatenates all video segments in order and optionally adds BGM.
Returns immediately with task_id for tracking progress.
"""
if storyboard_id not in _storyboard_cache:
raise HTTPException(status_code=404, detail=f"Storyboard {storyboard_id} not found")
storyboard = _storyboard_cache[storyboard_id]
frames = storyboard["frames"]
# Check if all frames have video segments
missing_segments = [f["id"] for f in frames if not f.get("video_segment_path")]
if missing_segments:
raise HTTPException(
status_code=400,
detail=f"Missing video segments for frames: {missing_segments}"
)
# Create export task
task_id = str(uuid_module.uuid4())
_export_tasks[task_id] = {
"status": "pending",
"progress": 0.0,
"video_path": None,
"error": None
}
# Run export in background
async def run_export():
try:
_export_tasks[task_id]["status"] = "processing"
_export_tasks[task_id]["progress"] = 0.1
from pixelle_video.services.video import VideoService
import os
video_service = VideoService()
# Get video segment paths (sorted by order)
sorted_frames = sorted(frames, key=lambda f: f.get("order", 0))
# Convert URLs back to file paths
video_segments = []
for frame in sorted_frames:
path = frame.get("video_segment_path", "")
if path.startswith("http"):
# Extract path from URL
path = path.replace("http://localhost:8000/api/files/", "output/")
video_segments.append(path)
_export_tasks[task_id]["progress"] = 0.3
# Create output directory
output_dir = f"output/{storyboard_id}"
os.makedirs(output_dir, exist_ok=True)
# Output path
output_path = f"{output_dir}/exported_{task_id[:8]}.mp4"
# BGM settings
bgm_path = request.bgm_path if request else None
bgm_volume = request.bgm_volume if request else 0.2
_export_tasks[task_id]["progress"] = 0.5
# Concatenate videos
video_service.concat_videos(
videos=video_segments,
output=output_path,
method="demuxer",
bgm_path=bgm_path,
bgm_volume=bgm_volume
)
_export_tasks[task_id]["progress"] = 1.0
_export_tasks[task_id]["status"] = "completed"
_export_tasks[task_id]["video_path"] = output_path
logger.info(f"Exported video for storyboard {storyboard_id}: {output_path}")
except Exception as e:
logger.error(f"Export failed: {e}")
_export_tasks[task_id]["status"] = "failed"
_export_tasks[task_id]["error"] = str(e)
background_tasks.add_task(run_export)
return ExportResponse(
task_id=task_id,
status="pending"
)
@router.get(
"/export/{task_id}/status",
response_model=ExportStatusResponse
)
async def get_export_status(task_id: str = Path(..., description="Export task ID")):
"""
Get export task status and progress
"""
if task_id not in _export_tasks:
raise HTTPException(status_code=404, detail=f"Export task {task_id} not found")
task = _export_tasks[task_id]
download_url = None
if task["video_path"]:
download_url = _path_to_url(task["video_path"])
return ExportStatusResponse(
task_id=task_id,
status=task["status"],
progress=task["progress"],
video_path=task["video_path"],
download_url=download_url,
error=task["error"]
)

View File

@@ -121,3 +121,26 @@ class InpaintResponse(BaseModel):
image_path: str
success: bool = True
class ExportRequest(BaseModel):
"""Request to export edited video"""
bgm_path: Optional[str] = Field(None, description="Background music path")
bgm_volume: float = Field(0.2, ge=0.0, le=1.0, description="BGM volume (0.0-1.0)")
class ExportResponse(BaseModel):
"""Response for export request"""
task_id: str
status: str = "pending"
class ExportStatusResponse(BaseModel):
"""Response for export status check"""
task_id: str
status: str # pending, processing, completed, failed
progress: float = 0.0 # 0.0 to 1.0
video_path: Optional[str] = None
download_url: Optional[str] = None
error: Optional[str] = None

160
dev.sh Executable file
View File

@@ -0,0 +1,160 @@
#!/bin/bash
# =============================================================================
# Pixelle-Video Development Environment Startup Script
# =============================================================================
#
# This script starts all services for development:
# - FastAPI Backend (port 8000)
# - Next.js Editor (port 3000)
# - Streamlit Web UI (port 8501)
#
# Usage:
# ./dev.sh # Start all services
# ./dev.sh api # Start only API
# ./dev.sh editor # Start only Editor
# ./dev.sh web # Start only Streamlit
# ./dev.sh stop # Stop all background services
#
# Environment Variables:
# API_PORT=8000 # Override API port
# EDITOR_PORT=3000 # Override Editor port
# WEB_PORT=8501 # Override Streamlit port
#
# =============================================================================
set -e
# Default ports (can be overridden via environment variables)
API_PORT=${API_PORT:-8000}
EDITOR_PORT=${EDITOR_PORT:-3000}
WEB_PORT=${WEB_PORT:-8501}
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Project root directory
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_ROOT"
# PID files for tracking background processes
PID_DIR="$PROJECT_ROOT/.pids"
mkdir -p "$PID_DIR"
print_banner() {
echo ""
echo -e "${BLUE}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Pixelle-Video Development Environment ║${NC}"
echo -e "${BLUE}╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
}
start_api() {
echo -e "${GREEN}🚀 Starting FastAPI Backend...${NC}"
uv run python api/app.py --port $API_PORT --reload &
echo $! > "$PID_DIR/api.pid"
echo -e " ${GREEN}${NC} API running at: ${YELLOW}http://localhost:$API_PORT${NC}"
echo -e " ${GREEN}${NC} API Docs at: ${YELLOW}http://localhost:$API_PORT/docs${NC}"
}
start_editor() {
echo -e "${GREEN}🎬 Starting Next.js Editor...${NC}"
cd "$PROJECT_ROOT/frontend"
PORT=$EDITOR_PORT npm run dev &
echo $! > "$PID_DIR/editor.pid"
cd "$PROJECT_ROOT"
echo -e " ${GREEN}${NC} Editor running at: ${YELLOW}http://localhost:$EDITOR_PORT${NC}"
}
start_web() {
echo -e "${GREEN}🌐 Starting Streamlit Web UI...${NC}"
uv run streamlit run web/app.py --server.port $WEB_PORT &
echo $! > "$PID_DIR/web.pid"
echo -e " ${GREEN}${NC} Web UI running at: ${YELLOW}http://localhost:$WEB_PORT${NC}"
}
stop_all() {
echo -e "${YELLOW}Stopping all services...${NC}"
for pid_file in "$PID_DIR"/*.pid; do
if [ -f "$pid_file" ]; then
pid=$(cat "$pid_file")
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
echo -e " ${GREEN}${NC} Stopped process $pid"
fi
rm -f "$pid_file"
fi
done
# Also kill any remaining processes on our ports
lsof -ti:$API_PORT | xargs kill -9 2>/dev/null || true
lsof -ti:$EDITOR_PORT | xargs kill -9 2>/dev/null || true
lsof -ti:$WEB_PORT | xargs kill -9 2>/dev/null || true
echo -e "${GREEN}All services stopped.${NC}"
}
show_status() {
echo ""
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN}All services started successfully!${NC}"
echo ""
echo -e " ${YELLOW}FastAPI Backend${NC}: http://localhost:$API_PORT"
echo -e " ${YELLOW}API Documentation${NC}: http://localhost:$API_PORT/docs"
echo -e " ${YELLOW}Next.js Editor${NC}: http://localhost:$EDITOR_PORT"
echo -e " ${YELLOW}Streamlit Web UI${NC}: http://localhost:$WEB_PORT"
echo ""
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "Press ${RED}Ctrl+C${NC} to stop all services"
echo ""
}
# Main logic
case "${1:-all}" in
api)
print_banner
start_api
wait
;;
editor)
print_banner
start_editor
wait
;;
web)
print_banner
start_web
wait
;;
stop)
stop_all
;;
all|*)
print_banner
# Check if frontend dependencies are installed
if [ ! -d "$PROJECT_ROOT/frontend/node_modules" ]; then
echo -e "${YELLOW}📦 Installing frontend dependencies...${NC}"
cd "$PROJECT_ROOT/frontend"
npm install
cd "$PROJECT_ROOT"
fi
start_api
sleep 2
start_editor
sleep 2
start_web
show_status
# Wait for Ctrl+C
trap stop_all EXIT
wait
;;
esac

5
frontend/env.example Normal file
View File

@@ -0,0 +1,5 @@
# Pixelle-Video Editor Frontend Environment Variables
# Copy to frontend/.env.local and modify as needed
# Backend API URL
NEXT_PUBLIC_API_URL=http://localhost:8000/api

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()

3777
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -309,7 +309,7 @@ def render_single_output(pixelle_video, video_params):
pass
if task_id:
editor_url = f"http://localhost:3001/editor?storyboard_id={task_id}"
editor_url = f"http://localhost:3000/editor?storyboard_id={task_id}"
st.markdown(
f'''
<a href="{editor_url}" target="_blank" style="text-decoration: none;">

View File

@@ -363,7 +363,7 @@ def render_task_detail_modal(task_id: str, pixelle_video):
)
# Open in Editor button
editor_url = f"http://localhost:3001/editor?storyboard_id={task_id}"
editor_url = f"http://localhost:3000/editor?storyboard_id={task_id}"
st.markdown(
f'''
<a href="{editor_url}" target="_blank" style="text-decoration: none;">

View File

@@ -183,7 +183,7 @@ def render_task_card(task):
with col_a:
st.success("✨ 视频生成成功")
with col_b:
editor_url = f"http://localhost:3001/editor?storyboard_id={task_id}"
editor_url = f"http://localhost:3000/editor?storyboard_id={task_id}"
st.markdown(
f'''
<a href="{editor_url}" target="_blank" style="text-decoration: none;">