# Copyright (C) 2025 AIDC-AI # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # http://www.apache.org/licenses/LICENSE-2.0 # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Editor API router for timeline editor operations Provides endpoints for: - Fetching storyboard data - Reordering frames - Updating frame duration - Generating preview """ from fastapi import APIRouter, HTTPException, Path from loguru import logger from api.schemas.editor import ( StoryboardSchema, StoryboardFrameSchema, ReorderFramesRequest, UpdateDurationRequest, PreviewRequest, PreviewResponse, UpdateFrameRequest, UpdateFrameResponse, RegenerateImageRequest, RegenerateImageResponse, RegenerateAudioRequest, RegenerateAudioResponse, InpaintRequest, InpaintResponse, ExportRequest, ExportResponse, ExportStatusResponse, AlignPromptRequest, AlignPromptResponse, ) 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""" if not file_path: return None import os from pathlib import Path # Normalize path separators file_path = file_path.replace("\\", "/") # Extract relative path from output directory parts = file_path.split("/") try: output_idx = parts.index("output") relative_parts = parts[output_idx + 1:] relative_path = "/".join(relative_parts) except ValueError: relative_path = Path(file_path).name return f"{base_url}/api/files/{relative_path}" # In-memory cache for demo (in production, use database) _storyboard_cache: dict = {} # Demo data for testing _demo_storyboard = { "id": "demo-1", "title": "演示视频", "total_duration": 15.5, "final_video_path": None, "created_at": None, "frames": [ {"id": "frame-0", "index": 0, "order": 0, "narration": "在一个宁静的早晨,阳光洒满了整个城市", "image_prompt": "A peaceful morning", "duration": 3.2}, {"id": "frame-1", "index": 1, "order": 1, "narration": "小明决定出门去探索这个美丽的世界", "image_prompt": "A young man stepping out", "duration": 2.8}, {"id": "frame-2", "index": 2, "order": 2, "narration": "他走过熟悉的街道,感受着微风的吹拂", "image_prompt": "Walking through streets", "duration": 3.5}, {"id": "frame-3", "index": 3, "order": 3, "narration": "公园里的花朵正在盛开,散发着迷人的芬芳", "image_prompt": "Blooming flowers", "duration": 3.0}, {"id": "frame-4", "index": 4, "order": 4, "narration": "这是新的一天的开始,充满了无限可能", "image_prompt": "New day begins", "duration": 3.0}, ], } # Import task manager from api.tasks.manager import task_manager @router.get("/storyboard/{storyboard_id}", response_model=StoryboardSchema) async def get_storyboard(storyboard_id: str = Path(..., description="Storyboard/task ID")): """ Get storyboard by ID Supports: - 'demo-1': Returns demo data for testing - Any task_id: Loads real storyboard from completed video generation tasks - History tasks: Loads from persistence service """ # Return demo data for demo-1 if storyboard_id == "demo-1": if "demo-1" not in _storyboard_cache: _storyboard_cache["demo-1"] = _demo_storyboard.copy() return _storyboard_cache["demo-1"] # Try to get from cache first if storyboard_id in _storyboard_cache: return _storyboard_cache[storyboard_id] # Try to load from task manager (in-memory task) task = task_manager.get_task(storyboard_id) if task and task.result: # Extract storyboard from task result result = task.result # Handle different result formats storyboard_data = None if hasattr(result, 'storyboard'): storyboard_data = result.storyboard elif isinstance(result, dict) and 'storyboard' in result: storyboard_data = result['storyboard'] if storyboard_data: # Convert to editor schema format schema = _convert_storyboard_to_schema(storyboard_id, storyboard_data) _storyboard_cache[storyboard_id] = schema logger.info(f"Loaded storyboard from task {storyboard_id}") return schema # Try to load from persistence service (history) try: from pixelle_video.services.persistence import PersistenceService persistence = PersistenceService(output_dir="output") # Load storyboard from disk (await since we're in an async function) storyboard = await persistence.load_storyboard(storyboard_id) if storyboard: schema = _convert_storyboard_to_schema(storyboard_id, storyboard) _storyboard_cache[storyboard_id] = schema logger.info(f"Loaded storyboard from persistence {storyboard_id}") return schema except Exception as e: logger.warning(f"Failed to load from persistence: {e}") raise HTTPException(status_code=404, detail=f"Storyboard {storyboard_id} not found") def _convert_storyboard_to_schema(storyboard_id: str, storyboard) -> dict: """Convert internal Storyboard model to API schema format.""" frames = [] # Handle both object and dict formats if hasattr(storyboard, 'frames'): frame_list = storyboard.frames title = getattr(storyboard, 'title', storyboard_id) total_duration = getattr(storyboard, 'total_duration', 0) final_video_path = getattr(storyboard, 'final_video_path', None) created_at = getattr(storyboard, 'created_at', None) elif isinstance(storyboard, dict): frame_list = storyboard.get('frames', []) title = storyboard.get('title', storyboard_id) total_duration = storyboard.get('total_duration', 0) final_video_path = storyboard.get('final_video_path') created_at = storyboard.get('created_at') else: frame_list = [] title = storyboard_id total_duration = 0 final_video_path = None created_at = None for i, frame in enumerate(frame_list): if hasattr(frame, 'narration'): # Object format frames.append({ "id": f"frame-{i}", "index": getattr(frame, 'index', i), "order": i, "narration": frame.narration or "", "image_prompt": getattr(frame, 'image_prompt', ""), "image_path": _path_to_url(getattr(frame, 'image_path', None)), "audio_path": _path_to_url(getattr(frame, 'audio_path', None)), "video_segment_path": _path_to_url(getattr(frame, 'video_segment_path', None)), "duration": getattr(frame, 'duration', 3.0), }) elif isinstance(frame, dict): # Dict format frames.append({ "id": f"frame-{i}", "index": frame.get('index', i), "order": i, "narration": frame.get('narration', ""), "image_prompt": frame.get('image_prompt', ""), "image_path": _path_to_url(frame.get('image_path')), "audio_path": _path_to_url(frame.get('audio_path')), "video_segment_path": _path_to_url(frame.get('video_segment_path')), "duration": frame.get('duration', 3.0), }) return { "id": storyboard_id, "title": title, "frames": frames, "total_duration": total_duration or sum(f.get('duration', 3.0) for f in frames), "final_video_path": final_video_path, "created_at": created_at.isoformat() if created_at else None, } @router.patch("/storyboard/{storyboard_id}/reorder", response_model=StoryboardSchema) async def reorder_frames( storyboard_id: str = Path(..., description="Storyboard/task ID"), request: ReorderFramesRequest = None ): """ Reorder frames in storyboard Updates the order of frames based on the provided frame ID list. """ if storyboard_id not in _storyboard_cache: raise HTTPException(status_code=404, detail=f"Storyboard {storyboard_id} not found in cache") storyboard = _storyboard_cache[storyboard_id] frames = storyboard["frames"] # Create ID to frame mapping frame_map = {f["id"]: f for f in frames} # Validate all IDs exist for frame_id in request.order: if frame_id not in frame_map: raise HTTPException(status_code=400, detail=f"Frame {frame_id} not found") # Reorder frames reordered = [] for idx, frame_id in enumerate(request.order): frame = frame_map[frame_id].copy() frame["order"] = idx reordered.append(frame) storyboard["frames"] = reordered _storyboard_cache[storyboard_id] = storyboard logger.info(f"Reordered {len(reordered)} frames in storyboard {storyboard_id}") return storyboard @router.patch( "/storyboard/{storyboard_id}/frames/{frame_id}/duration", response_model=StoryboardFrameSchema ) async def update_frame_duration( storyboard_id: str = Path(..., description="Storyboard/task ID"), frame_id: str = Path(..., description="Frame ID"), request: UpdateDurationRequest = None ): """ Update frame duration Changes the duration of a specific frame and recalculates total duration. """ if storyboard_id not in _storyboard_cache: raise HTTPException(status_code=404, detail=f"Storyboard {storyboard_id} not found in cache") storyboard = _storyboard_cache[storyboard_id] frames = storyboard["frames"] # Find and update frame updated_frame = None for frame in frames: if frame["id"] == frame_id: frame["duration"] = request.duration updated_frame = frame break if not updated_frame: raise HTTPException(status_code=404, detail=f"Frame {frame_id} not found") # Recalculate total duration storyboard["total_duration"] = sum(f["duration"] for f in frames) _storyboard_cache[storyboard_id] = storyboard logger.info(f"Updated frame {frame_id} duration to {request.duration}s") return updated_frame @router.post("/storyboard/{storyboard_id}/preview", response_model=PreviewResponse) async def generate_preview( storyboard_id: str = Path(..., description="Storyboard/task ID"), request: PreviewRequest = None ): """ Generate preview video for selected frames Creates a preview video from the specified frame range. """ if storyboard_id not in _storyboard_cache: raise HTTPException(status_code=404, detail=f"Storyboard {storyboard_id} not found in cache") storyboard = _storyboard_cache[storyboard_id] frames = storyboard["frames"] # Determine frame range start = request.start_frame if request else 0 end = request.end_frame if request and request.end_frame else len(frames) if start >= len(frames): raise HTTPException(status_code=400, detail="Start frame out of range") preview_frames = frames[start:end] total_duration = sum(f["duration"] for f in preview_frames) # TODO: Implement actual preview generation logic # For now, return mock response preview_path = f"/output/{storyboard_id}/preview_{start}_{end}.mp4" logger.info(f"Generated preview for frames {start}-{end} ({len(preview_frames)} frames)") return PreviewResponse( preview_path=preview_path, duration=total_duration, frames_count=len(preview_frames) ) def _storyboard_to_schema(storyboard_id: str, storyboard) -> dict: """Convert internal Storyboard to API schema format""" frames = [] for i, frame in enumerate(storyboard.frames): frames.append({ "id": f"frame-{i}", "index": frame.index, "order": i, "narration": frame.narration, "image_prompt": frame.image_prompt, "image_path": frame.image_path, "audio_path": frame.audio_path, "video_segment_path": frame.video_segment_path, "duration": frame.duration, }) return { "id": storyboard_id, "title": storyboard.title, "frames": frames, "total_duration": storyboard.total_duration, "final_video_path": storyboard.final_video_path, "created_at": storyboard.created_at, } @router.put( "/storyboard/{storyboard_id}/frames/{frame_id}", response_model=UpdateFrameResponse ) async def update_frame( storyboard_id: str = Path(..., description="Storyboard/task ID"), frame_id: str = Path(..., description="Frame ID"), request: UpdateFrameRequest = None ): """ Update frame content (narration and/or image prompt) Updates the text content of a frame without regenerating media. """ if storyboard_id not in _storyboard_cache: raise HTTPException(status_code=404, detail=f"Storyboard {storyboard_id} not found in cache") storyboard = _storyboard_cache[storyboard_id] frames = storyboard["frames"] # Find and update frame updated_frame = None for frame in frames: if frame["id"] == frame_id: if request.narration is not None: frame["narration"] = request.narration if request.image_prompt is not None: frame["image_prompt"] = request.image_prompt updated_frame = frame break if not updated_frame: raise HTTPException(status_code=404, detail=f"Frame {frame_id} not found") _storyboard_cache[storyboard_id] = storyboard logger.info(f"Updated frame {frame_id} content") return UpdateFrameResponse( id=frame_id, narration=updated_frame["narration"], image_prompt=updated_frame.get("image_prompt"), updated=True ) @router.post( "/storyboard/{storyboard_id}/frames/{frame_id}/regenerate-image", response_model=RegenerateImageResponse ) async def regenerate_frame_image( storyboard_id: str = Path(..., description="Storyboard/task ID"), frame_id: str = Path(..., description="Frame ID"), request: RegenerateImageRequest = None ): """ Regenerate image for a frame Uses the frame's image_prompt (or override) to generate a new image. Requires ComfyUI service to be running. """ 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"] # Find frame target_frame = None frame_index = 0 for i, frame in enumerate(frames): if frame["id"] == frame_id: target_frame = frame frame_index = i break if not target_frame: raise HTTPException(status_code=404, detail=f"Frame {frame_id} not found") # Get prompt to use prompt = request.image_prompt if request and request.image_prompt else target_frame.get("image_prompt", "") if not prompt: raise HTTPException(status_code=400, detail="No image prompt available") try: # Import and use PixelleVideo core services from api.dependencies import get_pixelle_video from api.routers.quality import _style_anchors pixelle_video = await get_pixelle_video() # Get style anchor prefix if available style_prefix = "" if storyboard_id in _style_anchors: style_data = _style_anchors[storyboard_id] style_prefix = style_data.get("style_prefix", "") if style_prefix: logger.info(f"Applying style anchor prefix: {style_prefix[:50]}...") # Apply style prefix to prompt final_prompt = f"{style_prefix}, {prompt}" if style_prefix else prompt # Use MediaService to generate image via RunningHub workflow # Use image_flux workflow by default, or get from config result = await pixelle_video.image( prompt=final_prompt, media_type="image", workflow="runninghub/image_flux.json", ) if result and result.url: # Download and save image import aiohttp import os output_dir = f"output/{storyboard_id}" os.makedirs(output_dir, exist_ok=True) image_path = f"{output_dir}/frame_{frame_index}_regenerated.png" # Check if URL is remote or local if result.url.startswith("http"): async with aiohttp.ClientSession() as session: async with session.get(result.url) as resp: if resp.status == 200: with open(image_path, 'wb') as f: f.write(await resp.read()) else: # Local file, copy it import shutil if os.path.exists(result.url): shutil.copy2(result.url, image_path) else: image_path = result.url # Update frame target_frame["image_path"] = _path_to_url(image_path) _storyboard_cache[storyboard_id] = storyboard logger.info(f"Regenerated image for frame {frame_id} via RunningHub") return RegenerateImageResponse( image_path=target_frame["image_path"], success=True ) else: raise HTTPException(status_code=500, detail="Image generation failed") except ImportError as e: logger.error(f"Failed to import dependencies: {e}") raise HTTPException(status_code=500, detail="Image generation service not available") except Exception as e: logger.error(f"Image regeneration failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post( "/storyboard/{storyboard_id}/frames/{frame_id}/regenerate-audio", response_model=RegenerateAudioResponse ) async def regenerate_frame_audio( storyboard_id: str = Path(..., description="Storyboard/task ID"), frame_id: str = Path(..., description="Frame ID"), request: RegenerateAudioRequest = None ): """ Regenerate audio for a frame Uses the frame's narration (or override) to generate new audio via TTS. """ 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"] # Find frame target_frame = None frame_index = 0 for i, frame in enumerate(frames): if frame["id"] == frame_id: target_frame = frame frame_index = i break if not target_frame: raise HTTPException(status_code=404, detail=f"Frame {frame_id} not found") # Get narration to use narration = request.narration if request and request.narration else target_frame.get("narration", "") if not narration: raise HTTPException(status_code=400, detail="No narration text available") try: from api.dependencies import get_pixelle_video import os pixelle_video = await get_pixelle_video() # Create output path output_dir = f"output/{storyboard_id}" os.makedirs(output_dir, exist_ok=True) audio_path = f"{output_dir}/frame_{frame_index}_audio_regenerated.mp3" # Generate audio using TTS service voice = request.voice if request and request.voice else None result_path = await pixelle_video.tts( text=narration, voice=voice, output_path=audio_path ) # Get audio duration from mutagen.mp3 import MP3 try: audio = MP3(result_path) duration = audio.info.length except: duration = 3.0 # Default duration # Update frame target_frame["audio_path"] = _path_to_url(result_path) target_frame["duration"] = duration # Recalculate total duration storyboard["total_duration"] = sum(f.get("duration", 3.0) for f in frames) _storyboard_cache[storyboard_id] = storyboard logger.info(f"Regenerated audio for frame {frame_id}, duration: {duration}s") return RegenerateAudioResponse( audio_path=target_frame["audio_path"], duration=duration, success=True ) except ImportError as e: logger.error(f"Failed to import dependencies: {e}") raise HTTPException(status_code=500, detail="TTS service not available") except Exception as e: logger.error(f"Audio regeneration failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post( "/storyboard/{storyboard_id}/frames/{frame_id}/align-prompt", response_model=AlignPromptResponse ) async def align_frame_prompt( storyboard_id: str = Path(..., description="Storyboard/task ID"), frame_id: str = Path(..., description="Frame ID"), request: AlignPromptRequest = None ): """ Align image prompt with narration Regenerates the image prompt based on the frame's narration using enhanced core imagery extraction for better semantic relevance. """ 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"] # Find frame target_frame = None for frame in frames: if frame["id"] == frame_id: target_frame = frame break if not target_frame: raise HTTPException(status_code=404, detail=f"Frame {frame_id} not found") # Get narration to use narration = request.narration if request and request.narration else target_frame.get("narration", "") if not narration: raise HTTPException(status_code=400, detail="No narration text available") try: from api.dependencies import get_pixelle_video pixelle_video = await get_pixelle_video() # Use LLM to generate aligned image prompt from pixelle_video.prompts import build_image_prompt_prompt prompt = build_image_prompt_prompt( narrations=[narration], min_words=30, max_words=60 ) response = await pixelle_video.llm( prompt=prompt, temperature=0.7, max_tokens=500 ) # Parse response import json import re # Try to extract JSON try: result = json.loads(response) except json.JSONDecodeError: # Try markdown code block match = re.search(r'```(?:json)?\s*([\s\S]+?)\s*```', response) if match: result = json.loads(match.group(1)) else: raise ValueError("Failed to parse LLM response") if "image_prompts" not in result or len(result["image_prompts"]) == 0: raise ValueError("No image prompts in response") new_prompt = result["image_prompts"][0] # Update frame target_frame["image_prompt"] = new_prompt _storyboard_cache[storyboard_id] = storyboard logger.info(f"Aligned image prompt for frame {frame_id}") return AlignPromptResponse( image_prompt=new_prompt, success=True ) except Exception as e: logger.error(f"Prompt alignment failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post( "/storyboard/{storyboard_id}/frames/{frame_id}/inpaint", response_model=InpaintResponse ) async def inpaint_frame_image( storyboard_id: str = Path(..., description="Storyboard/task ID"), frame_id: str = Path(..., description="Frame ID"), request: InpaintRequest = None ): """ Inpaint (局部重绘) frame image Uses mask to selectively regenerate parts of the image. """ 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"] # Find frame target_frame = None frame_index = 0 for i, frame in enumerate(frames): if frame["id"] == frame_id: target_frame = frame frame_index = i break if not target_frame: raise HTTPException(status_code=404, detail=f"Frame {frame_id} not found") # Get original image path original_image = target_frame.get("image_path") if not original_image: raise HTTPException(status_code=400, detail="No image to inpaint") if not request or not request.mask: raise HTTPException(status_code=400, detail="Mask is required") try: from api.dependencies import get_pixelle_video import base64 import tempfile import os pixelle_video = await get_pixelle_video() # Save mask to temp file mask_data = base64.b64decode(request.mask) output_dir = f"output/{storyboard_id}" os.makedirs(output_dir, exist_ok=True) mask_path = f"{output_dir}/mask_{frame_index}.png" with open(mask_path, 'wb') as f: f.write(mask_data) # Get prompt prompt = request.prompt or target_frame.get("image_prompt", "") # Call inpaint service # Convert URL back to file path (URL format: http://localhost:8000/api/files/{relative_path}) image_file_path = original_image if "/api/files/" in original_image: image_file_path = "output/" + original_image.split("/api/files/")[-1] result = await pixelle_video.media.inpaint( image_path=image_file_path, mask_path=mask_path, prompt=prompt, denoise_strength=request.denoise_strength, ) if result and result.url: # Save inpainted image import aiohttp image_path = f"{output_dir}/frame_{frame_index}_inpainted.png" async with aiohttp.ClientSession() as session: async with session.get(result.url) as resp: if resp.status == 200: with open(image_path, 'wb') as f: f.write(await resp.read()) # Update frame target_frame["image_path"] = _path_to_url(image_path) _storyboard_cache[storyboard_id] = storyboard logger.info(f"Inpainted image for frame {frame_id}") return InpaintResponse( image_path=target_frame["image_path"], success=True ) else: raise HTTPException(status_code=500, detail="Inpainting failed") except ImportError as e: logger.error(f"Failed to import dependencies: {e}") raise HTTPException(status_code=500, detail="Inpainting service not available") 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"] )