feat: Add editor enhancements - export video, audio preview, publish panel, configurable ports
This commit is contained in:
1
.pids/api.pid
Normal file
1
.pids/api.pid
Normal file
@@ -0,0 +1 @@
|
||||
12303
|
||||
1
.pids/editor.pid
Normal file
1
.pids/editor.pid
Normal file
@@ -0,0 +1 @@
|
||||
12312
|
||||
1
.pids/web.pid
Normal file
1
.pids/web.pid
Normal file
@@ -0,0 +1 @@
|
||||
12347
|
||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
84
.serena/project.yml
Normal file
84
.serena/project.yml
Normal 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: []
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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
160
dev.sh
Executable 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
5
frontend/env.example
Normal 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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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;">
|
||||
|
||||
Reference in New Issue
Block a user