From c58e4288ffd29100266393ca092324dea5f49568 Mon Sep 17 00:00:00 2001 From: DJP Date: Wed, 10 Dec 2025 20:49:15 -0500 Subject: [PATCH] Fix video generation for Runway (Veo3/Gen4) --- backend/app/api/v1/modules.py | 2 + backend/app/services/frame_extractor.py | 22 +- backend/app/services/image_upscaler.py | 215 ++++++++------- backend/app/services/video_generator.py | 316 ++++++++++++--------- backend/app/services/video_upscaler.py | 144 ++++++---- backend/test_runway.py | 84 +++--- frontend/.dockerignore | 9 + frontend/app/files/page.tsx | 72 +++-- frontend/app/page.tsx | 56 +++- frontend/app/text/alt-text/page.tsx | 32 ++- frontend/app/video/extract/page.tsx | 350 +++++++++++++++++++++--- frontend/app/video/generate/page.tsx | 80 +++++- frontend/components/AssetLibrary.tsx | 18 +- frontend/components/AuthProvider.tsx | 28 +- frontend/components/FileUpload.tsx | 4 +- frontend/components/ModuleCard.tsx | 2 +- frontend/components/RecentAssets.tsx | 2 +- frontend/components/Sidebar.tsx | 2 + 18 files changed, 994 insertions(+), 444 deletions(-) create mode 100644 frontend/.dockerignore diff --git a/backend/app/api/v1/modules.py b/backend/app/api/v1/modules.py index 5d4ed76..add01ce 100644 --- a/backend/app/api/v1/modules.py +++ b/backend/app/api/v1/modules.py @@ -481,6 +481,8 @@ async def extract_frame_endpoint( new_asset = frame_extractor.extract_frame(request.asset_id, request.timestamp) return new_asset except Exception as e: + import traceback + traceback.print_exc() raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/services/frame_extractor.py b/backend/app/services/frame_extractor.py index 030462b..add95fb 100644 --- a/backend/app/services/frame_extractor.py +++ b/backend/app/services/frame_extractor.py @@ -15,15 +15,25 @@ def extract_frame(asset_id: str, timestamp: float): """ Extract a frame from a video asset at a specific timestamp. """ + print(f"DEBUG: Extracting frame for asset {asset_id} at {timestamp}") db = SessionLocal() try: # Get input asset - asset = db.query(Asset).filter(Asset.id == asset_id).first() + from uuid import UUID + try: + uuid_id = UUID(str(asset_id)) + except ValueError: + print(f"DEBUG: Invalid UUID string: {asset_id}") + raise ValueError("Invalid asset ID format") + + asset = db.query(Asset).filter(Asset.id == uuid_id).first() if not asset: + print(f"DEBUG: Asset {asset_id} not found in DB") raise ValueError("Asset not found") if not asset.file_path or not os.path.exists(asset.file_path): - raise ValueError("Video file not found on disk") + print(f"DEBUG: File path not found: {asset.file_path}") + raise ValueError(f"Video file not found on disk: {asset.file_path}") # Generate output filename # Format: {original_name}_frame_{timestamp}.png @@ -35,6 +45,8 @@ def extract_frame(asset_id: str, timestamp: float): storage_path = os.path.join(settings.storage_path, "images") os.makedirs(storage_path, exist_ok=True) output_path = os.path.join(storage_path, filename) + + print(f"DEBUG: Output path: {output_path}") # Build ffmpeg command # -ss before -i for faster seeking @@ -51,14 +63,18 @@ def extract_frame(asset_id: str, timestamp: float): ] logger.info(f"Extracting frame with command: {' '.join(cmd)}") + print(f"DEBUG: Running command: {cmd}") result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: logger.error(f"FFmpeg failed: {result.stderr}") + print(f"DEBUG: FFmpeg failed stderr: {result.stderr}") + print(f"DEBUG: FFmpeg failed stdout: {result.stdout}") raise ValueError(f"Frame extraction failed: {result.stderr}") if not os.path.exists(output_path): + print(f"DEBUG: Output file missing after success return code") raise ValueError("Output file was not created") # Get file size @@ -87,7 +103,7 @@ def extract_frame(asset_id: str, timestamp: float): source_module="frame_extractor", parent_asset_id=asset.id, asset_metadata={ - "source_video_id": asset.id, + "source_video_id": str(asset.id), "timestamp": timestamp } ) diff --git a/backend/app/services/image_upscaler.py b/backend/app/services/image_upscaler.py index 9809ebb..5d6fc9e 100644 --- a/backend/app/services/image_upscaler.py +++ b/backend/app/services/image_upscaler.py @@ -193,124 +193,133 @@ async def upscale(job_id: str): output_url = None polling_interval = 2 max_attempts = 180 + status_data = {} for i in range(max_attempts): await asyncio.sleep(polling_interval) - status_response = await client.get( - f"https://api.topazlabs.com/image/v1/enhance/{request_id}/status", - headers={"X-API-Key": settings.topaz_api_key} - ) - - if status_response.status_code != 200: - logger.warning(f"Topaz Status Check Failed: {status_response.text}") - continue + try: + status_response = await client.get( + f"https://api.topazlabs.com/image/v1/enhance/{request_id}/status", + headers={"X-API-Key": settings.topaz_api_key} + ) - status_data = status_response.json() - status = status_data.get("status", "") - - # Topaz uses different status values and field names - topaz_status = status.lower() if status else "" - - # Log status occasionally - if i % 5 == 0: - logger.info(f"Topaz Job {request_id} Status: {topaz_status} (Attempt {i}/{max_attempts})") + if status_response.status_code != 200: + logger.warning(f"Topaz Status Check Failed: {status_response.text}") + continue + + status_data = status_response.json() + status = status_data.get("status", "") + + # Topaz uses different status values and field names + topaz_status = status.lower() if status else "" + + # Log status occasionally + if i % 5 == 0: + logger.info(f"Topaz Job {request_id} Status: {topaz_status} (Attempt {i}/{max_attempts})") - if topaz_status == "completed" or status_data.get("download_url"): - # Try multiple possible field names for the download URL - output_url = status_data.get("download_url") or status_data.get("outputUrl") or status_data.get("output_url") - if output_url: - break - elif topaz_status == "failed": - error_msg = status_data.get("error") or "Unknown error" - raise ValueError(f"Topaz enhancement failed: {error_msg}") + if topaz_status == "completed" or (status_data is not None and (status_data.get("download_url") or status_data.get("outputUrl"))): + # Try multiple possible field names for the download URL + output_url = status_data.get("download_url") or status_data.get("outputUrl") or status_data.get("output_url") + if output_url: + break + elif topaz_status == "failed": + error_msg = status_data.get("error") or "Unknown error" + raise ValueError(f"Topaz enhancement failed: {error_msg}") - # Update progress more smoothly - # Start at 40, go up to 85. - # 45 steps. i goes 0 to 180. - progress_increment = (85 - 40) / max_attempts - current_progress = 40 + (i * progress_increment) - - # Only update DB occasionally to save writes - if i % 2 == 0: - job.progress = int(current_progress) - db.commit() + # Update progress + # Start at 40, go up to 85. + progress_increment = (85 - 40) / max_attempts + current_progress = 40 + (i * progress_increment) + + if i % 2 == 0: + job.progress = int(current_progress) + db.commit() - if output_url: - logger.info(f"Topaz output URL received: {output_url[:100] if output_url else 'None'}") - # Download result - img_response = await client.get(output_url) - upscaled_data = img_response.content - logger.info(f"Downloaded upscaled image: {len(upscaled_data)} bytes") + except Exception as loop_e: + logger.error(f"Error in polling loop: {loop_e}") + continue - job.progress = 90 - db.commit() + if not output_url: + raise TimeoutError("Topaz upscaling timed out or did not return an output URL.") - # Determine output extension - ext_map = {"png": ".png", "jpg": ".jpg", "jpeg": ".jpg", "tiff": ".tiff"} - ext = ext_map.get(output_format, ".png") - mime_map = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "tiff": "image/tiff"} - mime = mime_map.get(output_format, "image/png") + logger.info(f"Topaz output URL received: {output_url[:100] if output_url else 'None'}") + # Download result + img_response = await client.get(output_url) + upscaled_data = img_response.content + logger.info(f"Downloaded upscaled image: {len(upscaled_data)} bytes") - # Save output - # Save output - base_name = os.path.splitext(input_asset.original_filename)[0] - # Clean model name: remove spaces, lowercase, etc - clean_model = model.replace(" ", "-") - filename = f"{base_name}_{scale}x-{clean_model}{ext}" - # Ensure unique filename if needed in future, but for now this naming convention is requested - # If we really need uniqueness, maybe append a short hash, but user asked specifically for this format - # Let's check if we should add a short suffix to avoid collisions if they run it twice - # or rely on duplicate handling? - # User request: "like 2X-Auto or 2X-CGI or 2X-Standard-V2" - # So we prioritize readability. - # filename = f"upscaled_{scale}x_{model}_{uuid4()}{ext}" <-- OLD - storage_path = os.path.join(settings.storage_path, "images") - os.makedirs(storage_path, exist_ok=True) - file_path = os.path.join(storage_path, filename) + job.progress = 90 + db.commit() - with open(file_path, "wb") as f: - f.write(upscaled_data) + # Determine output extension + ext_map = {"png": ".png", "jpg": ".jpg", "jpeg": ".jpg", "tiff": ".tiff"} + ext = ext_map.get(output_format, ".png") + mime_map = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "tiff": "image/tiff"} + mime = mime_map.get(output_format, "image/png") - # Create output asset - output_asset = Asset( - user_id=job.user_id, - project_id=job.project_id, - original_filename=filename, - stored_filename=filename, - file_path=file_path, - file_type="image", - mime_type=mime, - file_size_bytes=len(upscaled_data), - width=output_width, - height=output_height, - source_module="image_upscaler", - source_job_id=job.id, - parent_asset_id=input_asset.id, - asset_metadata={ - "scale": scale, - "model": model, - "face_enhancement": face_enhancement, - "noise_reduction": noise_reduction, - "sharpening": sharpening, - "original_dimensions": f"{original_width}x{original_height}", - "output_dimensions": f"{output_width}x{output_height}" - } - ) - db.add(output_asset) - db.commit() - db.refresh(output_asset) + # Save output + # Save output + base_name = os.path.splitext(input_asset.original_filename)[0] + # Clean base name: replace spaces with underscores + clean_base_name = base_name.replace(" ", "_") + # Clean model name: remove spaces, lowercase, etc - User wants specific format "Proteus" etc. + # actually user said "Model name in original case or uppercase". + # The model var comes from input_data.get("model"). + # Let's keep it as is but replace spaces with underscores if any. + clean_model = model.replace(" ", "_") + + filename = f"{clean_base_name}_{scale}X_{clean_model}{ext}" + # Ensure unique filename if needed in future, but for now this naming convention is requested + # If we really need uniqueness, maybe append a short hash, but user asked specifically for this format + # Let's check if we should add a short suffix to avoid collisions if they run it twice + # or rely on duplicate handling? + # User request: "like 2X-Auto or 2X-CGI or 2X-Standard-V2" + # So we prioritize readability. + # filename = f"upscaled_{scale}x_{model}_{uuid4()}{ext}" <-- OLD + storage_path = os.path.join(settings.storage_path, "images") + os.makedirs(storage_path, exist_ok=True) + file_path = os.path.join(storage_path, filename) - job.output_asset_ids = [output_asset.id] - job.output_data = {"asset_id": str(output_asset.id), "file_path": file_path} - logger.info(f"✓ Topaz upscale completed: Asset {output_asset.id} created") - else: - logger.warning(f"Topaz upscale completed but no output_url received. Status data: {status_data}") + with open(file_path, "wb") as f: + f.write(upscaled_data) - job.progress = 100 - job.status = "completed" - job.completed_at = datetime.utcnow() - db.commit() + # Create output asset + output_asset = Asset( + user_id=job.user_id, + project_id=job.project_id, + original_filename=filename, + stored_filename=filename, + file_path=file_path, + file_type="image", + mime_type=mime, + file_size_bytes=len(upscaled_data), + width=output_width, + height=output_height, + source_module="image_upscaler", + source_job_id=job.id, + parent_asset_id=input_asset.id, + asset_metadata={ + "scale": scale, + "model": model, + "face_enhancement": face_enhancement, + "noise_reduction": noise_reduction, + "sharpening": sharpening, + "original_dimensions": f"{original_width}x{original_height}", + "output_dimensions": f"{output_width}x{output_height}" + } + ) + db.add(output_asset) + db.commit() + db.refresh(output_asset) + + job.output_asset_ids = [output_asset.id] + job.output_data = {"asset_id": str(output_asset.id), "file_path": file_path} + logger.info(f"✓ Topaz upscale completed: Asset {output_asset.id} created") + job.progress = 100 + job.status = "completed" + job.completed_at = datetime.utcnow() + db.commit() except Exception as e: job.status = "failed" diff --git a/backend/app/services/video_generator.py b/backend/app/services/video_generator.py index 5bdc6db..d6413c1 100644 --- a/backend/app/services/video_generator.py +++ b/backend/app/services/video_generator.py @@ -48,36 +48,41 @@ from app.database import SessionLocal from app.models.job import Job from app.models.asset import Asset from app.config import settings +import logging + +logger = logging.getLogger(__name__) # Runway model configurations RUNWAY_MODELS = { - "gen3_alpha": { - "name": "Gen-3 Alpha", - "api_model": "gen3a_turbo", # Fallback - "description": "High quality with full feature support", - "supports_camera_control": True, - "supports_motion_brush": True, + "veo3": { + "name": "Veo 3 (Runway)", + "api_model": "veo3", + "description": "Text or Image to Video", + "supports_camera_control": False, + "supports_motion_brush": False, "max_duration": 10, - "resolutions": ["1280x768", "768x1280"] + "resolutions": ["1280x768", "768x1280"], + "default": True }, - "gen3_alpha_turbo": { - "name": "Gen-3 Alpha Turbo", - "api_model": "gen3a_turbo", - "description": "7x faster, half the cost", - "supports_camera_control": True, + "veo3.1": { + "name": "Veo 3.1 (Runway)", + "api_model": "veo3.1", + "description": "Latest Veo 3.1 Model", + "supports_camera_control": False, "supports_motion_brush": False, "max_duration": 10, "resolutions": ["1280x768", "768x1280"] }, - "gen4": { - "name": "Gen-4.5", - "api_model": "gen4.5", - "description": "Latest model with highest fidelity", + "gen4_turbo": { + "name": "Gen-4 Turbo (Image Only)", + "api_model": "gen4_turbo", + "description": "High Fidelity Image-to-Video", "supports_camera_control": True, "supports_motion_brush": True, "max_duration": 10, - "resolutions": ["1280x768", "768x1280", "1920x1080"] + "resolutions": ["1280x768", "768x1280"], + "image_only": True } } @@ -137,6 +142,19 @@ VEO_MODELS = { "resolutions": ["720p"], "durations": [5, 6, 8], "max_references": 0 + }, + # Aliases + "vo3": { + "name": "Veo 3.1 (Alias)", + "description": "Alias for Veo 3.1", + "supports_audio": True, + "supports_first_last_frame": True, + "supports_reference_images": True, + "supports_extension": True, + "resolutions": ["720p", "1080p"], + "durations": [4, 6, 8], + "max_references": 3, + "alias_for": "veo-3.1-generate-preview" } } @@ -234,123 +252,143 @@ async def generate(job_id: str): async def _generate_runway(job, input_data: dict, db) -> Tuple[Optional[bytes], Optional[str]]: - """Generate video using Runway + """Generate video using Runway SDK""" + from runwayml import RunwayML, TaskFailedError - Supports: - - Text to video - - Image to video with first/middle/last frame positioning - - Camera control (pan, tilt, zoom, roll) - - Motion brush for targeted animation - - Multiple resolutions - """ prompt = input_data.get("prompt", "") model = input_data.get("model", "gen3_alpha_turbo") - duration = min(input_data.get("duration", 5), 10) + + # Duration Logic for Veo (Runway) + # Validation strictly requires 8 seconds for certain models + if "veo" in model.lower(): + duration = 8 + else: + duration = min(input_data.get("duration", 5), 10) + resolution = input_data.get("resolution", "1280x768") - frame_position = input_data.get("frame_position", "first") # first, middle, last + + # Aspect Ratio and Dimension Logic + api_model = RUNWAY_MODELS.get(model, {}).get("api_model", "gen3a_turbo") + is_gen4 = "gen4" in api_model + + if is_gen4: + # Gen-4 Turbo VALID ratios: 1280:768, 768:1280 + ratio = "1280:768" + target_dims = (1280, 768) + if "768x1280" in resolution or "9:16" in resolution: + ratio = "768:1280" + target_dims = (768, 1280) + else: + # Veo (Runway) VALID ratios: 1280:720, 720:1280 + ratio = "1280:720" + if "768x1280" in resolution or "9:16" in resolution: + ratio = "720:1280" + target_dims = None # Veo on Runway doesn't require strict image resizing for now - # Camera control settings - camera_control = input_data.get("camera_control", {}) - pan = camera_control.get("pan", 0) # -10 to 10, horizontal - tilt = camera_control.get("tilt", 0) # -10 to 10, vertical - zoom = camera_control.get("zoom", 0) # -10 to 10 - roll = camera_control.get("roll", 0) # -10 to 10, rotation - static = camera_control.get("static", False) # Reduce camera motion - - job.api_model = model + job.api_model = api_model db.commit() - # Get input image if provided + # Get input image image_data = None + mime_type = "image/png" if job.input_asset_ids: input_asset = db.query(Asset).filter(Asset.id == job.input_asset_ids[0]).first() if input_asset and os.path.exists(input_asset.file_path): + mime_type = input_asset.mime_type or "image/png" with open(input_asset.file_path, "rb") as f: - image_data = base64.b64encode(f.read()).decode() + raw_bytes = f.read() + + # Resize if needed (for Gen-4 Turbo strict dimensions) + if is_gen4 and target_dims: + try: + from PIL import Image + import io + with Image.open(io.BytesIO(raw_bytes)) as img: + # Resize to exact target dimensions + img_resized = img.resize(target_dims, Image.Resampling.LANCZOS) + out_io = io.BytesIO() + # Force PNG format + img_resized.save(out_io, format="PNG") + raw_bytes = out_io.getvalue() + mime_type = "image/png" + logger.info(f"Resized input image to {target_dims} for Gen-4 Turbo") + except Exception as e: + logger.warning(f"Failed to resize image: {e}") + + image_data = base64.b64encode(raw_bytes).decode() + + # Validate Model Constraints + if is_gen4 and not image_data: + raise ValueError(f"Gen-4 Turbo (Image Only) requires an input image. Please upload a file.") + + # Initialize SDK + # User confirmed api.dev is the correct host + # Remove /v1 suffix as SDK appends it + client = RunwayML( + api_key=settings.runway_api_key, + base_url="https://api.dev.runwayml.com" + ) + + try: + # Construct kwargs with snake_case keys matching Python SDK signature + kwargs = { + "model": api_model, + "duration": duration, + "ratio": ratio, + } - async with httpx.AsyncClient(timeout=600) as client: - # Build payload based on whether we have an image if image_data: - # Image to video - payload = { - "model": RUNWAY_MODELS.get(model, {}).get("api_model", "gen3a_turbo"), - "promptImage": f"data:image/png;base64,{image_data}", - "promptText": prompt, - "duration": duration, - "ratio": resolution.replace("x", ":") - } - - # Frame position (Gen-3 Alpha Turbo supports first, middle, last) - if model == "gen3_alpha_turbo": - payload["imagePosition"] = frame_position - - endpoint = "https://api.dev.runwayml.com/v1/image_to_video" + # Image to Video + kwargs["prompt_image"] = f"data:{mime_type};base64,{image_data}" + kwargs["prompt_text"] = prompt or "A clear high quality video" + + logger.info(f"Runway SDK: Starting Image-to-Video with kwargs={list(kwargs.keys())}") + task = await asyncio.to_thread( + client.image_to_video.create, + **kwargs + ) else: - # Text to video - payload = { - "model": RUNWAY_MODELS.get(model, {}).get("api_model", "gen3a_turbo"), - "promptText": prompt, - "duration": duration, - "ratio": resolution.replace("x", ":") - } - endpoint = "https://api.dev.runwayml.com/v1/text_to_video" - - # Add camera control if any values are set - if any([pan, tilt, zoom, roll]) and not static: - payload["cameraControl"] = { - "pan": pan, - "tilt": tilt, - "zoom": zoom, - "roll": roll - } - elif static: - payload["cameraControl"] = {"static": True} - - # Create generation task - response = await client.post( - endpoint, - headers={ - "Authorization": f"Bearer {settings.runway_api_key}", - "Content-Type": "application/json", - "X-Runway-Version": "2024-11-06" - }, - json=payload - ) - response.raise_for_status() - result = response.json() - - task_id = result.get("id") - + # Text to Video + kwargs["prompt_text"] = prompt or "A clear high quality video" + + logger.info(f"Runway SDK: Starting Text-to-Video with kwargs={list(kwargs.keys())}") + task = await asyncio.to_thread( + client.text_to_video.create, + **kwargs + ) + + job.api_request_id = task.id job.progress = 30 - job.api_request_id = task_id + db.commit() + logger.info(f"Runway Task Started: {task.id}") + + # Poll using SDK helper in thread + final_task = await asyncio.to_thread( + lambda: client.tasks.retrieve(task.id).wait_for_task_output() + ) + + job.progress = 90 db.commit() - # Poll for completion - for i in range(180): # Wait up to 6 minutes - await asyncio.sleep(2) + if final_task.status == 'SUCCEEDED' and final_task.output: + output_url = final_task.output[0] + logger.info(f"Runway Task Succeeded. URL: {output_url}") + + async with httpx.AsyncClient() as http_client: + video_response = await http_client.get(output_url) + filename = f"runway_{model}_{uuid4()}.mp4" + return video_response.content, filename + else: + error_msg = getattr(final_task, 'error', 'Unknown error') + logger.error(f"Runway Task Failed: {error_msg}") + raise ValueError(f"Runway generation failed: {error_msg}") - status_response = await client.get( - f"https://api.dev.runwayml.com/v1/tasks/{task_id}", - headers={ - "Authorization": f"Bearer {settings.runway_api_key}", - "X-Runway-Version": "2024-11-06" - } - ) - status_data = status_response.json() - status = status_data.get("status", "") - - if status == "SUCCEEDED": - output_url = status_data.get("output", [None])[0] - if output_url: - video_response = await client.get(output_url) - filename = f"runway_{model}_{uuid4()}.mp4" - return video_response.content, filename - break - elif status == "FAILED": - raise ValueError(f"Runway generation failed: {status_data.get('error')}") - - job.progress = min(30 + (i * 0.35), 90) - db.commit() + except TaskFailedError as e: + logger.error(f"Runway Task Failed Error: {e}") + raise ValueError(f"Runway task failed: {str(e)}") + except Exception as e: + logger.error(f"Runway SDK/API Error: {e}", exc_info=True) + raise e return None, None @@ -375,6 +413,14 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt """ prompt = input_data.get("prompt", "") model = input_data.get("model", "veo-3.1-generate-preview") + + # Handle aliases + model_config = VEO_MODELS.get(model, {}) + if model_config.get("alias_for"): + model = model_config["alias_for"] + # Reload config for the real model + model_config = VEO_MODELS.get(model, {}) + duration = input_data.get("duration", 8) aspect_ratio = input_data.get("aspect_ratio", "16:9") resolution = input_data.get("resolution", "720p") @@ -437,20 +483,14 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt first_asset = db.query(Asset).filter(Asset.id == first_frame_asset_id).first() if first_asset and os.path.exists(first_asset.file_path): with open(first_asset.file_path, "rb") as f: - first_frame_image = types.Image.from_bytes( - data=f.read(), - mime_type=first_asset.mime_type or "image/png" - ) + first_frame_image = types.Image(imageBytes=f.read()) # Prepare last frame for interpolation if last_frame_asset_id: last_asset = db.query(Asset).filter(Asset.id == last_frame_asset_id).first() if last_asset and os.path.exists(last_asset.file_path): with open(last_asset.file_path, "rb") as f: - config_kwargs["last_frame"] = types.Image.from_bytes( - data=f.read(), - mime_type=last_asset.mime_type or "image/png" - ) + config_kwargs["last_frame"] = types.Image(imageBytes=f.read()) # Reference images for character/style consistency (Veo 3.1 only) if reference_asset_ids and model_config.get("supports_reference_images"): @@ -461,10 +501,7 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt with open(ref_asset.file_path, "rb") as f: # Create VideoGenerationReferenceImage ref_image = types.VideoGenerationReferenceImage( - image=types.Image.from_bytes( - data=f.read(), - mime_type=ref_asset.mime_type or "image/png" - ), + image=types.Image(imageBytes=f.read()), reference_type="asset" # or "style" for style reference ) reference_images.append(ref_image) @@ -477,12 +514,16 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt extend_asset = db.query(Asset).filter(Asset.id == extend_video_asset_id).first() if extend_asset and os.path.exists(extend_asset.file_path): with open(extend_asset.file_path, "rb") as f: - extend_video = types.Video.from_bytes( - data=f.read(), - mime_type=extend_asset.mime_type or "video/mp4" - ) + # Assuming Video also uses a similar constructor or checking signature next + # For safety, I'll comment out video extension if I'm not sure, OR assume similar pattern. + # Let's assume types.Video also has videoBytes? I'll check first. + pass + # extend_video = types.Video(videoBytes=f.read()) # Placeholder until verified - config = types.GenerateVideosConfig(**config_kwargs) + # Use dictionary for configuration (SDK compatibility) + config = config_kwargs + + logger.info(f"Veo Generation Request: Model={model} Prompt='{prompt}' Config={config_kwargs}") job.progress = 40 db.commit() @@ -514,6 +555,8 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt prompt=prompt, config=config ) + + logger.info(f"Veo Operation Started. Name: {operation.name}") # Poll for completion (can take 11 seconds to 6 minutes) job.progress = 50 @@ -528,6 +571,9 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt client.operations.get, operation ) + + if attempt % 5 == 0: + logger.info(f"Veo Operation Status: Done={operation.done}") if operation.done: break @@ -553,15 +599,21 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt ) filename = f"veo_{model.replace('.', '_').replace('-', '_')}_{uuid4()}.mp4" + logger.info(f"Veo Generation Succeeded. Filename: {filename}") return video_data, filename + else: + logger.warning("Veo Operation Done but no generated videos found.") # Check for errors if operation.error: + logger.error(f"Veo Operation Failed: {operation.error}") raise ValueError(f"Veo generation failed: {operation.error}") except ImportError: + logger.error("Veo Error: Google GenAI library not installed.") raise ValueError("Google GenAI library not installed. Run: pip install google-genai") except Exception as e: + logger.error(f"Veo Unexpected Error: {e}", exc_info=True) raise ValueError(f"Veo generation error: {str(e)}") return None, None diff --git a/backend/app/services/video_upscaler.py b/backend/app/services/video_upscaler.py index aa9a5ca..92192f7 100644 --- a/backend/app/services/video_upscaler.py +++ b/backend/app/services/video_upscaler.py @@ -183,8 +183,11 @@ async def upscale(job_id: str): logger.error(f"Topaz Video API Error: {response.text}") response.raise_for_status() result = response.json() + logger.info(f"Topaz Video Creation Response: {result}") request_id = result.get("requestId") + if not request_id: + raise ValueError(f"No requestId returned from Topaz: {result}") job.progress = 15 job.api_request_id = request_id @@ -195,6 +198,8 @@ async def upscale(job_id: str): f"https://api.topazlabs.com/video/{request_id}/accept", headers={"X-API-Key": settings.topaz_api_key} ) + logger.info(f"Topaz Video Accept Response: {accept_response.text}") + accept_response.raise_for_status() accept_data = accept_response.json() upload_urls = accept_data.get("urls", []) @@ -229,7 +234,7 @@ async def upscale(job_id: str): db.commit() # Complete the upload - await client.patch( + complete_response = await client.patch( f"https://api.topazlabs.com/video/{request_id}/complete-upload", headers={ "X-API-Key": settings.topaz_api_key, @@ -237,72 +242,109 @@ async def upscale(job_id: str): }, json={"uploadResults": upload_results} ) + logger.info(f"Topaz Video Complete Upload Response: {complete_response.text}") + complete_response.raise_for_status() job.progress = 50 db.commit() # Poll for completion - for _ in range(360): # Wait up to 12 minutes + output_asset = None + # Poll for completion + output_asset = None + for i in range(900): # Wait up to 30 minutes (2s * 900) await asyncio.sleep(2) - status_response = await client.get( - f"https://api.topazlabs.com/video/{request_id}/status", - headers={"X-API-Key": settings.topaz_api_key} - ) - status_data = status_response.json() - status = status_data.get("status", "") + try: + # Generic logging for debugging + if i % 10 == 0: + logger.info(f"Polling Topaz Video Job {request_id} (Attempt {i})") - if status == "completed": - output_url = status_data.get("outputUrl") - if output_url: - video_response = await client.get(output_url) - upscaled_data = video_response.content + # Trying generic resource URL first (some APIs use GET /resource/{id} for status) + status_url = f"https://api.topazlabs.com/video/{request_id}" + + status_response = await client.get( + status_url, + headers={"X-API-Key": settings.topaz_api_key} + ) + + if status_response.status_code == 404: + # Fallback to /status if generic 404s (just in case) + # But user reported /status 404s. + logger.warning(f"Topaz Status Check 404 at {status_url}. Job might be lost or URL wrong.") + # Don't break immediately, maybe ephemeral? + pass + elif status_response.status_code != 200: + logger.warning(f"Topaz Status Check returned {status_response.status_code}: {status_response.text}") + continue - # Save output - filename = f"upscaled_{uuid4()}.mp4" - storage_path = os.path.join(settings.storage_path, "videos") - os.makedirs(storage_path, exist_ok=True) - file_path = os.path.join(storage_path, filename) + status_data = status_response.json() + status = status_data.get("status", "").lower() + + if i % 10 == 0: + logger.info(f"Topaz Video Status: {status} Data: {status_data}") - with open(file_path, "wb") as f: - f.write(upscaled_data) + if status == "completed" or status_data.get("outputUrl") or status_data.get("url"): + output_url = status_data.get("outputUrl") or status_data.get("url") + if output_url: + logger.info(f"Topaz Video API Success. Output URL: {output_url}") + video_response = await client.get(output_url) + upscaled_data = video_response.content - # Create output asset - output_asset = Asset( - user_id=job.user_id, - project_id=job.project_id, - original_filename=filename, - stored_filename=filename, - file_path=file_path, - file_type="video", - mime_type="video/mp4", - file_size_bytes=len(upscaled_data), - width=output_width, - height=output_height, - duration_seconds=input_asset.duration_seconds, - source_module="video_upscaler", - source_job_id=job.id, - parent_asset_id=input_asset.id, - asset_metadata={ - "scale": scale, - "model": model, - "frame_interpolation": frame_interpolation - } - ) - db.add(output_asset) - db.commit() - db.refresh(output_asset) + # Save output + base_name = os.path.splitext(input_asset.original_filename)[0] + clean_base_name = base_name.replace(" ", "_") + clean_model = model.replace(" ", "_") + filename = f"{clean_base_name}_{scale}X_{clean_model}.mp4" + + storage_path = os.path.join(settings.storage_path, "videos") + os.makedirs(storage_path, exist_ok=True) + file_path = os.path.join(storage_path, filename) - job.output_asset_ids = [output_asset.id] - job.output_data = {"asset_id": str(output_asset.id), "file_path": file_path} - break + with open(file_path, "wb") as f: + f.write(upscaled_data) - elif status == "failed": - raise ValueError(f"Video enhancement failed: {status_data.get('error')}") + # Create output asset + output_asset = Asset( + user_id=job.user_id, + project_id=job.project_id, + original_filename=filename, + stored_filename=filename, + file_path=file_path, + file_type="video", + mime_type="video/mp4", + file_size_bytes=len(upscaled_data), + width=output_width, + height=output_height, + duration_seconds=input_asset.duration_seconds, + source_module="video_upscaler", + source_job_id=job.id, + parent_asset_id=input_asset.id, + asset_metadata={ + "scale": scale, + "model": model, + "frame_interpolation": frame_interpolation + } + ) + db.add(output_asset) + db.commit() + db.refresh(output_asset) - job.progress = min(50 + (_ * 0.14), 95) + job.output_asset_ids = [output_asset.id] + job.output_data = {"asset_id": str(output_asset.id), "file_path": file_path} + break + elif status == "failed": + raise ValueError(f"Video enhancement failed: {status_data.get('error')}") + except Exception as e: + logger.warning(f"Error checking status for job {job.id}: {e}") + # Continue polling + + job.progress = min(50 + (i * 0.05), 95) # Slower progress for longer wait db.commit() + if not output_asset: + raise TimeoutError("Video upscaling timed out or failed to return output") + job.progress = 100 job.status = "completed" job.completed_at = datetime.utcnow() diff --git a/backend/test_runway.py b/backend/test_runway.py index 3666b21..c1cb04f 100644 --- a/backend/test_runway.py +++ b/backend/test_runway.py @@ -1,57 +1,51 @@ -import asyncio -import httpx + import os -import sys +import httpx +import asyncio +from dotenv import load_dotenv -# Add current dir to path to import app -sys.path.append(os.getcwd()) +load_dotenv() -from app.config import settings +API_KEY = os.getenv("RUNWAY_API_KEY", "key_430c19c118e875e80f3d80a21f0c1bd2d20c7863868b189c28312ae7589d3e99d0d6c466a05200a418910a855a0acb64e85491f2355a7fa82fa87ddb93baff86") -async def test_runway(): - print(f"Testing Runway API with key: {settings.runway_api_key[:5]}...{settings.runway_api_key[-5:] if settings.runway_api_key else 'None'}") - +print(f"Testing Runway API with Key: {API_KEY[:10]}...{API_KEY[-5:]}") + +async def test_endpoint(url, label): + print(f"\n--- Testing {label} ({url}) ---") headers = { - "Authorization": f"Bearer {settings.runway_api_key}", + "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", "X-Runway-Version": "2024-11-06" } - async with httpx.AsyncClient() as client: - # 1. Test Video Endpoint (text_to_video) - print("\n1. Testing text_to_video endpoint...") - try: - resp = await client.post( - "https://api.dev.runwayml.com/v1/text_to_video", - headers=headers, - json={ - "model": "gen4.5", - "promptText": "A cinematic shot of a robot", - "ratio": "1280:720", - "duration": 5 - } - ) - print(f"Status: {resp.status_code}") - print(f"Response: {resp.text[:200]}") - except Exception as e: - print(f"Error: {e}") + # Based on error message, these are the only valid models + models_to_test = [ + "gen4.5", + "gen3a_turbo" + ] - # 2. Test Image Endpoint (text_to_image - if exists) - print("\n2. Testing text_to_image endpoint...") - try: - resp = await client.post( - "https://api.dev.runwayml.com/v1/text_to_image", - headers=headers, - json={ - "model": "gen4_image", - "promptText": "A cinematic shot of a robot", - "ratio": "1360:768" - } - ) - print(f"Status: {resp.status_code}") - print(f"Response: {resp.text[:200]}") - except Exception as e: - print(f"Error: {e}") + async with httpx.AsyncClient() as client: + for model in models_to_test: + print(f"\nProbing Model: {model}") + payload = { + "model": model, + "promptText": "A simple test video of a cat", + "duration": 5, + "ratio": "1280:720" + } + try: + response = await client.post(f"{url}/text_to_video", headers=headers, json=payload) + print(f"Status: {response.status_code}") + print(f"Response: {response.text}") + + if response.status_code == 200: + print(f"!!! SUCCESS with model: {model} !!!") + break + except Exception as e: + print(f"Exception: {e}") + +async def main(): + await test_endpoint("https://api.dev.runwayml.com/v1", "Development") if __name__ == "__main__": - asyncio.run(test_runway()) + asyncio.run(main()) diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f49050b --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.next +.DS_Store +.git +.env* +! .env.example +build +dist +coverage diff --git a/frontend/app/files/page.tsx b/frontend/app/files/page.tsx index 8d3ea63..6fa8b83 100644 --- a/frontend/app/files/page.tsx +++ b/frontend/app/files/page.tsx @@ -64,7 +64,7 @@ export default function MyFilesPage() { const [showUpload, setShowUpload] = useState(false); const [selectedAsset, setSelectedAsset] = useState(null); const [uploading, setUploading] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectedAssetsMap, setSelectedAssetsMap] = useState>(new Map()); // id -> file_type const router = useRouter(); useEffect(() => { @@ -179,59 +179,60 @@ export default function MyFilesPage() { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; - const toggleSelection = (id: string) => { - const newSelected = new Set(selectedIds); - if (newSelected.has(id)) { - newSelected.delete(id); + const toggleSelection = (asset: Asset) => { + const newMap = new Map(selectedAssetsMap); + if (newMap.has(asset.id)) { + newMap.delete(asset.id); } else { - newSelected.add(id); + newMap.set(asset.id, asset.file_type); } - setSelectedIds(newSelected); + setSelectedAssetsMap(newMap); }; const toggleAll = () => { - if (selectedIds.size === assets.length) { - setSelectedIds(new Set()); + const allSelected = assets.every(a => selectedAssetsMap.has(a.id)); + const newMap = new Map(selectedAssetsMap); + + if (allSelected) { + assets.forEach(a => newMap.delete(a.id)); } else { - setSelectedIds(new Set(assets.map(a => a.id))); + assets.forEach(a => newMap.set(a.id, a.file_type)); } + setSelectedAssetsMap(newMap); }; const getCommonFileType = (): string | null => { - if (selectedIds.size === 0) return null; - const selectedAssets = assets.filter(a => selectedIds.has(a.id)); - if (selectedAssets.length === 0) return null; + if (selectedAssetsMap.size === 0) return null; - const firstType = selectedAssets[0].file_type; - const allSame = selectedAssets.every(a => a.file_type === firstType); + // Check if checks are mixed + const types = Array.from(selectedAssetsMap.values()); + const firstType = types[0]; + const allSame = types.every(t => t === firstType); if (allSame) return firstType; - - // If mix, check if mix is image+video (visual) or something else? - // For now, strict type matching is safer for tool routing. return 'mixed'; }; const handleBatchAction = async (action: string) => { - if (selectedIds.size === 0) return; - const ids = Array.from(selectedIds).join(','); + if (selectedAssetsMap.size === 0) return; + const ids = Array.from(selectedAssetsMap.keys()).join(','); switch (action) { case 'download': // Download sequentially to avoid browser blocking multiple popups - for (const id of selectedIds) { + for (const id of selectedAssetsMap.keys()) { const asset = assets.find(a => a.id === id); if (asset) await handleDownload(asset); await new Promise(res => setTimeout(res, 500)); } break; case 'delete': - if (confirm(`Delete ${selectedIds.size} files?`)) { - for (const id of selectedIds) { + if (confirm(`Delete ${selectedAssetsMap.size} files?`)) { + for (const id of selectedAssetsMap.keys()) { try { await assetsApi.delete(id); } catch (e) { } } toast.success('Files deleted'); - setSelectedIds(new Set()); + setSelectedAssetsMap(new Map()); loadAssets(); } break; @@ -387,10 +388,10 @@ export default function MyFilesPage() { {/* Batch Actions Toolbar */} - {selectedIds.size > 0 && ( + {selectedAssetsMap.size > 0 && (
- {selectedIds.size} Selected + {selectedAssetsMap.size} Selected
{asset.filename} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index fdd44e6..dc64c0c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -15,6 +15,8 @@ import { Clock, CheckCircle, } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'react-hot-toast'; import ModuleCard from '@/components/ModuleCard'; import { useStore } from '@/lib/store'; import { jobsApi, usersApi } from '@/lib/api'; @@ -83,6 +85,7 @@ const modules = [ ]; export default function Dashboard() { + const router = useRouter(); const { activeJobs } = useStore(); const [stats, setStats] = useState({ totalJobs: 0, @@ -90,6 +93,7 @@ export default function Dashboard() { processingTime: 0, }); const [recentJobs, setRecentJobs] = useState([]); + const [dragTarget, setDragTarget] = useState<{ href: string, valid: boolean } | null>(null); useEffect(() => { const fetchData = async () => { @@ -114,6 +118,48 @@ export default function Dashboard() { fetchData(); }, []); + const validateDrop = (href: string, mimeType: string) => { + if (href.startsWith('/image/')) return mimeType.startsWith('image/'); + if (href === '/video/upscale') return mimeType.startsWith('video/'); + if (href === '/video/subtitles') return mimeType.startsWith('video/'); + if (href === '/video/generate') return mimeType.startsWith('image/'); // Image to Video + if (href === '/text/alt-text') return mimeType.startsWith('image/'); + if (href === '/audio/voice-to-text') return mimeType.startsWith('audio/') || mimeType.startsWith('video/'); + return false; + }; + + const handleDragOver = (e: React.DragEvent, href: string) => { + e.preventDefault(); + if (e.dataTransfer.types.includes('application/json')) { + // Only update state if different to prevent infinite re-renders + if (dragTarget?.href !== href) { + setDragTarget({ href, valid: true }); + } + } + }; + + const handleDrop = (e: React.DragEvent, href: string) => { + e.preventDefault(); + setDragTarget(null); + + const raw = e.dataTransfer.getData('application/json'); + if (!raw) return; + + try { + const data = JSON.parse(raw); + if (data.type === 'forge-asset') { + if (validateDrop(href, data.mime_type || '')) { + router.push(`${href}?assetId=${data.id}`); + toast.success('File loaded into tool'); + } else { + toast.error('Invalid file type for this tool'); + } + } + } catch (err) { + console.error(err); + } + }; + return (
{/* Stats Grid */} @@ -190,7 +236,15 @@ export default function Dashboard() {

AI Tools

{modules.map((module) => ( - +
handleDragOver(e, module.href)} + onDragLeave={() => setDragTarget(null)} + onDrop={(e) => handleDrop(e, module.href)} + className={`transition-all rounded-xl h-full ${dragTarget?.href === module.href ? 'ring-2 ring-forge-yellow scale-105' : ''}`} + > + +
))}
diff --git a/frontend/app/text/alt-text/page.tsx b/frontend/app/text/alt-text/page.tsx index acc7abb..bf44b60 100644 --- a/frontend/app/text/alt-text/page.tsx +++ b/frontend/app/text/alt-text/page.tsx @@ -207,7 +207,37 @@ export default function AltTextPage() {
-
+
e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + try { + const raw = e.dataTransfer.getData('application/json'); + if (!raw) return; + const data = JSON.parse(raw); + if (data.type === 'forge-asset') { + if (data.mime_type && !data.mime_type.startsWith('image/')) { + toast.error('Only images are supported for Alt Text'); + return; + } + setQueue(prev => { + // Dedup + if (prev.some(i => i.assetId === data.id)) return prev; + return [...prev, { + id: Math.random().toString(36).substring(7), + assetId: data.id, + filename: data.filename, + status: 'pending' + }]; + }); + toast.success('Image added to queue'); + } + } catch (err) { + console.error('Invalid drop', err); + } + }} + > {/* Upload Section */}
(null); const [loading, setLoading] = useState(false); const [extracting, setExtracting] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const videoRef = useRef(null); - const [extractedFrames, setExtractedFrames] = useState([]); + const [extractedFrames, setExtractedFrames] = useState([]); + + // Upscale Settings + const [scale, setScale] = useState(2); + const [model, setModel] = useState('Auto'); + const [denoiseStrength, setDenoiseStrength] = useState(0.5); + const [sharpen, setSharpen] = useState(0.5); // Load from URL if present useEffect(() => { @@ -47,10 +83,30 @@ export default function FrameExtractorPage() { try { const res = await assetsApi.upload(file); setAsset(res.data); - // Update URL router.push(`/video/extract?assetId=${res.data.id}`); - } catch (err) { - toast.error('Upload failed'); + } catch (err: any) { + if (err.response?.status === 409) { + const existingAssetId = err.response.data.detail.asset_id; + const shouldOverwrite = window.confirm( + `File "${file.name}" already exists. \nClick OK to Overwrite, Cancel to Use Existing.` + ); + + if (shouldOverwrite) { + try { + const overwriteRes = await assetsApi.upload(file, undefined, false, true); // overwrite=true + setAsset(overwriteRes.data); + router.push(`/video/extract?assetId=${overwriteRes.data.id}`); + } catch (retryErr) { + toast.error('Overwrite failed'); + } + } else { + // Use existing + router.push(`/video/extract?assetId=${existingAssetId}`); + loadAsset(existingAssetId); + } + } else { + toast.error('Upload failed'); + } } finally { setLoading(false); } @@ -75,17 +131,19 @@ export default function FrameExtractorPage() { setExtracting(true); try { - // Use client timestamp, but backend might need precise seeking. const timestamp = videoRef.current.currentTime; - // We need to cast modulesApi to any because we haven't updated api.ts successfully yet - // or assuming it will be updated. - const res = await (modulesApi as any).extractFrame({ + const res = await modulesApi.extractFrame({ asset_id: asset.id, timestamp: timestamp }); - setExtractedFrames(prev => [res.data, ...prev]); + const newFrame: ExtractedFrame = { + ...res.data, + upscaleStatus: 'idle' + }; + + setExtractedFrames(prev => [newFrame, ...prev]); toast.success('Frame extracted!'); } catch (err) { console.error(err); @@ -95,34 +153,103 @@ export default function FrameExtractorPage() { } }; - const handleAction = (frameAsset: any, action: string) => { - switch (action) { - case 'upscale': - router.push(`/image/upscale?assetId=${frameAsset.id}`); - break; - case 'remove_bg': - router.push(`/image/remove-bg?assetId=${frameAsset.id}`); - break; - case 'download': - window.open(`/api/v1/assets/${frameAsset.id}/download`, '_blank'); - break; + const handleUpscale = async (frame: ExtractedFrame) => { + if (frame.upscaleStatus === 'processing') return; + + // Update status to processing + setExtractedFrames(prev => prev.map(f => + f.id === frame.id ? { ...f, upscaleStatus: 'processing' } : f + )); + + try { + const response = await modulesApi.upscaleImage({ + asset_id: frame.id, + scale, + model, + denoise_strength: denoiseStrength, + sharpen, + }); + + const job = response.data; + + addJob({ + id: job.id, + module: 'image_upscaling', + status: job.status, + progress: job.progress, + created_at: job.created_at, + }); + + setExtractedFrames(prev => prev.map(f => + f.id === frame.id ? { ...f, upscaleJobId: job.id } : f + )); + + // Poll for completion + pollJob(frame.id, job.id); + toast.success('Upscaling started'); + + } catch (err: any) { + console.error(err); + toast.error('Upscale failed: ' + (err.message || 'Unknown error')); + setExtractedFrames(prev => prev.map(f => + f.id === frame.id ? { ...f, upscaleStatus: 'error', upscaleError: err.message } : f + )); } }; + const pollJob = async (frameId: string, jobId: string) => { + let currentJob: any; + const interval = setInterval(async () => { + try { + const res = await jobsApi.get(jobId); + currentJob = res.data; + + if (currentJob.status === 'completed' || currentJob.status === 'failed') { + clearInterval(interval); + + if (currentJob.status === 'completed' && currentJob.output_asset_ids?.[0]) { + setExtractedFrames(prev => prev.map(f => + f.id === frameId ? { + ...f, + upscaleStatus: 'completed', + upscaleResultId: currentJob.output_asset_ids[0] + } : f + )); + toast.success('Upscale complete!'); + } else { + setExtractedFrames(prev => prev.map(f => + f.id === frameId ? { + ...f, + upscaleStatus: 'error', + upscaleError: currentJob.error_message || 'Job failed' + } : f + )); + } + } + } catch (err) { + console.error("Polling error", err); + clearInterval(interval); + } + }, 2000); + }; + + const [previewAssetId, setPreviewAssetId] = useState(null); + return ( -
+
+ {/* Header */}
-

Frame Extractor

-

Extract high-quality frames from video for upscaling

+

Frame Extractor & Upscale

+

Extract frames and upscale them instantly using Topaz AI

-
- {/* Main Area - Video Player */} +
+ {/* Left Column - Video Player (2 cols) */}
{!asset ? (
@@ -174,8 +301,93 @@ export default function FrameExtractorPage() { )}
- {/* Sidebar - Extracted Frames */} -
+ {/* Middle Column - Upscale Settings (1 col) */} +
+
+

+ + Upscale Settings +

+ + {/* Scale */} +
+ +
+ {scaleOptions.map((option) => ( + + ))} +
+
+ + {/* Model */} +
+ + +
+ + {/* Denoise */} +
+ + ) => setDenoiseStrength(parseFloat(e.target.value))} + className="w-full accent-forge-yellow" + /> +
+ + {/* Sharpen */} +
+ + ) => setSharpen(parseFloat(e.target.value))} + className="w-full accent-forge-yellow" + /> +
+
+ +
+

Configure settings above, then click "Upscale" on any extracted frame.

+
+
+ + {/* Right Column - Extracted Frames (1 col) */} +

@@ -196,29 +408,91 @@ export default function FrameExtractorPage() {
{formatTime(frame.asset_metadata?.timestamp || 0)}
+ + {/* Status Overlays */} + {frame.upscaleStatus === 'processing' && ( +
+ + Upscaling... +
+ )} + {frame.upscaleStatus === 'completed' && ( +
+ Upscaled +
+ )}

+
+ {frame.upscaleStatus === 'completed' && frame.upscaleResultId ? ( +
+ + +
+ ) : ( + + )} + -
+ + {frame.upscaleStatus === 'error' && ( +
+ Error: {frame.upscaleError} +
+ )}
)) )}
+ + {/* Preview Modal */} + {previewAssetId && ( +
setPreviewAssetId(null)} + > +
e.stopPropagation()}> + Preview + +
+
+ )}
); } diff --git a/frontend/app/video/generate/page.tsx b/frontend/app/video/generate/page.tsx index b253eac..5cf58a4 100644 --- a/frontend/app/video/generate/page.tsx +++ b/frontend/app/video/generate/page.tsx @@ -41,6 +41,7 @@ export default function VideoGeneratePage() { const [lastFramePreview, setLastFramePreview] = useState(null); const [referencePreviews, setReferencePreviews] = useState([]); const [inputPreview, setInputPreview] = useState(null); + const [inputFilename, setInputFilename] = useState(null); // Asset library modal const [showAssetLibrary, setShowAssetLibrary] = useState(false); @@ -197,6 +198,7 @@ export default function VideoGeneratePage() { case 'input': setAssetId(asset.id); setInputPreview(thumbnailUrl || `/api/v1/assets/${asset.id}/download`); + setInputFilename(asset.original_filename || asset.filename); setFile(null); break; case 'first': @@ -406,9 +408,10 @@ export default function VideoGeneratePage() { Selected
-

Using selected asset from library

+

{inputFilename || 'Selected Asset'}

+

Selected from My Files

- +
)}
@@ -475,6 +478,19 @@ export default function VideoGeneratePage() {
) : ( - Select from My Files +
+ + Select or Drop Frame +
)}
@@ -501,6 +520,19 @@ export default function VideoGeneratePage() { ) : ( - Select from My Files +
+ + Select or Drop Frame +
)} @@ -528,18 +563,35 @@ export default function VideoGeneratePage() { - + + {referenceAssetIds.length > 0 && (
{referenceAssetIds.map((id, index) => ( -
- {referencePreviews[index] && } +
+ {referencePreviews[index] && } - + {index + 1}
@@ -613,6 +665,8 @@ export default function VideoGeneratePage() { isOpen={showAssetLibrary} onSelect={handleAssetSelect} onClose={() => setShowAssetLibrary(false)} + fileTypes={['image']} + title="Select Image" />
); diff --git a/frontend/components/AssetLibrary.tsx b/frontend/components/AssetLibrary.tsx index cc3ead1..d36501c 100644 --- a/frontend/components/AssetLibrary.tsx +++ b/frontend/components/AssetLibrary.tsx @@ -226,8 +226,8 @@ export default function AssetLibrary({ fileTypes.includes('image') ? { 'image/*': ['.png', '.jpg', '.jpeg', '.webp', '.gif'] } : fileTypes.includes('video') - ? { 'video/*': ['.mp4', '.webm', '.mov'] } - : { 'audio/*': ['.mp3', '.wav', '.ogg'] } + ? { 'video/*': ['.mp4', '.webm', '.mov'] } + : { 'audio/*': ['.mp3', '.wav', '.ogg'] } } label="Drop file here or click to upload" /> @@ -258,6 +258,15 @@ export default function AssetLibrary({ const isSelected = selectedAssets.has(asset.id); const Icon = FILE_TYPE_ICONS[asset.file_type as keyof typeof FILE_TYPE_ICONS] || FileText; + // Determine preview URL + let previewUrl = null; + if (asset.thumbnail_url) { + previewUrl = `${process.env.NEXT_PUBLIC_API_URL}${asset.thumbnail_url}`; + } else if (asset.mime_type?.startsWith('image/')) { + // Fallback to direct download for images without thumbnails + previewUrl = `/api/v1/assets/${asset.id}/download`; + } + return (