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,
|
RegenerateAudioResponse,
|
||||||
InpaintRequest,
|
InpaintRequest,
|
||||||
InpaintResponse,
|
InpaintResponse,
|
||||||
|
ExportRequest,
|
||||||
|
ExportResponse,
|
||||||
|
ExportStatusResponse,
|
||||||
)
|
)
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
import asyncio
|
||||||
|
import uuid as uuid_module
|
||||||
|
|
||||||
router = APIRouter(prefix="/editor", tags=["Editor"])
|
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:
|
def _path_to_url(file_path: str, base_url: str = "http://localhost:8000") -> str:
|
||||||
"""Convert local file path to URL accessible through API"""
|
"""Convert local file path to URL accessible through API"""
|
||||||
@@ -683,3 +692,138 @@ async def inpaint_frame_image(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Inpainting failed: {e}")
|
logger.error(f"Inpainting failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(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
|
image_path: str
|
||||||
success: bool = True
|
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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { Timeline } from '@/components/timeline'
|
||||||
import { PreviewPlayer } from '@/components/preview'
|
import { PreviewPlayer } from '@/components/preview'
|
||||||
|
import { PublishPanel } from '@/components/publish'
|
||||||
import { useEditorStore, Storyboard } from '@/stores/editor-store'
|
import { useEditorStore, Storyboard } from '@/stores/editor-store'
|
||||||
import { editorApi } from '@/services/editor-api'
|
import { editorApi } from '@/services/editor-api'
|
||||||
|
|
||||||
@@ -65,6 +66,13 @@ export default function EditorPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
async function loadStoryboard() {
|
async function loadStoryboard() {
|
||||||
// Get storyboard_id from URL, default to demo-1
|
// 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" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm">
|
<ExportButton
|
||||||
<Download className="h-4 w-4 mr-2" />
|
storyboardId={storyboard?.id}
|
||||||
导出视频
|
isExporting={isExporting}
|
||||||
</Button>
|
setIsExporting={setIsExporting}
|
||||||
|
exportProgress={exportProgress}
|
||||||
|
setExportProgress={setExportProgress}
|
||||||
|
exportStatus={exportStatus}
|
||||||
|
setExportStatus={setExportStatus}
|
||||||
|
setExportDownloadUrl={setExportDownloadUrl}
|
||||||
|
setExportError={setExportError}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -174,9 +189,17 @@ export default function EditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right sidebar (optional, for frame details) */}
|
{/* 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>
|
<h3 className="font-semibold mb-4">分镜详情</h3>
|
||||||
<SelectedFrameDetails />
|
<SelectedFrameDetails />
|
||||||
|
|
||||||
|
{/* Publish Panel */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<PublishPanel
|
||||||
|
storyboardId={storyboard?.id || ''}
|
||||||
|
videoPath={exportDownloadUrl || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { useEditorStore } from '@/stores/editor-store'
|
import { useEditorStore } from '@/stores/editor-store'
|
||||||
@@ -31,10 +31,12 @@ export function PreviewPlayer() {
|
|||||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const inpaintingCanvasRef = useRef<InpaintingCanvasRef>(null)
|
const inpaintingCanvasRef = useRef<InpaintingCanvasRef>(null)
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
|
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
|
||||||
const [isApplying, setIsApplying] = useState(false)
|
const [isApplying, setIsApplying] = useState(false)
|
||||||
const [canUndo, setCanUndo] = useState(false)
|
const [canUndo, setCanUndo] = useState(false)
|
||||||
const [canRedo, setCanRedo] = useState(false)
|
const [canRedo, setCanRedo] = useState(false)
|
||||||
|
const [isMuted, setIsMuted] = useState(false)
|
||||||
|
|
||||||
// Get current frame based on currentTime
|
// Get current frame based on currentTime
|
||||||
const getCurrentFrameIndex = useCallback(() => {
|
const getCurrentFrameIndex = useCallback(() => {
|
||||||
@@ -84,6 +86,32 @@ export function PreviewPlayer() {
|
|||||||
}
|
}
|
||||||
}, [isPlaying, storyboard, setCurrentTime, setPlaying])
|
}, [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)
|
const selectedFrame = storyboard?.frames.find((f) => f.id === selectedFrameId)
|
||||||
|
|
||||||
// Calculate canvas size when entering inpainting mode
|
// Calculate canvas size when entering inpainting mode
|
||||||
@@ -298,6 +326,18 @@ export function PreviewPlayer() {
|
|||||||
<SkipForward className="h-4 w-4" />
|
<SkipForward className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -311,6 +351,9 @@ export function PreviewPlayer() {
|
|||||||
<Maximize className="h-4 w-4" />
|
<Maximize className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden audio element */}
|
||||||
|
<audio ref={audioRef} preload="auto" />
|
||||||
</div>
|
</div>
|
||||||
</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()
|
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 singleton instance
|
||||||
export const editorApi = new EditorApiClient()
|
export const editorApi = new EditorApiClient()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ def render_single_output(pixelle_video, video_params):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if task_id:
|
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(
|
st.markdown(
|
||||||
f'''
|
f'''
|
||||||
<a href="{editor_url}" target="_blank" style="text-decoration: none;">
|
<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
|
# 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(
|
st.markdown(
|
||||||
f'''
|
f'''
|
||||||
<a href="{editor_url}" target="_blank" style="text-decoration: none;">
|
<a href="{editor_url}" target="_blank" style="text-decoration: none;">
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ def render_task_card(task):
|
|||||||
with col_a:
|
with col_a:
|
||||||
st.success("✨ 视频生成成功")
|
st.success("✨ 视频生成成功")
|
||||||
with col_b:
|
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(
|
st.markdown(
|
||||||
f'''
|
f'''
|
||||||
<a href="{editor_url}" target="_blank" style="text-decoration: none;">
|
<a href="{editor_url}" target="_blank" style="text-decoration: none;">
|
||||||
|
|||||||
Reference in New Issue
Block a user