From 96eacf06ba6751469715ce6cb8b83bcf454fb198 Mon Sep 17 00:00:00 2001 From: empty Date: Tue, 6 Jan 2026 17:29:43 +0800 Subject: [PATCH] feat: Add editor enhancements - export video, audio preview, publish panel, configurable ports --- .pids/api.pid | 1 + .pids/editor.pid | 1 + .pids/web.pid | 1 + .serena/.gitignore | 1 + .serena/project.yml | 84 + api/routers/editor.py | 144 + api/schemas/editor.py | 23 + dev.sh | 160 + frontend/env.example | 5 + frontend/src/app/editor/page.tsx | 149 +- .../src/components/preview/preview-player.tsx | 45 +- frontend/src/components/publish/index.ts | 1 + .../src/components/publish/publish-panel.tsx | 202 + frontend/src/services/editor-api.ts | 47 + uv.lock | 3777 +++++++++-------- web/components/output_preview.py | 2 +- web/pages/2_๐Ÿ“š_History.py | 2 +- web/pages/3_๐Ÿ“‹_Tasks.py | 2 +- 18 files changed, 2946 insertions(+), 1701 deletions(-) create mode 100644 .pids/api.pid create mode 100644 .pids/editor.pid create mode 100644 .pids/web.pid create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100755 dev.sh create mode 100644 frontend/env.example create mode 100644 frontend/src/components/publish/index.ts create mode 100644 frontend/src/components/publish/publish-panel.tsx diff --git a/.pids/api.pid b/.pids/api.pid new file mode 100644 index 0000000..f57c919 --- /dev/null +++ b/.pids/api.pid @@ -0,0 +1 @@ +12303 diff --git a/.pids/editor.pid b/.pids/editor.pid new file mode 100644 index 0000000..83f4927 --- /dev/null +++ b/.pids/editor.pid @@ -0,0 +1 @@ +12312 diff --git a/.pids/web.pid b/.pids/web.pid new file mode 100644 index 0000000..5b4677a --- /dev/null +++ b/.pids/web.pid @@ -0,0 +1 @@ +12347 diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..e9c0ab2 --- /dev/null +++ b/.serena/project.yml @@ -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: [] diff --git a/api/routers/editor.py b/api/routers/editor.py index 8de29c8..93841e4 100644 --- a/api/routers/editor.py +++ b/api/routers/editor.py @@ -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"] + ) diff --git a/api/schemas/editor.py b/api/schemas/editor.py index c5bfae8..c6ff412 100644 --- a/api/schemas/editor.py +++ b/api/schemas/editor.py @@ -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 + + diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..55c83b3 --- /dev/null +++ b/dev.sh @@ -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 diff --git a/frontend/env.example b/frontend/env.example new file mode 100644 index 0000000..397f639 --- /dev/null +++ b/frontend/env.example @@ -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 diff --git a/frontend/src/app/editor/page.tsx b/frontend/src/app/editor/page.tsx index 83b0afc..8e33e30 100644 --- a/frontend/src/app/editor/page.tsx +++ b/frontend/src/app/editor/page.tsx @@ -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(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(null) + const [exportError, setExportError] = useState(null) + useEffect(() => { async function loadStoryboard() { // Get storyboard_id from URL, default to demo-1 @@ -159,10 +167,17 @@ export default function EditorPage() { ไฟๅญ˜ - + @@ -174,9 +189,17 @@ export default function EditorPage() { {/* Right sidebar (optional, for frame details) */} -
+

ๅˆ†้•œ่ฏฆๆƒ…

+ + {/* Publish Panel */} +
+ +
@@ -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(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 ( +
+ + +
+ ) + } + + if (isExporting) { + return ( + + ) + } + + return ( + + ) +} diff --git a/frontend/src/components/preview/preview-player.tsx b/frontend/src/components/preview/preview-player.tsx index e1bb322..40abfc7 100644 --- a/frontend/src/components/preview/preview-player.tsx +++ b/frontend/src/components/preview/preview-player.tsx @@ -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(null) const canvasContainerRef = useRef(null) const inpaintingCanvasRef = useRef(null) + const audioRef = useRef(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() {
+
+ + {/* Hidden audio element */} +