diff --git a/.gitignore b/.gitignore index 03f4d1e..62e2a43 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/backend/app/api/v1/assets.py b/backend/app/api/v1/assets.py index 7d2402d..58d6efe 100644 --- a/backend/app/api/v1/assets.py +++ b/backend/app/api/v1/assets.py @@ -186,11 +186,45 @@ async def upload_asset( file: UploadFile = File(...), project_id: Optional[str] = Form(None), source_module: Optional[str] = Form(None), + is_temporary: bool = Form(False), + overwrite: bool = Form(False), db: Session = Depends(get_db) ): """Upload a new asset""" # Get test user user = db.query(User).filter(User.email == "test@forge.ai").first() + + # Check for duplicates if not temporary + if not is_temporary and user: + existing = db.query(Asset).filter( + Asset.user_id == user.id, + Asset.original_filename == file.filename, + Asset.is_temporary == False + ).first() + + if existing: + if not overwrite: + # Return conflict with existing ID + # We interpret 409 specially in frontend + raise HTTPException( + status_code=409, + detail={"message": "File exists", "asset_id": str(existing.id)} + ) + else: + # Overwrite: Delete existing file and record + if os.path.exists(existing.file_path): + try: + os.remove(existing.file_path) + except OSError: + pass + if existing.thumbnail_path and os.path.exists(existing.thumbnail_path): + try: + os.remove(existing.thumbnail_path) + except OSError: + pass + + db.delete(existing) + db.commit() # Determine file type file_type = get_file_type(file.content_type) @@ -251,7 +285,9 @@ async def upload_asset( width=width, height=height, duration_seconds=duration_seconds, - source_module=source_module + + source_module=source_module, + is_temporary=is_temporary ) db.add(asset) diff --git a/backend/app/api/v1/modules.py b/backend/app/api/v1/modules.py index 3523ac6..5d4ed76 100644 --- a/backend/app/api/v1/modules.py +++ b/backend/app/api/v1/modules.py @@ -142,14 +142,20 @@ class ImageUpscaleRequest(BaseModel): model: str = "Standard V2" output_format: str = "png" crop_to_fill: bool = False - # Face enhancement parameters + + # Face enhancement face_enhancement: bool = False face_enhancement_creativity: Optional[float] = None face_enhancement_strength: Optional[float] = None - # Model-specific parameters - detail: Optional[float] = None # For Super Focus V2 (0-1) - focus_boost: Optional[float] = None # For Super Focus V2 (0.25-1) - strength: Optional[float] = None # For upscaling models (0.01-1) + + # Frontend matches + denoise_strength: Optional[float] = None + sharpen: Optional[float] = None + + # Legacy / Other params + detail: Optional[float] = None + focus_boost: Optional[float] = None + strength: Optional[float] = None subject_detection: Optional[str] = None @@ -164,9 +170,15 @@ class VideoUpscaleRequest(BaseModel): recover_detail: Optional[int] = None # 0-100 add_noise: Optional[int] = None # 0-100 video_type: Optional[str] = "Progressive" # Progressive, Interlaced, Interlaced Progressive + video_type: Optional[str] = "Progressive" # Progressive, Interlaced, Interlaced Progressive face_enhancement: bool = False +class FrameExtractionRequest(BaseModel): + asset_id: str + timestamp: float + + class RemoveBackgroundRequest(BaseModel): asset_id: str output_format: str = "png" @@ -317,11 +329,14 @@ async def upscale_image( "scale": request.scale, "model": request.model, "face_enhancement": request.face_enhancement, - "noise_reduction": request.noise_reduction, - "sharpening": request.sharpening, - "compression_recovery": request.compression_recovery, - "detail_enhancement": request.detail_enhancement, - "preserve_grain": request.preserve_grain, + # Use new fields mapped from frontend + "denoise": request.denoise_strength, # Map denoise_strength -> denoise for backend service + "sharpen": request.sharpen, + + # Optional extra params + "face_enhancement_creativity": request.face_enhancement_creativity, + "face_enhancement_strength": request.face_enhancement_strength, + "output_format": request.output_format }, input_asset_ids=[asset.id], @@ -451,6 +466,24 @@ async def upscale_video( return job_response(job) +@router.post("/video/extract-frame") +async def extract_frame_endpoint( + request: FrameExtractionRequest, + db: Session = Depends(get_db) +): + """Extract a single frame from a video""" + from app.services import frame_extractor + try: + # Since extract_frame is sync (using subprocess), we can run it directly or in threadpool + # For simplicity in FastAPI, just calling it is fine if it's fast (< few sec). + # Topaz upscaler uses async + background tasks because it takes minutes. + # fast-seeking ffmpeg extract is usually < 1s. + new_asset = frame_extractor.extract_frame(request.asset_id, request.timestamp) + return new_asset + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + @router.get("/video/subtitles/config") async def get_subtitle_config(): """Get available subtitle configuration options""" diff --git a/backend/app/services/frame_extractor.py b/backend/app/services/frame_extractor.py new file mode 100644 index 0000000..030462b --- /dev/null +++ b/backend/app/services/frame_extractor.py @@ -0,0 +1,102 @@ + +import os +import subprocess +import logging +from uuid import uuid4 +from datetime import datetime + +from app.database import SessionLocal +from app.models.asset import Asset +from app.config import settings + +logger = logging.getLogger(__name__) + +def extract_frame(asset_id: str, timestamp: float): + """ + Extract a frame from a video asset at a specific timestamp. + """ + db = SessionLocal() + try: + # Get input asset + asset = db.query(Asset).filter(Asset.id == asset_id).first() + if not asset: + 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") + + # Generate output filename + # Format: {original_name}_frame_{timestamp}.png + base_name = os.path.splitext(asset.original_filename)[0] + # Clean timestamp format to be safe (replace . with -) + time_str = f"{timestamp:.3f}".replace('.', '-') + filename = f"{base_name}_frame_{time_str}_{uuid4().hex[:6]}.png" + + storage_path = os.path.join(settings.storage_path, "images") + os.makedirs(storage_path, exist_ok=True) + output_path = os.path.join(storage_path, filename) + + # Build ffmpeg command + # -ss before -i for faster seeking + # -vframes 1 to get one frame + # -q:v 2 for high quality jpg, but we want png so usually just default or compression level + # PNG is lossless by default in ffmpeg usually. + cmd = [ + 'ffmpeg', + '-y', # Overwrite + '-ss', str(timestamp), + '-i', asset.file_path, + '-vframes', '1', + output_path + ] + + logger.info(f"Extracting frame with command: {' '.join(cmd)}") + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + logger.error(f"FFmpeg failed: {result.stderr}") + raise ValueError(f"Frame extraction failed: {result.stderr}") + + if not os.path.exists(output_path): + raise ValueError("Output file was not created") + + # Get file size + file_size = os.path.getsize(output_path) + + # Get dimensions if possible (assume same as video or read it) + # We can use Pillow if installed, or just use input video dims + width = asset.width + height = asset.height + + # Determine mime type + mime_type = "image/png" + + # Create new asset + new_asset = Asset( + user_id=asset.user_id, + project_id=asset.project_id, + original_filename=filename, + stored_filename=filename, + file_path=output_path, + file_type="image", + mime_type=mime_type, + file_size_bytes=file_size, + width=width, + height=height, + source_module="frame_extractor", + parent_asset_id=asset.id, + asset_metadata={ + "source_video_id": asset.id, + "timestamp": timestamp + } + ) + + db.add(new_asset) + db.commit() + db.refresh(new_asset) + + return new_asset + + finally: + db.close() diff --git a/backend/app/services/image_generator.py b/backend/app/services/image_generator.py index a5422c1..a485c2f 100644 --- a/backend/app/services/image_generator.py +++ b/backend/app/services/image_generator.py @@ -523,33 +523,34 @@ async def _generate_leonardo(input_data: dict) -> tuple: } # Alchemy / PhotoReal Logic - # Note: Alchemy is incorrectly flagged by authorization hook if model doesn't support it - # OR if specific params (like contrast) are missing when it's on. - # Phoenix (the default) usually wants alchemy=True implicitly or handles it differently. - # However, older models crash if alchemy is True. - # Safest bet: Only enable if explicitly requested AND not Phoenix (which has its own pipeline), - # OR follow specific Phoenix guidelines. - # Actually, Phoenix is a "Platform Model" that might NOT use the Alchemy pipeline the same way legacy models did. - # Let's trust the input_data but default to False if not present to avoid 500s. + # Phoenix (de7d3faf...) does NOT support Alchemy or PhotoReal (it has its own pipeline). + # Sending 'alchemy': True with Phoenix causes "Invalid response from authorization hook" (500). + + is_phoenix = model_id == "de7d3faf-762f-48e0-b3b7-9d0ac3a3fcf3" alchemy = input_data.get("alchemy", False) photo_real = input_data.get("photo_real", False) + if is_phoenix: + # Force disable legacy features for Phoenix + alchemy = False + photo_real = False + # Phoenix might support 'elements' or other new params, but definitely not legacy alchemy. + if alchemy: payload["alchemy"] = True - # Alchemy requires high contrast usually? payload["contrastRatio"] = input_data.get("contrast_ratio", 0.5) if photo_real: payload["photoReal"] = True payload["photoRealStrength"] = input_data.get("photo_real_strength", 0.5) - # PhotoReal V2 requires distinct setup, usually no modelId? - # Docs say: "When photoReal is true, modelId is ignored" (for v1) - # But for V2 (Phoenix?), it might be different. - # For now, if PhotoReal is on, we remove modelId to rely on system default for PhotoReal. + # If PhotoReal is on, we remove modelId to rely on system default for PhotoReal. if "modelId" in payload: del payload["modelId"] + # Log payload for debugging + logger.info(f"Leonardo Payload (Model: {model_id}): {payload}") + if input_data.get("preset_style") and input_data.get("preset_style") != "NONE": payload["presetStyle"] = input_data.get("preset_style") @@ -881,18 +882,21 @@ async def _generate_nano_banana(input_data: dict, image_data: Optional[bytes] = url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent" # Build payload with text and optional image - parts = [{"text": prompt}] + # Build payload with image first (context) then text (instruction) + parts = [] if image_data: import base64 b64_image = base64.b64encode(image_data).decode("utf-8") parts.append({ "inlineData": { - "mimeType": "image/png", # Assuming PNG for now, ideally detect from input + "mimeType": "image/png", "data": b64_image } }) + logger.info(f"Nano Banana: Added reference image ({len(image_data)} bytes) to payload") + parts.append({"text": prompt}) payload = { "contents": [{ "parts": parts diff --git a/backend/app/services/image_upscaler.py b/backend/app/services/image_upscaler.py index 0a86d49..9809ebb 100644 --- a/backend/app/services/image_upscaler.py +++ b/backend/app/services/image_upscaler.py @@ -28,6 +28,9 @@ 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__) # Topaz enhancement models with their specialties @@ -148,44 +151,23 @@ async def upscale(job_id: str): job.progress = 20 db.commit() - # Build enhancement parameters with ALL supported Topaz features + # Build enhancement parameters matching simple working PHP version enhance_params: Dict[str, Any] = { "output_height": str(output_height), - "output_width": str(output_width), "output_format": output_format if output_format in ["jpeg", "jpg", "png", "tiff", "tif"] else "png", - "model": model, - "crop_to_fill": str(input_data.get("crop_to_fill", False)).lower() + "crop_to_fill": "true" if input_data.get("crop_to_fill") else "false", + "face_enhancement": "true" if input_data.get("face_enhancement") else "false" } - - # Face enhancement - if input_data.get("face_enhancement"): - enhance_params["face_enhancement"] = "true" - if input_data.get("face_enhancement_creativity") is not None: - enhance_params["face_enhancement_creativity"] = str(input_data.get("face_enhancement_creativity")) - if input_data.get("face_enhancement_strength") is not None: - enhance_params["face_enhancement_strength"] = str(input_data.get("face_enhancement_strength")) - - # Model-specific parameters - # Model-specific parameters - if input_data.get("detail") is not None: - enhance_params["detail"] = str(input_data.get("detail")) - if input_data.get("sharpening") is not None or input_data.get("sharpen") is not None: - enhance_params["sharpen"] = str(input_data.get("sharpening") or input_data.get("sharpen")) - if input_data.get("noise_reduction") is not None: - enhance_params["denoise"] = str(input_data.get("noise_reduction")) - if input_data.get("denoise_strength") is not None: - enhance_params["denoise"] = str(input_data.get("denoise_strength")) - - # Subject detection defaults to NONE if not specified, or API might handle it. - if input_data.get("subject_detection"): - enhance_params["subject_detection"] = input_data.get("subject_detection") - - # Handle 'auto' model by defaulting to Standard V2 if not specified - if model.lower() == "auto": - enhance_params["model"] = "Standard V2" - else: + + # Handle 'auto' model: simplest is to OMIT parameter if auto + if model.lower() != "auto": enhance_params["model"] = model + # Strict PHP parity for reliability: + # process.php ONLY sent: image, output_height, output_format, crop_to_fill, face_enhancement, model. + # It did NOT send denoise, sharpen, creativity, etc. + # To avoid 422 errors or timeouts, we omit them until verified. + # Call Topaz API async with httpx.AsyncClient(timeout=600) as client: # Start async enhancement @@ -201,7 +183,7 @@ async def upscale(job_id: str): response.raise_for_status() result = response.json() - request_id = result.get("id") or result.get("requestId") + request_id = result.get("id") or result.get("requestId") or result.get("process_id") job.progress = 40 job.api_request_id = request_id @@ -209,18 +191,30 @@ async def upscale(job_id: str): # Poll for completion output_url = None - for i in range(180): # Wait up to 6 minutes for large upscales - await asyncio.sleep(2) + polling_interval = 2 + max_attempts = 180 + + 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 + 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 @@ -228,10 +222,19 @@ async def upscale(job_id: str): if output_url: break elif topaz_status == "failed": - raise ValueError(f"Topaz enhancement failed: {status_data.get('error')}") + error_msg = status_data.get("error") or "Unknown error" + raise ValueError(f"Topaz enhancement failed: {error_msg}") - job.progress = min(40 + (i * 0.28), 85) - db.commit() + # 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() if output_url: logger.info(f"Topaz output URL received: {output_url[:100] if output_url else 'None'}") @@ -250,7 +253,18 @@ async def upscale(job_id: str): mime = mime_map.get(output_format, "image/png") # Save output - filename = f"upscaled_{scale}x_{model}_{uuid4()}{ext}" + # 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) diff --git a/backend/app/services/markdown_tools.py b/backend/app/services/markdown_tools.py index 4bc1f73..875cec1 100644 --- a/backend/app/services/markdown_tools.py +++ b/backend/app/services/markdown_tools.py @@ -203,8 +203,36 @@ async def render_mermaid( # Add theme parameter params = [] - if theme != "default": + if theme == "forge": + # Inject Forge theme directive if not present + forge_theme_config = """%%{ + init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#FFC407', + 'primaryTextColor': '#000000', + 'primaryBorderColor': '#FFC407', + 'lineColor': '#FFC407', + 'secondaryColor': '#ffffff', + 'tertiaryColor': '#ffffff' + } + } +}%% +""" + if not code.strip().startswith("%%{"): + code = forge_theme_config + code + # Re-encode with new code + encoded = base64.urlsafe_b64encode(code.encode()).decode() + # Re-build URL + if output_format == "svg": + url = f"{base_url}/svg/{encoded}" + else: + url = f"{base_url}/img/{encoded}" + + # Don't pass theme param, rely on directive + elif theme != "default": params.append(f"theme={theme}") + if background != "transparent": params.append(f"bgColor={background.replace('#', '')}") diff --git a/backend/app/services/video_upscaler.py b/backend/app/services/video_upscaler.py index a514952..aa9a5ca 100644 --- a/backend/app/services/video_upscaler.py +++ b/backend/app/services/video_upscaler.py @@ -9,6 +9,9 @@ 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__) # Topaz Video AI Models Mapping VIDEO_MODELS = { @@ -106,42 +109,44 @@ async def upscale(job_id: str): # Logic: If face_enhancement is True, strictly use 'iris-2' (Iris). # Otherwise, lookup model in VIDEO_MODELS. If not found, default to 'prob-4' (Proteus). + # Build filters logic matching PHP structure exactly (from estimate.php) + selected_model_code = "prob-4" if face_enhancement: selected_model_code = "iris-2" else: selected_model_code = VIDEO_MODELS.get(model, "prob-4") + # PHP used "Progressive" (User's working code) + video_type_val = video_type.capitalize() if video_type else "Progressive" + enhance_filter = { "model": selected_model_code, - "videoType": video_type.lower(), # Ensure lowercase "progressive", "interlaced" + "videoType": video_type_val, + "auto": "Auto", + "fieldOrder": "Auto", # Added from estimate.php + "focusFixLevel": "None", # Added from estimate.php + "blur": 0.0, # Default from estimate.php + "grain": 0.0, # Default from estimate.php + "grainSize": 1.5, # Default from estimate.php + "recoverOriginalDetailValue": 0.2 # Default from estimate.php } - # Add optional parameters to enhancement filter - # Mapping based on API Schema: - # - sharpening -> details (Enhances details/sharpness) - # - recover_detail -> recoverOriginalDetailValue (Matches 'Recover Original Detail') - # - add_noise -> noise (Matches 'Add Noise') - - # Note: Values typically 0-100 or 0-1 depending on parameter? - # Browser findings showed examples like '0.1'. - # Frontend sends 0-100 integers. We should likely convert to float 0.0-1.0 if stats imply, - # BUT Proteus manual parameters in app are 0-100. - # Let's assume 0-100 integers are fine for `details` etc if they match app. - # However, `noise` in encoding is often valid. - # Let's stick to passing the int logic for now, or scaled if error persists. - # User error was 400 Bad Request (Invalid Payload). - + # Override defaults with inputs if present if sharpening is not None: - enhance_filter["details"] = int(sharpening) + # Note: estimate.php doesn't map 'sharpening' to 'details', it uses 'recoverOriginalDetailValue' + # But typically 'details' is the param. Let's stick to valid defaults from PHP first. + # Actually estimate.php uses $_POST['recoverDetail'] -> recoverOriginalDetailValue. + pass + if recover_detail is not None: - enhance_filter["recoverOriginalDetailValue"] = int(recover_detail) - if add_noise is not None: - enhance_filter["noise"] = int(add_noise) + enhance_filter["recoverOriginalDetailValue"] = int(recover_detail) / 100.0 if int(recover_detail) > 1 else int(recover_detail) + + # Map other UI sliders if we want, but let's stick to working PHP defaults + Model for now to FIX it. filters.append(enhance_filter) - # Create video enhancement request + # Create video enhancement request - match PHP keys layout payload = { "source": video_info, "filters": filters, @@ -153,11 +158,18 @@ async def upscale(job_id: str): "frameRate": target_fps, "audioCodec": "AAC", "audioTransfer": "Copy", + + # Added missing fields from estimate.php + "videoEncoder": "H265", + "videoBitrate": "6000k", + "videoProfile": "Main", + "cropToFit": False, "container": "mp4" } } print(f"DEBUG: Topaz Video Payload: {payload}") + # Revert to root /video endpoint as /v1 returned 404 response = await client.post( "https://api.topazlabs.com/video/", headers={ diff --git a/backend/debug_topaz.py b/backend/debug_topaz.py deleted file mode 100644 index 9002fa8..0000000 --- a/backend/debug_topaz.py +++ /dev/null @@ -1,90 +0,0 @@ - -import asyncio -import os -import sys -import json -from unittest.mock import MagicMock, patch - -# Mock settings and imports -sys.path.append(os.path.dirname(os.path.abspath(__file__))) - -# Mock database and models to avoid dependency issues -sys.modules['app.database'] = MagicMock() -sys.modules['app.models.job'] = MagicMock() -sys.modules['app.models.asset'] = MagicMock() - -# Import services after mocking -from app.services.video_upscaler import VIDEO_MODELS -# Image upscaler we can import 'upscale' or just check models -from app.services.image_upscaler import TOPAZ_MODELS - -async def debug_topaz_video_payload(): - print("--- Debugging Topaz Video Payload ---") - - # Simulate user inputs - model_name = "Proteus" - model_code = VIDEO_MODELS.get(model_name, "prob-4") - - params = { - "sharpening": 50, - "recover_detail": 20, - "add_noise": 10, - "video_type": "Progressive" - } - - # REPLICATED LOGIC FROM video_upscaler.py - # --------------------------------------- - enhance_filter_payload = { - "model": model_code, - "videoType": params["video_type"].lower(), - } - - if params["sharpening"] is not None: - enhance_filter_payload["details"] = int(params["sharpening"]) - if params["recover_detail"] is not None: - enhance_filter_payload["recoverOriginalDetailValue"] = int(params["recover_detail"]) - if params["add_noise"] is not None: - enhance_filter_payload["noise"] = int(params["add_noise"]) - - # Full payload simulation - filters = [] - filters.append(enhance_filter_payload) - - payload = { - "source": {"container": "mp4", "duration": 10}, # Mock source - "filters": filters, - "output": { - "resolution": {"width": 3840, "height": 2160}, - "frameRate": 30, - "container": "mp4" - } - } - # --------------------------------------- - - print(f"Generated Video Payload: {json.dumps(payload, indent=2)}") - - # Validation - if "sharpen" in enhance_filter_payload: - print("FAIL: 'sharpen' key found (forbidden)") - if "recoverDetail" in enhance_filter_payload: - print("FAIL: 'recoverDetail' key found (forbidden)") - if enhance_filter_payload["videoType"] != "progressive": - print(f"FAIL: videoType not lowercase: {enhance_filter_payload['videoType']}") - - print("---------------------------------------") - -async def debug_topaz_image_setup(): - print("--- Debugging Topaz Image Setup ---") - - try: - import PIL - print("Pillow (PIL) is installed.") - except ImportError: - print("FAIL: Pillow (PIL) is NOT installed. This will cause 'Failed to start' errors.") - - print(f"Loaded {len(TOPAZ_MODELS)} Topaz Image models.") - - -if __name__ == "__main__": - asyncio.run(debug_topaz_video_payload()) - asyncio.run(debug_topaz_image_setup()) diff --git a/backend/debug_topaz_real.py b/backend/debug_topaz_real.py new file mode 100644 index 0000000..71f17ac --- /dev/null +++ b/backend/debug_topaz_real.py @@ -0,0 +1,135 @@ + +import asyncio +import httpx +import os +import json + +TOPAZ_API_KEY = "5af61151-913b-4c58-b842-dc52e2913800" + +async def test_image_upscale(): + print("\n--- Testing Image Upscale ---") + + # Payload matching PHP exactly + # PHP: + # 'output_height' => $output_height, + # 'output_format' => 'jpeg', + # 'crop_to_fill' => 'false', + # 'face_enhancement' => 'false' (if off) + # 'model' => send only if not Auto. + + params = { + "output_height": "1000", + "output_format": "jpeg", + "crop_to_fill": "false", + "face_enhancement": "false" + } + + # In PHP: curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields) where $postFields has file object. + + files = { + "image": ("test_image.png", open("test_image.png", "rb"), "image/png") + } + + async with httpx.AsyncClient() as client: + try: + print(f"Sending POST to https://api.topazlabs.com/image/v1/enhance/async") + print(f"Params: {params}") + + response = await client.post( + "https://api.topazlabs.com/image/v1/enhance/async", + headers={ + "X-API-Key": TOPAZ_API_KEY, + "Accept": "application/json" + }, + files=files, + data=params + ) + print(f"Status: {response.status_code}") + print(f"Response: {response.text}") + except Exception as e: + print(f"Error: {e}") + +async def test_video_upscale(): + print("\n--- Testing Video Upscale (Creation Request) ---") + + # PHP Payload: + # { + # "source": { ... }, + # "filters": [ { "model": "prob-4", "videoType": "Progressive", "auto": "Auto" } ], + # "output": { ... } + # } + + payload = { + "source": { + "container": "mp4", + "size": 1024, + "duration": 10, + "frameCount": 300, + "frameRate": 30, + "resolution": { + "width": 1920, + "height": 1080 + } + }, + "filters": [ + { + "model": "prob-4", + "videoType": "Progressive", + "auto": "Auto" + } + ], + "output": { + "resolution": { + "width": 3840, + "height": 2160 + }, + "frameRate": 30, + "audioCodec": "AAC", + "audioTransfer": "Copy", + "container": "mp4", + "videoBitrate": "6000k" + } + } + + async with httpx.AsyncClient() as client: + try: + # Trying the URL we suspect is correct: /video/ + url = "https://api.topazlabs.com/video/" + print(f"Sending POST to {url}") + print(f"Payload: {json.dumps(payload, indent=2)}") + + response = await client.post( + url, + headers={ + "X-API-Key": TOPAZ_API_KEY, + "Content-Type": "application/json", + "Accept": "application/json" + }, + json=payload + ) + print(f"Status: {response.status_code}") + print(f"Response: {response.text}") + + if response.status_code == 201 or response.status_code == 200: + data = response.json() + # Check for various ID keys + request_id = data.get("id") or data.get("requestId") or data.get("process_id") + print(f"Got Request ID: {request_id}") + + if request_id: + # Test ACCEPT endpoint with v1 + accept_url = f"https://api.topazlabs.com/video/{request_id}/accept" + print(f"Testing ACCEPT at: {accept_url}") + accept_response = await client.patch( + accept_url, + headers={"X-API-Key": TOPAZ_API_KEY} + ) + print(f"Accept Status: {accept_response.status_code}") + print(f"Accept Response: {accept_response.text}") + + except Exception as e: + print(f"Error: {e}") + +if __name__ == "__main__": + asyncio.run(test_image_upscale()) + asyncio.run(test_video_upscale()) diff --git a/backend/test_image.jpg b/backend/test_image.jpg new file mode 100644 index 0000000..266d7b4 --- /dev/null +++ b/backend/test_image.jpg @@ -0,0 +1 @@ +fake image content diff --git a/backend/test_image.png b/backend/test_image.png new file mode 100644 index 0000000..989b634 Binary files /dev/null and b/backend/test_image.png differ diff --git a/frontend/app/audio/voice-to-text/page.tsx b/frontend/app/audio/voice-to-text/page.tsx index aa12b23..64e18fd 100644 --- a/frontend/app/audio/voice-to-text/page.tsx +++ b/frontend/app/audio/voice-to-text/page.tsx @@ -49,9 +49,30 @@ export default function VoiceToTextPage() { const response = await assetsApi.upload(uploadedFile); setAssetId(response.data.id); toast.success('Audio uploaded!'); - } catch (err) { - toast.error('Failed to upload audio'); - setFile(null); + } catch (err: any) { + if (err.response?.status === 409) { + const existingAssetId = err.response.data.detail.asset_id; + const shouldOverwrite = window.confirm( + `File "${uploadedFile.name}" already exists. \nClick OK to Overwrite, Cancel to Use Existing.` + ); + + if (shouldOverwrite) { + try { + const response = await assetsApi.upload(uploadedFile, undefined, false, true); // overwrite=true + setAssetId(response.data.id); + toast.success('Audio overwritten!'); + } catch (retryErr: any) { + toast.error('Failed to overwrite audio'); + setFile(null); + } + } else { + setAssetId(existingAssetId); + toast('Using existing file', { icon: 'ℹ️' }); + } + } else { + toast.error('Failed to upload audio'); + setFile(null); + } } finally { setUploading(false); } @@ -98,11 +119,11 @@ export default function VoiceToTextPage() { if (job.output_data) { const assets = job.output_asset_ids ? await Promise.all( - job.output_asset_ids.map(async (id: string) => { - const asset = await assetsApi.get(id); - return asset.data; - }) - ) + job.output_asset_ids.map(async (id: string) => { + const asset = await assetsApi.get(id); + return asset.data; + }) + ) : []; setResults({ @@ -183,7 +204,7 @@ export default function VoiceToTextPage() { setTargetLanguage(e.target.value)} + onChange={(e: React.ChangeEvent) => setTargetLanguage(e.target.value)} className="select-field" > {targetLanguages.filter((l) => l.value).map((lang) => ( diff --git a/frontend/app/files/page.tsx b/frontend/app/files/page.tsx index 5fcead8..8d3ea63 100644 --- a/frontend/app/files/page.tsx +++ b/frontend/app/files/page.tsx @@ -1,6 +1,8 @@ 'use client'; + import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { toast } from 'react-hot-toast'; import { FolderOpen, @@ -16,6 +18,9 @@ import { List, Loader2, Eye, + CheckSquare, + Square, + ChevronDown } from 'lucide-react'; import FileUpload from '@/components/FileUpload'; import api, { assetsApi } from '@/lib/api'; @@ -59,6 +64,8 @@ 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 router = useRouter(); useEffect(() => { loadAssets(); @@ -67,7 +74,7 @@ export default function MyFilesPage() { const loadAssets = async () => { setLoading(true); try { - const params: any = { page, limit: 24 }; + const params: Record = { page, limit: 24 }; if (selectedType) params.file_types = selectedType; if (search) params.search = search; @@ -88,8 +95,29 @@ export default function MyFilesPage() { toast.success('File uploaded!'); loadAssets(); setShowUpload(false); - } catch (error) { - toast.error('Failed to upload file'); + } catch (error: any) { + if (error.response?.status === 409) { + const shouldOverwrite = window.confirm( + `File "${file.name}" already exists. \nClick OK to Overwrite, Cancel to Keep Existing.` + ); + + if (shouldOverwrite) { + try { + await assetsApi.upload(file, undefined, false, true); // overwrite=true + toast.success('File overwritten!'); + loadAssets(); + setShowUpload(false); + } catch (retryError: any) { + toast.error('Failed to overwrite file'); + } + } else { + // User cancelled, treat as success or ignore + toast('Upload skipped (file exists)', { icon: 'ℹ️' }); + setShowUpload(false); + } + } else { + toast.error('Failed to upload file'); + } } finally { setUploading(false); } @@ -151,6 +179,91 @@ 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); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + const toggleAll = () => { + if (selectedIds.size === assets.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(assets.map(a => a.id))); + } + }; + + 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; + + const firstType = selectedAssets[0].file_type; + const allSame = selectedAssets.every(a => a.file_type === 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(','); + + switch (action) { + case 'download': + // Download sequentially to avoid browser blocking multiple popups + for (const id of selectedIds) { + 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) { + try { await assetsApi.delete(id); } catch (e) { } + } + toast.success('Files deleted'); + setSelectedIds(new Set()); + loadAssets(); + } + break; + case 'upscale_image': + router.push(`/image/upscale?assetIds=${ids}`); + break; + case 'remove_bg': + router.push(`/image/remove-bg?assetIds=${ids}`); + break; + case 'upscale_video': + router.push(`/video/upscale?assetIds=${ids}`); + break; + case 'subtitles': + router.push(`/video/subtitles?assetIds=${ids}`); + break; + case 'transcribe': + router.push(`/audio/voice-to-text?assetIds=${ids}`); + break; + case 'alt_text': + router.push(`/text/alt-text?assetIds=${ids}`); + break; + case 'img_to_video': + router.push(`/video/generate?assetIds=${ids}`); + break; + case 'extract_frame': + router.push(`/video/extract?assetIds=${ids}`); + break; + } + }; + + const [showActionMenu, setShowActionMenu] = useState(false); + return (
{/* Header */} @@ -203,7 +316,7 @@ export default function MyFilesPage() { type="text" placeholder="Search files..." value={search} - onChange={(e) => { + onChange={(e: React.ChangeEvent) => { setSearch(e.target.value); setPage(1); }} @@ -273,6 +386,79 @@ export default function MyFilesPage() {
+ {/* Batch Actions Toolbar */} + {selectedIds.size > 0 && ( +
+
+ {selectedIds.size} Selected +
+ + + +
+ +
+ + {/* App Actions Dropdown */} + {getCommonFileType() !== 'mixed' && getCommonFileType() !== null && ( +
+ + + {showActionMenu && ( + <> + {/* Backdrop to close menu */} +
setShowActionMenu(false)} /> + +
+ {getCommonFileType() === 'image' && ( + <> + + +
+ + + + )} + {getCommonFileType() === 'video' && ( + <> + + +
+ + + )} + {getCommonFileType() === 'audio' && ( + + )} +
+ + )} +
+ )} + +
+ + +
+ )} + {/* Content */} {loading ? (
@@ -297,15 +483,34 @@ export default function MyFilesPage() { > {/* Thumbnail */}
setSelectedAsset(asset)} + className={`aspect-square relative cursor-pointer group/item ${selectedIds.has(asset.id) ? 'ring-2 ring-forge-yellow' : ''}`} + onClick={(e) => { + // If clicking the selection box or holding shift/ctrl (future), toggle selection + // If clicking the image itself, open preview? + // User wants clicking image to behave normally (preview), so maybe only toggle if clicking checkbox? + // OR: Main click is preview, selection is only via checkbox? + // BUT previous request said: "selection box on files... pick these 5" + // Let's make clicking the main area open preview, and the top-left area handles selection toggling + setSelectedAsset(asset); + }} > +
{ + e.stopPropagation(); + toggleSelection(asset.id); + }} + > +
+ {selectedIds.has(asset.id) && } +
+
{asset.thumbnail_url || asset.file_type === 'image' ? ( {asset.filename} { + onError={(e: React.SyntheticEvent) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> @@ -318,7 +523,7 @@ export default function MyFilesPage() { {/* Hover Actions */}
{asset.filename}
@@ -389,13 +597,13 @@ export default function MyFilesPage() { )} + +
-
- {/* Controls */} -
- {/* File Upload */} +
+ {/* Controls - Left Column */} +
{ - setFile(null); - setAssetId(null); - }} - label="Upload an image" + label="Upload images (Multiple allowed)" + multiple={true} /> - {uploading && ( -

Uploading...

- )}
- {/* Output Format */}
- {/* Refine Mask */}
setRefineMask(e.target.checked)} + onChange={(e: React.ChangeEvent) => setRefineMask(e.target.checked)} className="w-4 h-4 rounded border-gray-600 bg-forge-dark text-forge-yellow focus:ring-forge-yellow" /> -
- {/* Remove Background Button */} - - - {/* Job Progress */} - {jobId && loading && ( - + {queue.length > 0 && ( +
+ + +
+ {queue.filter(i => i.status === 'completed').length} / {queue.length} completed +
+
)}
- {/* Results */} -
-

Result

- {resultImage ? ( -
-
- Result -
-
-
-
-

{resultImage.original_filename}

-

- {(resultImage.file_size_bytes / 1024).toFixed(1)} KB -

-
- -
-
+ {/* Results / Queue - Right Column */} +
+

Queue

+ + {queue.length === 0 ? ( +
+ +

Queue is empty. Upload images to start.

) : ( -
-

- Result will appear here -

+
+ {queue.map(item => ( +
+
+ {/* Show source image if available (file or if we fetched asset details, but simpler to just show file or placeholder for now if from URL) */} + {item.file ? ( + source + ) : item.assetId ? ( + source + ) : ( +
No Img
+ )} +
+ +
+
+

+ {item.originalFileName || 'Asset ID: ' + item.assetId} +

+ +
+ +
+ {/* Status Badges */} + {item.status === 'pending' && Pending} + {item.status === 'uploading' && Uploading...} + {item.status === 'processing' && Processing...} + {item.status === 'error' && Error: {item.error}} + {item.status === 'completed' && Completed} +
+
+ + {/* Result Preview (if completed) */} + {item.status === 'completed' && item.resultAssetId && ( +
+ result +
+ +
+
+ )} +
+ ))}
)}
+
); } diff --git a/frontend/app/image/upscale/page.tsx b/frontend/app/image/upscale/page.tsx index 186dcf3..ca797d6 100644 --- a/frontend/app/image/upscale/page.tsx +++ b/frontend/app/image/upscale/page.tsx @@ -1,11 +1,11 @@ 'use client'; import { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'react-hot-toast'; -import { Maximize, Download, Sparkles } from 'lucide-react'; +import { Maximize, Download, Sparkles, Trash2, RefreshCw } from 'lucide-react'; import FileUpload from '@/components/FileUpload'; -import JobProgress from '@/components/JobProgress'; -import { modulesApi, assetsApi } from '@/lib/api'; +import { modulesApi, assetsApi, jobsApi } from '@/lib/api'; import { useStore } from '@/lib/store'; const scaleOptions = [ @@ -21,56 +21,125 @@ const modelOptions = [ { value: 'CGI', label: 'CGI' }, { value: 'Text Refine', label: 'Text Refine' }, { value: 'Enhance Generative', label: 'Enhance Generative' }, + { value: 'Auto', label: 'Auto' }, ]; +interface QueueItem { + id: string; + file?: File; + assetId?: string; + filename?: string; // fallback for display if file is missing + jobId?: string; + outputAssetId?: string; + status: 'pending' | 'uploading' | 'processing' | 'completed' | 'error'; + error?: string; + result?: any; +} + export default function ImageUpscalePage() { + const router = useRouter(); + const searchParams = useSearchParams(); const { addJob, updateJob } = useStore(); const [mounted, setMounted] = useState(false); - const [file, setFile] = useState(null); - const [assetId, setAssetId] = useState(null); + const [queue, setQueue] = useState([]); + const [processing, setProcessing] = useState(false); + + // Settings applied to the batch const [scale, setScale] = useState(2); - const [model, setModel] = useState('Standard V2'); + const [model, setModel] = useState('Auto'); const [denoiseStrength, setDenoiseStrength] = useState(0.5); const [sharpen, setSharpen] = useState(0.5); - const [jobId, setJobId] = useState(null); - const [upscaledImage, setUpscaledImage] = useState(null); - const [loading, setLoading] = useState(false); - const [uploading, setUploading] = useState(false); useEffect(() => { setMounted(true); }, []); + useEffect(() => { + // Check for 'assetIds' (batch) OR 'assetId' (legacy/single) in URL + const assetIdsParam = searchParams.get('assetIds'); + const singleAssetId = searchParams.get('assetId'); // Legacy support + + if ((assetIdsParam || singleAssetId) && assetsApi) { + const idsToFetch = assetIdsParam ? assetIdsParam.split(',') : [singleAssetId!]; + + // Fetch details for all IDs + Promise.all(idsToFetch.map(id => assetsApi.get(id))) + .then((responses) => { + setQueue(prev => { + // Avoid duplicates + const existingIds = new Set(prev.map(p => p.assetId)); + const newItems = responses + .map((r: any) => r.data) + .filter((asset: any) => !existingIds.has(asset.id)) + .map((asset: any) => ({ + id: Math.random().toString(36).substring(7), + assetId: asset.id, + status: 'pending' as const, + filename: asset.original_filename || asset.filename + })); + return [...prev, ...newItems]; + }); + + // Clean URL + router.replace('/image/upscale'); + if (idsToFetch.length > 0) toast.success(`${idsToFetch.length} assets added from library`); + }) + .catch(err => { + console.error("Failed to load assets", err); + toast.error("Failed to load some assets"); + }); + } + }, [searchParams]); + if (!mounted) { return null; } - const handleFileUpload = async (uploadedFile: File) => { - setFile(uploadedFile); - setUploading(true); - - try { - const response = await assetsApi.upload(uploadedFile); - setAssetId(response.data.id); - toast.success('Image uploaded!'); - } catch (err) { - toast.error('Failed to upload image'); - setFile(null); - } finally { - setUploading(false); - } + const handleFileUpload = (files: File[]) => { + const newItems: QueueItem[] = files.map(file => ({ + id: Math.random().toString(36).substring(7), + file, + status: 'pending' + })); + setQueue(prev => [...prev, ...newItems]); + toast.success(`${files.length} images added to queue`); }; - const handleUpscale = async () => { - if (!assetId) { - toast.error('Please upload an image first'); - return; - } - - setLoading(true); - setUpscaledImage(null); + const processItem = async (item: QueueItem) => { + if (item.status === 'completed' || item.status === 'processing') return item; try { + // 1. Upload if needed + let assetId = item.assetId; + if (!assetId) { + setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'uploading' } : i)); + if (!item.file) throw new Error("No file to upload"); + try { + const uploadRes = await assetsApi.upload(item.file); + assetId = uploadRes.data.id; + } catch (uploadErr: any) { + if (uploadErr.response?.status === 409) { + const existingAssetId = uploadErr.response.data.detail.asset_id; + const shouldOverwrite = window.confirm( + `File "${item.file?.name}" already exists. \nClick OK to Overwrite, Cancel to Use Existing.` + ); + + if (shouldOverwrite) { + const uploadRes = await assetsApi.upload(item.file, undefined, false, true); // overwrite=true + assetId = uploadRes.data.id; + } else { + assetId = existingAssetId; + } + } else { + throw uploadErr; + } + } + setQueue(prev => prev.map(i => i.id === item.id ? { ...i, assetId, status: 'pending' } : i)); + } + + // 2. Start Upscale Job + setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'processing' } : i)); + const response = await modulesApi.upscaleImage({ asset_id: assetId, scale, @@ -80,7 +149,7 @@ export default function ImageUpscalePage() { }); const job = response.data; - setJobId(job.id); + addJob({ id: job.id, module: 'image_upscaling', @@ -89,39 +158,77 @@ export default function ImageUpscalePage() { created_at: job.created_at, }); - toast.success('Upscaling started!'); + // Poll locally for this item + let currentJob = job; + while (currentJob.status !== 'completed' && currentJob.status !== 'failed') { + await new Promise(resolve => setTimeout(resolve, 2000)); + const pollRes = await jobsApi.get(job.id); + if (pollRes?.data) currentJob = pollRes.data; + else break; + } + + if (currentJob.status === 'completed' && currentJob.output_asset_ids?.[0]) { + // Fetch the output asset to get details + const assetRes = await assetsApi.get(currentJob.output_asset_ids[0]); + const outputAsset = assetRes.data; + + setQueue(prev => prev.map(i => i.id === item.id ? { + ...i, + status: 'completed', + jobId: job.id, + outputAssetId: outputAsset.id, + result: outputAsset + } : i)); + + return { ...item, status: 'completed', result: outputAsset }; + } else { + throw new Error(currentJob.error_message || 'Job failed'); + } + } catch (err: any) { - toast.error(err.response?.data?.detail || 'Failed to start upscaling'); - setLoading(false); + console.error(err); + const errorMsg = err.message || 'Failed'; + setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'error', error: errorMsg } : i)); + return { ...item, status: 'error', error: errorMsg }; } }; - const handleJobComplete = async (job: any) => { - setLoading(false); - updateJob(job.id, { status: 'completed', progress: 100 }); + const handleProcessQueue = async () => { + setProcessing(true); + const pending = queue.filter(i => i.status === 'pending' || i.status === 'error'); - if (job.output_asset_ids?.[0]) { - const asset = await assetsApi.get(job.output_asset_ids[0]); - setUpscaledImage(asset.data); - toast.success('Image upscaled successfully!'); + // Process strictly sequentially (concurrency 1) to avoid rate limits on Topaz + // Topaz can be touchy with concurrent heavy upscales + const limit = 1; + for (let i = 0; i < pending.length; i += limit) { + const chunk = pending.slice(i, i + limit); + await Promise.all(chunk.map(item => processItem(item))); } + + setProcessing(false); + toast.success('Batch processing complete'); }; - const handleJobError = (error: string) => { - setLoading(false); - toast.error(error); + const handleClearQueue = () => { + setQueue([]); }; - const handleDownload = async () => { - if (!upscaledImage) return; + const removeItem = (id: string) => { + setQueue(prev => prev.filter(i => i.id !== id)); + }; + + const handleDownload = async (item: QueueItem) => { + if (!item.result) return; try { - const response = await assetsApi.download(upscaledImage.id); - const url = window.URL.createObjectURL(response.data); - const a = document.createElement('a'); - a.href = url; - a.download = upscaledImage.original_filename; - a.click(); - window.URL.revokeObjectURL(url); + // Direct download link logic + const url = `/api/v1/assets/${item.result.id}/download`; + const link = document.createElement('a'); + link.href = url; + link.download = item.result.original_filename; // Browser should handle this + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (err) { toast.error('Failed to download image'); } @@ -135,159 +242,183 @@ export default function ImageUpscalePage() {

Image Upscaler

-

Enhance image resolution with Topaz Labs AI

+

Enhance multiple images with Topaz Labs AI

-
- {/* Controls */} -
- {/* File Upload */} -
- - { - setFile(null); - setAssetId(null); - }} - label="Upload an image to upscale" - /> - {uploading && ( -

Uploading...

- )} -
+
- {/* Scale */} -
- -
- {scaleOptions.map((option) => ( - - ))} + {/* Left Column: Settings */} +
+
+

Batch 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" + />
- - {/* 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" - /> -
- - {/* Upscale Button */} - - - {/* Job Progress */} - {jobId && loading && ( - - )}
- {/* Results */} -
-

Result

- {upscaledImage ? ( -
-
- Upscaled + {/* Right Column: Queue & Upload */} +
+ + {/* Upload Area */} +
+ +
+ + {/* Queue Actions */} + {queue.length > 0 && ( +
+
+ Queue: {queue.length} items ({queue.filter(i => i.status === 'completed').length} completed)
-
-
-
-

{upscaledImage.original_filename}

-

- {upscaledImage.width} x {upscaledImage.height} -

+
+ + +
+
+ )} + + {/* Queue List */} +
+ {queue.map(item => ( +
+ {/* Thumbnail */} +
+ thumb +
+ + {/* Info */} +
+

{item.file?.name || item.filename || 'Unknown File'}

+
+ {item.status === 'pending' && Waiting to start...} + {item.status === 'uploading' && Uploading original...} + {item.status === 'processing' && Upscaling...} + {item.status === 'completed' && Complete} + {item.status === 'error' && Error: {item.error}}
+
+ + {/* Actions */} +
+ {item.status === 'completed' && item.result && ( + + )}
-
- ) : ( -
-

Upscaled image will appear here

-
- )} + ))} + + {queue.length === 0 && !processing && ( +
+ Add images to start batch upscaling +
+ )} +
+
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 03d126a..c609eff 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -7,6 +7,11 @@ import AppShell from '@/components/AppShell'; export const metadata: Metadata = { title: 'FORGE AI - Creative Tools Platform', description: 'Unified AI-powered creative tools for image, video, and audio generation', + icons: { + icon: '/favicon.ico', + shortcut: '/THE_FORGE_LOGO.png', + apple: '/apple-touch-icon.png', + }, }; export default function RootLayout({ @@ -16,7 +21,7 @@ export default function RootLayout({ }) { return ( - + (null); - const [assetId, setAssetId] = useState(null); - const [jobId, setJobId] = useState(null); - const [results, setResults] = useState(null); - const [loading, setLoading] = useState(false); - const [uploading, setUploading] = useState(false); - const [copied, setCopied] = useState(null); + const [queue, setQueue] = useState([]); + const [processing, setProcessing] = useState(false); + const SEARCH_PARAMS = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : ''); - const handleFileUpload = async (uploadedFile: File) => { - setFile(uploadedFile); - setUploading(true); + // Handle URL params on mount + useState(() => { + if (typeof window !== 'undefined') { + const urlParams = new URLSearchParams(window.location.search); + const assetIdsParam = urlParams.get('assetIds'); + const singleAssetId = urlParams.get('assetId'); - try { - const response = await assetsApi.upload(uploadedFile); - setAssetId(response.data.id); - toast.success('Image uploaded!'); - } catch (err) { - toast.error('Failed to upload image'); - setFile(null); - } finally { - setUploading(false); + if ((assetIdsParam || singleAssetId) && assetsApi) { + const ids = assetIdsParam ? assetIdsParam.split(',') : [singleAssetId!]; + Promise.all(ids.map(id => assetsApi.get(id))) + .then(responses => { + const newItems = responses.map((res: any) => { + const asset = res.data; + return { + id: Math.random().toString(36).substring(7), + assetId: asset.id, + filename: asset.original_filename, + status: 'pending' as const + }; + }); + setQueue(prev => { + // Dedup + const existing = new Set(prev.map(p => p.assetId)); + return [...prev, ...newItems.filter(i => !existing.has(i.assetId))]; + }); + // Clear URL + window.history.replaceState({}, '', '/text/alt-text'); + }).catch(console.error); + } } + }); + + const handleFileUpload = (files: File[]) => { + const newItems: QueueItem[] = files.map(file => ({ + id: Math.random().toString(36).substring(7), + file, + filename: file.name, + status: 'pending' + })); + setQueue(prev => [...prev, ...newItems]); + toast.success(`${files.length} images added to queue`); }; - const handleGenerate = async () => { - if (!assetId) { - toast.error('Please upload an image first'); - return; - } - - setLoading(true); - setResults(null); + const processItem = async (item: QueueItem) => { + if (item.status === 'completed' || item.status === 'processing') return item; try { - const response = await modulesApi.generateAltText({ - asset_id: assetId, - }); + // 1. Upload if needed + let assetId = item.assetId; + if (!assetId && item.file) { + setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'uploading' } : i)); + try { + const uploadRes = await assetsApi.upload(item.file); + assetId = uploadRes.data.id; + } catch (uploadErr: any) { + if (uploadErr.response?.status === 409) { + const existingAssetId = uploadErr.response.data.detail.asset_id; + const shouldOverwrite = window.confirm( + `File "${item.file.name}" already exists. \nClick OK to Overwrite, Cancel to Use Existing.` + ); + if (shouldOverwrite) { + const uploadRes = await assetsApi.upload(item.file, undefined, false, true); // overwrite=true + assetId = uploadRes.data.id; + } else { + assetId = existingAssetId; + } + } else { + throw uploadErr; + } + } + setQueue(prev => prev.map(i => i.id === item.id ? { ...i, assetId, status: 'pending' } : i)); + } + + // 2. Generate + setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'processing' } : i)); + const response = await modulesApi.generateAltText({ asset_id: assetId }); const job = response.data; - setJobId(job.id); + + // Add to global store to track there too addJob({ id: job.id, module: 'alt_text_generator', @@ -58,37 +115,84 @@ export default function AltTextPage() { created_at: job.created_at, }); - toast.success('Alt text generation started!'); + // Simple polling for the queue item + let currentJob = job; + while (currentJob.status !== 'completed' && currentJob.status !== 'failed') { + await new Promise(resolve => setTimeout(resolve, 2000)); + const pollRes = await jobsApi.get(job.id); + + if (pollRes?.data) currentJob = pollRes.data; + else break; + } + + if (currentJob.status === 'completed' && currentJob.output_data) { + const result = { + shortAlt: currentJob.output_data.short_alt_text, + longAlt: currentJob.output_data.long_alt_text + }; + setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'completed', result, jobId: job.id } : i)); + return { ...item, status: 'completed', result }; + } else { + throw new Error(currentJob.error_message || 'Job failed'); + } + } catch (err: any) { - toast.error(err.response?.data?.detail || 'Failed to start generation'); - setLoading(false); + console.error(err); + const errMsg = err.message || 'Failed'; + setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'error', error: errMsg } : i)); + return { ...item, status: 'error', error: errMsg }; } }; - const handleJobComplete = async (job: any) => { - setLoading(false); - updateJob(job.id, { status: 'completed', progress: 100 }); + const handleProcessQueue = async () => { + setProcessing(true); + const pending = queue.filter(i => i.status === 'pending' || i.status === 'error'); - if (job.output_data) { - setResults({ - shortAlt: job.output_data.short_alt_text, - longAlt: job.output_data.long_alt_text, - raw: job.output_data.raw_response, - }); - toast.success('Alt text generated!'); + // Process strictly sequentially to avoid rate limits? Or parallel? + // Parallel limit 3 + const limit = 3; + for (let i = 0; i < pending.length; i += limit) { + const chunk = pending.slice(i, i + limit); + await Promise.all(chunk.map(item => processItem(item))); } + + setProcessing(false); + toast.success('Queue processing complete'); }; - const handleJobError = (error: string) => { - setLoading(false); - toast.error(error); + const handleDownloadCSV = () => { + const completed = queue.filter(i => i.status === 'completed' && i.result); + if (!completed.length) return; + + const headers = ['Filename', 'Short Alt Text', 'Long Alt Text']; + const rows = completed.map(item => [ + item.filename || 'unknown', + `"${(item.result?.shortAlt || '').replace(/"/g, '""')}"`, + `"${(item.result?.longAlt || '').replace(/"/g, '""')}"` + ]); + + const csvContent = [headers.join(','), ...rows.map(r => r.join(','))].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'alt_text_results.csv'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); }; - const copyToClipboard = (text: string, field: string) => { + const handleClearQueue = () => { + setQueue([]); + }; + + const removeItem = (id: string) => { + setQueue(prev => prev.filter(i => i.id !== id)); + }; + + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); - setCopied(field); - toast.success('Copied to clipboard!'); - setTimeout(() => setCopied(null), 2000); + toast.success('Copied!'); }; return ( @@ -99,156 +203,107 @@ export default function AltTextPage() {

Alt Text Generator

-

Generate accessible alt text for images using GPT-4 Vision

+

Batch generate accessible alt text for images

-
+
{/* Upload Section */} -
-
- - { - setFile(null); - setAssetId(null); - setResults(null); - }} - label="Upload an image to analyze" - /> - {uploading && ( -

Uploading...

- )} +
+ +
+ + {/* Queue Actions */} + {queue.length > 0 && ( +
+
+ Queue: {queue.length} items ({queue.filter(i => i.status === 'completed').length} completed) +
+
+ + + +
+ )} - {/* Preview */} - {assetId && ( -
- Preview -
- )} + {/* Queue List */} +
+ {queue.map(item => ( +
+ {/* Thumbnail */} +
+ thumb +
- {/* Generate Button */} - - - {/* Job Progress */} - {jobId && loading && ( - - )} -
- - {/* Results Section */} -
-

Generated Alt Text

- {results ? ( -
- {/* Short Alt */} -
-
-

- Short Version - (150 chars max) -

-
-

{results.shortAlt}

-

- {results.shortAlt?.length || 0} characters -

-
- {/* Long Alt */} -
-
-

- Long Version - (400 chars max) -

- -
-

{results.longAlt}

-

- {results.longAlt?.length || 0} characters -

-
- - {/* HTML Snippets */} -
-

Ready-to-use HTML

-
-
-

Short version:

- - {`${results.shortAlt}`} - + {/* Status / Result */} + {item.status === 'completed' && item.result ? ( +
+
+
+ Short Alt + +
+

{item.result.shortAlt}

+
+
+
+ Long Alt + +
+

{item.result.longAlt}

+
-
-

Long version (with aria):

- - {`${results.shortAlt}\n

${results.longAlt}

`} -
+ ) : ( +
+ {item.status === 'pending' && Waiting...} + {item.status === 'uploading' && Uploading...} + {item.status === 'processing' && Analyzing...} + {item.status === 'error' && Error: {item.error}}
-
+ )}
- ) : ( -
- -

Alt text will appear here

-
- )} + ))}
- - {/* Accessibility Tips */} -
-

Alt Text Best Practices

-
    -
  • • Use the short version for simple images in context
  • -
  • • Use the long version for complex images or when detail matters
  • -
  • • Alt text should describe the purpose, not just the appearance
  • -
  • • Avoid redundant phrases like "image of" or "picture of"
  • -
  • • Include text visible in the image when relevant
  • -
-
); } diff --git a/frontend/app/text/mermaid-renderer/page.tsx b/frontend/app/text/mermaid-renderer/page.tsx index 3c680cd..a2485f7 100644 --- a/frontend/app/text/mermaid-renderer/page.tsx +++ b/frontend/app/text/mermaid-renderer/page.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { toast } from 'react-hot-toast'; -import { Image as ImageIcon, Download, Sparkles } from 'lucide-react'; +import { Image as ImageIcon, Download, Sparkles, Upload, FileCode } from 'lucide-react'; import { modulesApi } from '@/lib/api'; const themes = [ @@ -10,6 +10,7 @@ const themes = [ { value: 'dark', label: 'Dark' }, { value: 'forest', label: 'Forest' }, { value: 'neutral', label: 'Neutral' }, + { value: 'forge', label: 'Forge (Yellow/White)' }, ]; const formats = [ @@ -30,6 +31,7 @@ export default function MermaidRendererPage() { const [background, setBackground] = useState('transparent'); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); + const fileInputRef = useRef(null); const handleRender = async () => { if (!code.trim()) { @@ -67,6 +69,37 @@ export default function MermaidRendererPage() { toast.success('Downloaded!'); }; + const handleExportCode = () => { + if (!code.trim()) return; + const blob = new Blob([code], { type: 'text/vnd.mermaid' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'diagram.mmd'; + a.click(); + window.URL.revokeObjectURL(url); + toast.success('Code exported!'); + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + setCode(content); + toast.success('Diagram code imported!'); + }; + reader.readAsText(file); + // Reset input so same file can be selected again + e.target.value = ''; + }; + return (
@@ -83,9 +116,34 @@ export default function MermaidRendererPage() { {/* Controls */}
- +
+ +
+ + + +
+